Skip to content
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
8f7a98b
feat: bi-so101
bingogome Nov 2, 2025
e5eb811
feat: lekiwi base and gamepad teleop
bingogome Nov 2, 2025
08206f5
feat: xlerobot top cam mount robot and teleop gamepad
bingogome Nov 2, 2025
d40e40a
fix: added gamepad calibration and bash script
bingogome Nov 2, 2025
0e272c0
feat: mount teleop calibration and bash script
bingogome Nov 2, 2025
9ffe2a0
Add biwheel-base and keyboard teleporation
hyy02 Nov 3, 2025
d4c2fef
Merge pull request #3 from Vector-Wangel/feat-xlerobot-mount
bingogome Nov 4, 2025
268f4b0
Merge pull request #1 from Vector-Wangel/feat-bi-so101
bingogome Nov 4, 2025
f80d9db
Merge branch 'dev-xlerobot' into feat-lekiwi-base
bingogome Nov 4, 2025
9ccabad
Merge pull request #2 from Vector-Wangel/feat-lekiwi-base
bingogome Nov 4, 2025
14a8d9b
fix bi-wheel speed levels
hyy02 Nov 4, 2025
7b7ad3f
sync pyproject.toml
hyy02 Nov 4, 2025
3648c6c
feat: xle and xle leader+gamepad
bingogome Nov 7, 2025
3bbd6c1
Merge branch 'dev-xlerobot' into feat-biwheel-base
bingogome Nov 7, 2025
06a6706
Merge pull request #8 from Vector-Wangel/feat-biwheel-base
bingogome Nov 7, 2025
a747c35
feat: biwheel gamepad teleoperator
bingogome Nov 7, 2025
9007024
feat: xlerobot configurable mobile base
bingogome Nov 8, 2025
2ff169e
lint: pre commit and references
bingogome Nov 8, 2025
8ef21ee
fix: change the gamepad calibration to delta direction
hyy02 Nov 9, 2025
b61a7d8
fix: empty sub robot (compositional run)
bingogome Nov 10, 2025
37d0c0a
lint: precommit
bingogome Nov 10, 2025
07b5ad2
Merge branch 'huggingface:main' into dev-xlerobot
bingogome Nov 10, 2025
3470e2c
demo: video link
bingogome Nov 11, 2025
baa786d
refactor: collect xlerobot sub-robots
bingogome Nov 12, 2025
6570881
feat: shared bus mode
bingogome Nov 12, 2025
6be6bb4
fix
bingogome Nov 13, 2025
05bb02f
lint: pre-commit
bingogome Nov 19, 2025
95710d1
readme: xlerobot
bingogome Nov 19, 2025
def080f
readme: xlerobot
bingogome Nov 19, 2025
f2a46d1
lint: pre-commit
bingogome Nov 19, 2025
a51ea67
Merge branch 'main' into dev-xlerobot
jadechoghari Nov 20, 2025
0d04e4e
fix: addressing reviews
bingogome Nov 20, 2025
c5f8da9
fix: addressing Copilot review
bingogome Nov 20, 2025
b2c963a
revert: super init and config order for keyboard
bingogome Nov 26, 2025
2e75fc0
revert: super init and config order for phone
bingogome Nov 26, 2025
5990f4f
Merge branch 'main' into dev-xlerobot
jadechoghari Dec 15, 2025
85d90dc
sync: huggingface/lerobot:main
bingogome Jan 26, 2026
d9bfa54
feat: odrive biwheel and config by json
bingogome Jan 27, 2026
ff65289
merge: hf main
bingogome Mar 2, 2026
f37a49d
feat: new keyboard composite / ssh to edge usable
bingogome Mar 3, 2026
e1d0b0d
feat: keyboard composite, panthera arm
bingogome Mar 3, 2026
00077a2
fix: cartesian imp ctrl instability, switch to joint imp ctrl
bingogome Mar 5, 2026
7f26c7b
feat: gripper torque obs
bingogome Mar 5, 2026
60ce4ea
readme: xlerobot remote teleop
bingogome Mar 11, 2026
c3bc5a0
feat: switch Panthera polar EE to Cartesian EE, added reversable dire…
bingogome Mar 11, 2026
4372b1a
fix: remove polar
bingogome Mar 11, 2026
bec0c7d
readme: mac os x11 key forwarding for Rerun's teleop
bingogome Mar 11, 2026
875f5ec
Merge branch 'hf-main' into dev-xlerobot
bingogome Mar 11, 2026
1a3c846
remove: legacy biso101
bingogome Mar 12, 2026
831386f
fix: recurssive draccus config parsing, typed using dict
bingogome Mar 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/lerobot/robots/bi_so101_follower/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# BiSO-101 Follower Robot

