This project contains examples of SO101-to-SO101 teleoperation using the SO101 (SO-ARM100) arm. Examples are split into two groups:
single_follower/— one leader arm drives one follower arm (or the on-screen URDF). Covers basic teleop, Neuracore data collection, episode replay, policy rollout, and Meta Quest IK control.dual_follower/— two leader arms (or two Meta Quest controllers) drive two follower arms simultaneously. Covers dual leader teleop, Neuracore data collection, episode replay, and dual-arm Meta Quest IK control.
All examples can be run with only the hardware you have — a physical follower arm and a second set of arms are both optional, and Meta Quest is only needed for the IK-based examples.
- Python 3.10+
- Conda (recommended)
- Leader arm (required): One SO101 arm (LeRobot SO101 leader) with calibration — needed for all
single_followerexamples - Follower arm (optional): Second SO101 arm for real-robot teleop; omit to drive the on-screen URDF only
- Second leader + follower pair (optional): Two additional SO101 arms (one leader, one follower) for the
dual_followerexamples - Meta Quest (optional): Meta Quest 2/3/Pro headset — required only for
single_follower/7_tune_quest_teleop_params.pyand alldual_followerQuest examples (4_dual_quest_teleop.py,5_collect_dual_quest_teleop.py)
cd example_lerobot_so101
conda env create -f environment.yaml
conda activate so101-teleopThe teleoperation scripts do not require lerobot at runtime — feetech-servo-sdk (already in environment.yaml, imported as scservo_sdk) communicates with the motors directly.
However, the lerobot CLI tools are still needed once to set up motor IDs and calibrate the leader arm. Install lerobot with the feetech extra (from the lerobot repo):
pip install -e ".[feetech]"You can uninstall it again after calibration is done.
If your LeRobot is already calibrated and you decide you do not need to use lerobot CLI tools, you can use a script local to this repo to discover the ports your leader and follower are connected to:
python scripts/lerobot_find_port.py-
Find the USB port for the SO101 controller:
lerobot-find-port
Use the reported port (e.g.
/dev/ttyACM0or/dev/ttyUSB0). -
Set motor IDs and baudrate (1 Mbps standard). Do this before full assembly so you can access each motor:
lerobot-setup-motors \ --robot.type=so101_follower \ --robot.port=/dev/ttyACM0Or set each motor manually; see LeRobot SO101 docs.
-
Linux: Please remember to grant access to the USB port:
sudo chmod 666 /dev/ttyACM0
Or add a udev rule so your user can access the device without sudo.
The leader arm must be calibrated so joint readings are correct:
lerobot-calibrate \
--teleop.type=so101_leader \
--teleop.port=/dev/ttyACM0 \
--teleop.id=my_awesome_leader_armUse the same --teleop.id when running the example (--leader-id).
- Connect leader and follower to different USB ports (e.g. leader on
/dev/ttyACM0, follower on/dev/ttyUSB0). - Leader uses the calibration id (
--leader-id). - Follower uses the id you gave when running
lerobot-setup-motorsor when calibrating the follower (--follower-id).
- URDF:
examples/common/configs.pysetsURDF_PATHtoso101_description/urdf/so101_minimal.urdf. For accurate mesh, use the official SO-ARM100 URDF (seeso101_description/urdf/README.md). - Neutral pose:
NEUTRAL_JOINT_ANGLESinconfigs.py(5 body joints in degrees). - Joint names: SO101 uses
shoulder_pan,shoulder_lift,elbow_flex,wrist_flex,wrist_roll(+ gripper). - Camera (USB webcam): The camera thread in
examples/common/threads/camera.pyuses OpenCV and a basic USB webcam. Inconfigs.pyyou can setCAMERA_DEVICE_INDEX(0 = first camera),CAMERA_WIDTH,CAMERA_HEIGHT, andCAMERA_FRAME_STREAMING_RATE. Start the camera thread from your script if you need RGB frames (e.g. for logging or visualization).
Drive the SO101 URDF in the GUI with the leader arm:
python examples/single_follower/1_leader_arm_teleop_so101.py --leader-port /dev/ttyACM0 --leader-id my_awesome_leader_armDrive the physical follower arm with the leader:
python examples/single_follower/1_leader_arm_teleop_so101.py --real-robot \
--leader-port /dev/ttyACM0 --leader-id my_awesome_leader_arm \
--follower-port /dev/ttyUSB0 --follower-id my_awesome_follower_arm- Enable robot in the GUI before moving the leader; the follower will then follow.
- Home sends the follower to the neutral pose defined in
configs.py. - Ctrl+C shuts down cleanly.
Stream and record teleoperation data (joint positions, gripper, RGB camera) to Neuracore for training:
python examples/single_follower/2_collect_teleop_data_with_neuracore.py \
--leader-port /dev/ttyACM0 --leader-id my_awesome_leader_arm \
--follower-port /dev/ttyACM1 --follower-id my_awesome_follower_arm \
--dataset-name so101-demoPrerequisites: A Neuracore account. The script calls nc.login() on startup — set your credentials beforehand (see Neuracore docs).
What it does:
- Connects to Neuracore, creates (or reuses) the named dataset, and streams live data.
- The real SO101 follower is always active — the robot is auto-enabled at startup.
- Streams joint positions, gripper state, and RGB frames from a USB webcam simultaneously.
- Recording episodes is controlled from the Neuracore web UI (start / stop recording there).
- Press Ctrl+C to stop teleoperation and shut down cleanly; any active recording is stopped automatically.
Optional arguments:
| Flag | Default | Description |
|---|---|---|
--leader-port |
/dev/ttyACM0 |
Serial port for the leader arm |
--leader-id |
my_awesome_leader_arm |
Calibration id (matches --teleop.id used with lerobot-calibrate) |
--follower-port |
/dev/ttyUSB0 |
Serial port for the follower arm |
--follower-id |
my_awesome_follower_arm |
Follower arm id |
--dataset-name |
so101-teleop-data-<timestamp> |
Dataset name in Neuracore |
Script: examples/single_follower/3_replay_neuracore_episodes.py
Replay recorded episodes from a Neuracore dataset on the physical robot.
python3 examples/single_follower/3_replay_neuracore_episodes.py --dataset-name <dataset-name> --frequency <hz> --follower-port <follower-port> --follower-id my_awesome_follower_arm --episode-index <idx>Arguments:
--dataset-name: Name of the Neuracore dataset to replay--frequency: Playback frequency in Hz (default: 0). 0 plays the data aperiodically (not synchronized at a certain frequency as it was recorded).--follower-port: The port on which your follower is connected (default:/dev/ttyACM1)--follower-id: The follower ID name you set for your follower arm--episode-index: Which episode to replay (default: 0). -1 will start replaying all the episodes one after the other.
NOTE: please be careful that the robot will start moving on the same trajectory that was recorded. Pressing ctrl+C
will gracefully disable the robot and it will cut power to the motors after 5 seconds.
After collecting data with example 2 and training a policy in Neuracore, use these scripts to test it on the SO101 follower. They use the same embodiment channels as data collection: six joint positions (including a pseudo gripper joint), parallel gripper open amount, and RGB camera rgb.
Example 4 – interactive rollout with leader teleop and Viser
python examples/single_follower/4_rollout_neuracore_policy.py \
--train-run-name YOUR_RUN_NAME \
--leader-port /dev/ttyACM0 --leader-id my_awesome_leader_arm \
--follower-port /dev/ttyACM1 --follower-id my_awesome_follower_arm- SO101 leader arm teleop (same mapping as example 2), not Meta Quest — Agilex example 4 uses Quest; SO101 uses a second SO101 arm as leader.
- Viser workflow: Enable Robot → Engage Leader Teleop (replaces Quest “hold grip”) → move leader → Run Policy / execute.
- Viser GUI (
http://localhost:8080): enable/disable, engage leader teleop, home, run policy, execute horizon, continuous play, live camera + ghost preview. - Policy source:
--train-run-name,--model-path, or--remote-endpoint-name.
Example 5 – minimal terminal-only rollout
python examples/single_follower/5_rollout_neuracore_policy_minimal.py \
--train-run-name YOUR_RUN_NAME \
--follower-port /dev/ttyACM1 --follower-id my_awesome_follower_armNo leader arm or GUI: enables the follower, homes, then loops predict → execute. Ctrl+C homes and exits.
Example 6 – visualize policy from a dataset
python examples/single_follower/6_visualize_policy_from_dataset.py \
--dataset-name so101-demo \
--train-run-name YOUR_RUN_NAMELoads a random synchronized dataset step, runs the policy, and animates the predicted horizon in Viser (no real robot).
Use two SO101 leader arms to drive two SO101 follower arms simultaneously. The left leader maps directly to the left follower and the right leader maps directly to the right follower — no IK involved. Each pair uses an independent joint offset configuration (SO101_OFFSETS_DEG for the left pair, SO101_OFFSETS_DEG_2 for the right pair).
Hardware required: four SO101 arms (two leaders, two followers) on four separate USB ports.
python examples/dual_follower/1_dual_leader_teleop.py \
--left-leader-port /dev/ttyACM0 --left-leader-id my_awesome_left_leader \
--right-leader-port /dev/ttyACM2 --right-leader-id my_awesome_right_leader \
--left-follower-port /dev/ttyACM1 --left-follower-id my_awesome_left_follower \
--right-follower-port /dev/ttyACM3 --right-follower-id my_awesome_right_follower \
--real-robotOmit --real-robot to drive the dual-arm URDF only (no follower hardware needed).
Optional arguments:
| Flag | Default | Description |
|---|---|---|
--left-leader-port |
/dev/ttyACM0 |
Serial port for the left leader arm |
--left-leader-id |
my_awesome_left_leader |
Left leader calibration ID |
--right-leader-port |
/dev/ttyACM2 |
Serial port for the right leader arm |
--right-leader-id |
my_awesome_right_leader |
Right leader calibration ID |
--left-follower-port |
/dev/ttyACM1 |
Serial port for the left follower arm |
--left-follower-id |
my_awesome_left_follower |
Left follower ID |
--right-follower-port |
/dev/ttyACM3 |
Serial port for the right follower arm |
--right-follower-id |
my_awesome_right_follower |
Right follower ID |
--leader-rate |
50.0 |
Leader arm polling rate in Hz |
--real-robot |
off | Drive real follower hardware (default: URDF only) |
Viser GUI (http://localhost:8080, real-robot mode only):
- Enable / Disable both follower arms
- Home both arms
- Ghost robot preview of the leader-commanded target pose
Same as example 1 (always drives real followers) but streams joint positions, gripper states, and two RGB camera frames to Neuracore for dataset collection. Recording is controlled from the keyboard.
python examples/dual_follower/2_collect_dual_leader_teleop.py \
--left-leader-port /dev/ttyACM0 --left-leader-id my_awesome_left_leader \
--right-leader-port /dev/ttyACM2 --right-leader-id my_awesome_right_leader \
--left-follower-port /dev/ttyACM1 --left-follower-id my_awesome_left_follower \
--right-follower-port /dev/ttyACM3 --right-follower-id my_awesome_right_follower \
--dataset-name so101-dual-demoPrerequisites: A Neuracore account. The script calls nc.login() on startup — set your credentials beforehand (see Neuracore docs).
What it does:
- Connects to Neuracore, creates (or reuses) the named dataset, and streams live data at 30 Hz.
- Logs left and right joint positions and targets, gripper states, and two RGB camera frames (
rgb,rgb_2) per episode. - Followers start disabled — press
eto enable before moving leaders. - Press Ctrl+C to shut down cleanly; any active recording is stopped automatically.
Optional arguments:
| Flag | Default | Description |
|---|---|---|
--left-leader-port |
/dev/ttyACM0 |
Serial port for the left leader arm |
--left-leader-id |
my_awesome_left_leader |
Left leader calibration ID |
--right-leader-port |
/dev/ttyACM2 |
Serial port for the right leader arm |
--right-leader-id |
my_awesome_right_leader |
Right leader calibration ID |
--left-follower-port |
/dev/ttyACM1 |
Serial port for the left follower arm |
--left-follower-id |
my_awesome_left_follower |
Left follower ID |
--right-follower-port |
/dev/ttyACM3 |
Serial port for the right follower arm |
--right-follower-id |
my_awesome_right_follower |
Right follower ID |
--leader-rate |
50.0 |
Leader arm polling rate in Hz |
--dataset-name |
so101-dual-leader-teleop-<timestamp> |
Dataset name in Neuracore |
Keyboard controls:
| Key | Action |
|---|---|
e |
Enable / disable both follower arms |
r |
Start / stop Neuracore recording |
Ctrl+C |
Exit |
Replay recorded dual-arm episodes from a Neuracore dataset on the physical robot. Both arms move simultaneously following the recorded trajectories.
python examples/dual_follower/3_replay_dual_arm_episodes.py \
--dataset-name so101-dual-demo \
--frequency 30 \
--left-port /dev/ttyACM0 --left-id my_awesome_left_follower \
--right-port /dev/ttyACM1 --right-id my_awesome_right_follower \
--episode-index 0NOTE: The robot will start moving immediately on the recorded trajectory. Press Ctrl+C to stop, or q in any OpenCV window to abort the current episode.
Arguments:
| Flag | Default | Description |
|---|---|---|
--dataset-name |
(required) | Name of the Neuracore dataset to replay |
--frequency |
(required) | Playback frequency in Hz |
--left-port |
/dev/ttyACM0 |
Serial port for the left follower arm |
--left-id |
my_awesome_left_follower |
Left follower ID |
--right-port |
/dev/ttyACM1 |
Serial port for the right follower arm |
--right-id |
my_awesome_right_follower |
Right follower ID |
--episode-index |
0 |
Episode to replay; -1 replays all episodes in sequence |
The following packages are installed automatically by environment.yaml when you create the conda environment:
pin-pinkandqpsolvers[quadprog]— Pink IK solver dependenciesmeta_quest_teleop— MetaQuestReader package, pulled from NeuracoreAI/meta_quest_teleop
No extra installation steps are needed beyond the standard conda env create -f environment.yaml.
The companion app must be running on the headset (see the meta_quest_teleop README for setup). Two connection modes are supported:
- USB (recommended) — connect the headset via USB and omit
--ip-address; the device will be auto-discovered. - WiFi — ensure the headset is on the same network as your machine and pass
--ip-address <quest-ip>.
Drive the SO101 follower arm using a Meta Quest controller (right hand). Unlike the leader-arm examples, this uses Pink IK to convert the Quest controller's cartesian pose into SO101 joint angles in real time. A Viser GUI lets you tune the 1€ filter and IK parameters live.
python examples/single_follower/7_tune_quest_teleop_params.py \
--port /dev/ttyACM0 \
--follower-id my_awesome_follower_arm \
--ip-address <quest-ip> # omit for auto-discoveryOptional arguments:
| Flag | Default | Description |
|---|---|---|
--port |
/dev/ttyACM0 |
Serial port for the SO101 follower arm |
--follower-id |
my_awesome_follower_arm |
Follower arm calibration ID |
--ip-address |
None |
Meta Quest IP address (auto-discovered if omitted) |
Controls:
| Input | Action |
|---|---|
| Button A | Enable / disable robot |
| Right grip (hold) | Activate IK teleoperation (dead man's switch) |
| Move controller | Robot end-effector follows |
| Right trigger (hold) | Close gripper |
| Button B | Send robot to home position |
| Release grip | Pause teleoperation |
Viser GUI (http://localhost:8080):
- Enable / Disable robot toggle button
- Home button
- Controller filter params — tune the 1€ filter (
min_cutoff,beta,d_cutoff) live to balance smoothness vs. latency - Scaling controls — adjust
translation_scaleandrotation_scaleto calibrate how much the robot moves relative to the controller - Pink IK parameters — tune
position_cost,orientation_cost,frame_task_gain,lm_damping,damping_cost,solver_damping_valuein real time - Ghost robot — translucent preview of the IK target (where the robot is aiming, not where it currently is)
- IK solve time — timing display to monitor solver performance
Drive two SO101 arms simultaneously using both Meta Quest controllers. The left controller maps to the left arm and the right controller maps to the right arm via a single 10-DOF IK solver on the dual-arm URDF.
python examples/dual_follower/4_dual_quest_teleop.py \
--left-port /dev/ttyACM0 --left-id L1 \
--right-port /dev/ttyACM1 --right-id L1 \
--ip-address <quest-ip> # omit for auto-discoveryOptional arguments:
| Flag | Default | Description |
|---|---|---|
--left-port |
/dev/ttyACM0 |
Serial port for the left SO101 arm |
--left-id |
L1 |
Left arm follower ID |
--right-port |
/dev/ttyACM1 |
Serial port for the right SO101 arm |
--right-id |
L1 |
Right arm follower ID |
--ip-address |
None |
Meta Quest IP address (auto-discovered if omitted) |
Controls:
| Input | Action |
|---|---|
| Hold LEFT + RIGHT grip | Activate dual-arm teleoperation (dead man's switch) |
| Move left controller | Left arm end-effector follows |
| Move right controller | Right arm end-effector follows |
| Left / right trigger (hold) | Close corresponding gripper |
| Button A | Enable / disable both arms |
| Button B | Send both arms to home position |
| Ctrl+C | Exit |
Same as example 4 but streams joint positions, gripper state, and two RGB camera frames to Neuracore for dataset collection. Recording episodes is controlled directly from the Quest controller.
python examples/dual_follower/5_collect_dual_quest_teleop.py \
--left-port /dev/ttyACM0 --left-id L1 \
--right-port /dev/ttyACM1 --right-id L1 \
--ip-address <quest-ip> \
--dataset-name so101-dual-demoPrerequisites: A Neuracore account. The script calls nc.login() on startup — set your credentials beforehand (see Neuracore docs).
What it does:
- Connects to Neuracore, creates (or reuses) the named dataset, and streams live data at 30 Hz.
- Logs left and right joint positions, gripper states, and two RGB camera frames (
rgb,rgb_2) per episode. - Recording is controlled via Button X on the Quest — no web UI required.
- Press Ctrl+C to shut down cleanly; any active recording is stopped automatically.
Optional arguments:
| Flag | Default | Description |
|---|---|---|
--left-port |
/dev/ttyACM0 |
Serial port for the left SO101 arm |
--left-id |
L1 |
Left arm follower ID |
--right-port |
/dev/ttyACM1 |
Serial port for the right SO101 arm |
--right-id |
L1 |
Right arm follower ID |
--ip-address |
None |
Meta Quest IP address (auto-discovered if omitted) |
--dataset-name |
so101-dual-teleop-<timestamp> |
Dataset name in Neuracore |
Controls (all example 8 controls, plus):
| Input | Action |
|---|---|
| Button X | Start / stop Neuracore recording |
example_so101/
├── examples/
│ ├── single_follower/
│ │ ├── 1_leader_arm_teleop_so101.py # SO101 leader → SO101 follower (URDF or real robot)
│ │ ├── 2_collect_teleop_data_with_neuracore.py # Leader teleop + Neuracore data collection
│ │ ├── 3_replay_neuracore_episodes.py # Replay Neuracore episodes on real hardware
│ │ ├── 4_rollout_neuracore_policy.py # Policy rollout + leader teleop + Viser
│ │ ├── 5_rollout_neuracore_policy_minimal.py # Minimal policy rollout (terminal only)
│ │ ├── 6_visualize_policy_from_dataset.py # Policy preview from dataset (Viser only)
│ │ └── 7_tune_quest_teleop_params.py # Meta Quest IK teleop + live parameter tuning
│ ├── dual_follower/
│ │ ├── 1_dual_leader_teleop.py # Dual SO101 leaders → dual SO101 followers (URDF or real robot)
│ │ ├── 2_collect_dual_leader_teleop.py # Dual leader teleop + Neuracore data collection
│ │ ├── 3_replay_dual_arm_episodes.py # Replay dual-arm Neuracore episodes on real hardware
│ │ ├── 4_dual_quest_teleop.py # Dual-arm Meta Quest teleoperation (10-DOF IK)
│ │ └── 5_collect_dual_quest_teleop.py # Dual-arm Meta Quest teleop + Neuracore data collection
│ └── common/ # Config, data manager, visualizer, threads, STS3215 driver
├── scripts/ # Utility scripts (port finder, Viser debug controls)
├── tests/ # Unit tests (no hardware required)
├── pink_ik_solver.py # Generic Pink IK solver (used by Quest examples)
├── vectorised_posture_task.py # Pink posture task helper (used by pink_ik_solver)
├── so101_controller.py # SO101 single-arm follower controller
├── so101_dual_controller.py # SO101 dual-arm follower controller
├── so101_description/urdf/ # SO101 URDF (minimal + README for official mesh)
├── environment.yaml
└── README.md
- "Calibration file not found": Run
lerobot-calibratefor the leader with the same--teleop.idyou pass as--leader-id. The calibration JSON is saved to~/.cache/huggingface/lerobot/calibration/teleoperators/so_leader/<id>.json. - Follower not moving: Ensure the robot is enabled in the GUI and the follower is on the correct
--follower-port. - Wrong port: Use
ls /dev/tty*(orlerobot-find-portif lerobot is installed) to identify ports; leader and follower must be on different ports when using two arms. - Motor direction opposite: Some setups need per-motor direction or recalibration; see LeRobot SO101 docs.
- This software drives a physical robot. Keep a safe workspace and be ready to stop (disable in GUI or Ctrl+C).
- Start with the robot disabled and only enable after confirming the leader pose is safe.
See LICENSE file.