The `bi_so101_follower` robot lets LeRobot control two SO-101 follower arms (left/right) as a single synchronized robot. It follows the same design as our SO-100 bimanual implementation but upgrades all joints and calibration paths to the SO-101 hardware designed by [TheRobotStudio](https://github.com/TheRobotStudio/SO-ARM100).

- Wraps two `SO101Follower` instances and exposes their joints with `left_*/right_*` prefixes so policies and teleoperators can address both arms with a single action dictionary (see `action_features` in `bi_so101_follower.py`).
- Shares camera streams between the arms. The config accepts arbitrary `CameraConfig` entries and they are automatically wired in `observation_features`.
- Fully compatible with `lerobot-record`, `lerobot-replay`, and `lerobot-teleoperate`.
18 changes: 18 additions & 0 deletions src/lerobot/robots/bi_so101_follower/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#!/usr/bin/env python
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't need this anymore if I'm not wrong

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed both bi_so101_follower and bi_so101_leader. Modified xlerobot teleop / configs / examples to use bi_so_leader (xlerobot subrobots already uses the so_follower alias). Changes in 1a3c846

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test with bi arm failed with a recurrsion issue of draccus config. Fixed using dict type instead of direct biarm TeleoperatorConfig. 831386f


# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from .bi_so101_follower import BiSO101Follower
from .config_bi_so101_follower import BiSO101FollowerConfig
163 changes: 163 additions & 0 deletions src/lerobot/robots/bi_so101_follower/bi_so101_follower.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env python

# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
import time
from functools import cached_property
from typing import Any

from lerobot.cameras.utils import make_cameras_from_configs
from lerobot.robots.so101_follower import SO101Follower
from lerobot.robots.so101_follower.config_so101_follower import SO101FollowerConfig

from ..robot import Robot
from .config_bi_so101_follower import BiSO101FollowerConfig

logger = logging.getLogger(__name__)


class BiSO101Follower(Robot):
"""
Implementation based on src/lerobot/robots/bi_so100_follower/bi_so100_follower.py
[Bimanual SO-101 Follower Arms (in the same repository as so 100)](https://github.com/TheRobotStudio/SO-ARM100) designed by TheRobotStudio
"""

config_class = BiSO101FollowerConfig
name = "bi_so101_follower"

def __init__(self, config: BiSO101FollowerConfig):
super().__init__(config)
self.config = config

left_arm_config = SO101FollowerConfig(
id=f"{config.id}_left" if config.id else None,
calibration_dir=config.calibration_dir,
port=config.left_arm_port,
disable_torque_on_disconnect=config.left_arm_disable_torque_on_disconnect,
max_relative_target=config.left_arm_max_relative_target,
use_degrees=config.left_arm_use_degrees,
cameras={},
)

right_arm_config = SO101FollowerConfig(
id=f"{config.id}_right" if config.id else None,
calibration_dir=config.calibration_dir,
port=config.right_arm_port,
disable_torque_on_disconnect=config.right_arm_disable_torque_on_disconnect,
max_relative_target=config.right_arm_max_relative_target,
use_degrees=config.right_arm_use_degrees,
cameras={},
)

self.left_arm = SO101Follower(left_arm_config)
self.right_arm = SO101Follower(right_arm_config)
self.cameras = make_cameras_from_configs(config.cameras)

@property
def _motors_ft(self) -> dict[str, type]:
return {f"left_{motor}.pos": float for motor in self.left_arm.bus.motors} | {
f"right_{motor}.pos": float for motor in self.right_arm.bus.motors
}

@property
def _cameras_ft(self) -> dict[str, tuple]:
return {
cam: (self.config.cameras[cam].height, self.config.cameras[cam].width, 3) for cam in self.cameras
}

@cached_property
def observation_features(self) -> dict[str, type | tuple]:
return {**self._motors_ft, **self._cameras_ft}

@cached_property
def action_features(self) -> dict[str, type]:
return self._motors_ft

@property
def is_connected(self) -> bool:
return (
self.left_arm.bus.is_connected
and self.right_arm.bus.is_connected
and all(cam.is_connected for cam in self.cameras.values())
)

def connect(self, calibrate: bool = True) -> None:
self.left_arm.connect(calibrate)
self.right_arm.connect(calibrate)

for cam in self.cameras.values():
cam.connect()

@property
def is_calibrated(self) -> bool:
return self.left_arm.is_calibrated and self.right_arm.is_calibrated

def calibrate(self) -> None:
self.left_arm.calibrate()
self.right_arm.calibrate()

def configure(self) -> None:
self.left_arm.configure()
self.right_arm.configure()

def setup_motors(self) -> None:
self.left_arm.setup_motors()
self.right_arm.setup_motors()

def get_observation(self) -> dict[str, Any]:
obs_dict = {}

# Add "left_" prefix
left_obs = self.left_arm.get_observation()
obs_dict.update({f"left_{key}": value for key, value in left_obs.items()})

# Add "right_" prefix
right_obs = self.right_arm.get_observation()
obs_dict.update({f"right_{key}": value for key, value in right_obs.items()})

for cam_key, cam in self.cameras.items():
start = time.perf_counter()
obs_dict[cam_key] = cam.async_read()
dt_ms = (time.perf_counter() - start) * 1e3
logger.debug(f"{self} read {cam_key}: {dt_ms:.1f}ms")

return obs_dict

def send_action(self, action: dict[str, Any]) -> dict[str, Any]:
# Remove "left_" prefix
left_action = {
key.removeprefix("left_"): value for key, value in action.items() if key.startswith("left_")
}
# Remove "right_" prefix
right_action = {
key.removeprefix("right_"): value for key, value in action.items() if key.startswith("right_")
}

send_action_left = self.left_arm.send_action(left_action)
send_action_right = self.right_arm.send_action(right_action)

# Add prefixes back
prefixed_send_action_left = {f"left_{key}": value for key, value in send_action_left.items()}
prefixed_send_action_right = {f"right_{key}": value for key, value in send_action_right.items()}

return {**prefixed_send_action_left, **prefixed_send_action_right}

def disconnect(self):
self.left_arm.disconnect()
self.right_arm.disconnect()

for cam in self.cameras.values():
cam.disconnect()
39 changes: 39 additions & 0 deletions src/lerobot/robots/bi_so101_follower/config_bi_so101_follower.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env python

# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from dataclasses import dataclass, field

from lerobot.cameras import CameraConfig

from ..config import RobotConfig


@RobotConfig.register_subclass("bi_so101_follower")
@dataclass
class BiSO101FollowerConfig(RobotConfig):
left_arm_port: str
right_arm_port: str

# Optional
left_arm_disable_torque_on_disconnect: bool = True
left_arm_max_relative_target: float | dict[str, float] | None = None
left_arm_use_degrees: bool = False
right_arm_disable_torque_on_disconnect: bool = True
right_arm_max_relative_target: float | dict[str, float] | None = None
right_arm_use_degrees: bool = False

# cameras (shared between both arms)
cameras: dict[str, CameraConfig] = field(default_factory=dict)
20 changes: 20 additions & 0 deletions src/lerobot/robots/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ def make_robot_from_config(config: RobotConfig) -> Robot:
from .lekiwi import LeKiwi

return LeKiwi(config)
elif config.type == "lekiwi_base":
from .xlerobot.sub_robots.lekiwi_base import LeKiwiBase

return LeKiwiBase(config)
elif config.type == "hope_jr_hand":
from .hope_jr import HopeJrHand

Expand All @@ -52,6 +56,10 @@ def make_robot_from_config(config: RobotConfig) -> Robot:
from .bi_so100_follower import BiSO100Follower

return BiSO100Follower(config)
elif config.type == "bi_so101_follower":
from .bi_so101_follower import BiSO101Follower

return BiSO101Follower(config)
elif config.type == "reachy2":
from .reachy2 import Reachy2Robot

Expand All @@ -60,6 +68,18 @@ def make_robot_from_config(config: RobotConfig) -> Robot:
from tests.mocks.mock_robot import MockRobot

return MockRobot(config)
elif config.type == "biwheel_base":
from .xlerobot.sub_robots.biwheel_base import BiWheelBase

return BiWheelBase(config)
elif config.type == "xlerobot_mount":
from .xlerobot.sub_robots.xlerobot_mount import XLeRobotMount

return XLeRobotMount(config)
elif config.type == "xlerobot":
from .xlerobot import XLeRobot

return XLeRobot(config)
else:
try:
return cast(Robot, make_device_from_device_class(config))
Expand Down
89 changes: 89 additions & 0 deletions src/lerobot/robots/xlerobot/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# XLeRobot Modular Platform

An example run using `./src/lerobot/teleoperators/xlerobot_teleoperator/run.sh` : [video](https://drive.google.com/file/d/1Kqvb8zP6Zjkz2CuB5h4jL4ymOBka8ckQ/view?usp=sharing)

`xlerobot` is a fully mobile manipulator robot by composing:

- **Dual SO-101 follower arms** with shared calibration assets.
- **Mobile base** abstraction with current support for `lekiwi_base` and `biwheel_base`.
- **Pan/Tilt camera mount** driven by Feetech servos.
- **Multi-camera rig** wired through the standard camera factory so training pipelines receive synchronized RGB frames.
- **Shared motor buses** that multiplex multiple components on one serial port, reducing cabling and easing deployment on embedded PCs.

This is the same configuration showcased in the [XLeRobot demo run script](run.sh) and mirrors the hardware described in the linked community projects.

## The robot class

`XLeRobot` orchestrates several sub-robots, each with its own configuration/handshake needs. The class:

- Provides shared bus configs, injects IDs, and enforces that every component is routed through their declared shared bus (`shared_buses`).
- Bridges component observations and actions into a single namespace (`left_*`, `right_*`, `x.vel`, `mount_pan.pos`, …) for policies and scripts.
- Keeps the newest camera frame around in case a sensor read fails mid-run, which is crucial during mobile deployments.
- Provides safe connect/disconnect/calibration routines that cascade to all mounted components.
- Integrates with updated `lerobot-record`, `lerobot-replay`, and `lerobot-teleoperate` commands. No custom code required to capture trajectories or run inference.

## Configuration example

Simply run the demo run script under `./src/lerobot/teleoperators/xlerobot_teleoperator/run.sh`. Or, if needed, you may create an XLeRobotConfig instance by configuring it like below.

Note, make sure on the shared buses, you have set the motor ID correctly. In subrobot's configs, the motor IDs index from 1. In the `shared_buses` field, the subrobot's IDs will be shifted by `motor_id_offset`. For example, the `pan_motor_id` for the `mount` will be 1 + 6 = 7. So, you would also need to set the FeeTech motor to be 7 using supported motor programming tool. This ensures the IDs do no collide with the other subrobots on the same bus.

XLeRobot's default is to connect left arm and the mount on the same board, and right arm and the base on the same board. Refer to [doc](https://xlerobot.readthedocs.io/en/latest/hardware/getting_started/assemble.html) for more details.

If you prefer to have each sub robot on a different board, configure each with a shared bus and you are good to go.

```yaml
robot:
type: xlerobot

left_arm: {id: xlerobot_arm_left}
right_arm: {id: xlerobot_arm_right}
base:
type: lekiwi_base
base_motor_ids: [2, 1, 3]
wheel_radius_m: 0.05
base_radius_m: 0.125
mount:
pan_motor_id: 1
tilt_motor_id: 2
motor_model: sts3215
pan_key: "mount_pan.pos",
tilt_key: "mount_tilt.pos",
max_pan_speed_dps: 60.0,
max_tilt_speed_dps: 45.0,
pan_range: [-90.0, 90.0],
tilt_range: [-30.0, 60.0]

cameras:
top:
type: opencv
index_or_path: 8
width: 640
height: 480
fps: 30

shared_buses:
left_bus:
port: /dev/ttyACM2
components:
- {component: left_arm}
- {component: mount, motor_id_offset: 6}
right_bus:
port: /dev/ttyACM3
components:
- {component: right_arm}
- {component: base, motor_id_offset: 6}
```

With this config you can drive/record the platform via standard `lerobot-teleoperate`, `lerobot-record`, and `lerobot-replay`.

Customize the base type (`lekiwi_base` vs `biwheel_base`), mount gains, or camera set without editing Python. The config pipeline handles serialization, validation, and processor wiring for you.

# XLeRobot integration based on

- https://www.hackster.io/brainbot/brainbot-big-brain-with-xlerobot-ad1b4c
- https://github.com/Astera-org/brainbot
- https://github.com/Vector-Wangel/XLeRobot
- https://github.com/bingogome/lerobot

---
24 changes: 24 additions & 0 deletions src/lerobot/robots/xlerobot/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/usr/bin/env python

# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# XLeRobot integration based on
# https://www.hackster.io/brainbot/brainbot-big-brain-with-xlerobot-ad1b4c
# https://github.com/Astera-org/brainbot
# https://github.com/Vector-Wangel/XLeRobot
# https://github.com/bingogome/lerobot

from .config_xlerobot import XLeRobotConfig
from .xlerobot import XLeRobot
Loading