Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
80 changes: 80 additions & 0 deletions src/lerobot/motors/feetech/calibration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env python

# Copyright 2026 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

from lerobot.motors import Motor, MotorCalibration

from .feetech import (
FeetechMotorsBus,
OperatingMode,
)

logger = logging.getLogger(__name__)


def calibrate_partial(
bus: FeetechMotorsBus,
existing_calibration: dict[str, MotorCalibration],
motors: list[str],
device_name: str,
full_turn_motors: frozenset[str] = frozenset(),
) -> dict[str, MotorCalibration]:
if not existing_calibration:
raise ValueError(
"No existing calibration found. Run full calibration first before calibrating specific motors."
)

updated_calibration = existing_calibration.copy()
motors_to_calibrate: dict[str, Motor] = {}
logger.info(f"\nRunning partial calibration of {device_name} for motors: {set(motors)}")
bus.disable_torque()
for motor in motors:
if motor not in bus.motors:
raise ValueError(
f"Motor '{motor}' not found in the bus. Available motors: {list(bus.motors.keys())}"
)
motors_to_calibrate[motor] = bus.motors[motor]
bus.write("Operating_Mode", motor, OperatingMode.POSITION.value)

# calibrate motors one by one
logger.info(
"Calibration of selected motors will be done sequentially. During calibration, all the motors will have their torque disabled and can be moved freely, but only the specified motor will be calibrated."
)
for motor, m in motors_to_calibrate.items():
input(f"Move {motor} to the middle of its range of motion and press ENTER....")
homing_offset = int(bus.set_half_turn_homings([motor])[motor])
if motor in full_turn_motors:
range_min_val, range_max_val = 0, 4095
print(
f"'{motor}' is set as the full turn motor with a fixed range of [0, 4095]. Homing offset was recorded but no further calibration is needed. Press ENTER to continue..."
)
else:
print(
f"Move {motor} through its entire range of motion (calibration of other motors won't be affected).\nRecording positions. Press ENTER to stop..."
)
range_mins, range_maxs = bus.record_ranges_of_motion([motor])
range_min_val, range_max_val = int(range_mins[motor]), int(range_maxs[motor])

updated_calibration[motor] = MotorCalibration(
id=m.id,
drive_mode=0,
homing_offset=homing_offset,
range_min=range_min_val,
range_max=range_max_val,
)

return updated_calibration
43 changes: 39 additions & 4 deletions src/lerobot/robots/so_follower/so_follower.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
FeetechMotorsBus,
OperatingMode,
)
from lerobot.motors.feetech.calibration import calibrate_partial
from lerobot.types import RobotAction, RobotObservation
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected

Expand Down Expand Up @@ -108,7 +109,31 @@ def connect(self, calibrate: bool = True) -> None:
def is_calibrated(self) -> bool:
return self.bus.is_calibrated

def calibrate(self) -> None:
def calibrate(self, motors: list[str] | None = None) -> None:
full_turn_motor = "wrist_roll"
if motors is not None:
try:
new_calibration = calibrate_partial(
bus=self.bus,
existing_calibration=self.calibration,
motors=motors,
device_name=str(self),
full_turn_motors={full_turn_motor},
)
self.calibration = new_calibration
self.bus.write_calibration(self.calibration)
self._save_calibration()
print(f"Partial calibration of motors {set(motors)} saved to {self.calibration_fpath}")
return
except Exception:
logger.exception(
"Error occurred during partial calibration. Your previous calibration has been restored."
)
# Restore the original homing offsets of the motors that may have been modified during
# calibration before the error occurred.
self.bus.write_calibration(self.calibration)
raise

if self.calibration:
# Calibration file exists, ask user whether to use it or run new calibration
user_input = input(
Expand All @@ -128,7 +153,6 @@ def calibrate(self) -> None:
homing_offsets = self.bus.set_half_turn_homings()

# Attempt to call record_ranges_of_motion with a reduced motor set when appropriate.
full_turn_motor = "wrist_roll"
unknown_range_motors = [motor for motor in self.bus.motors if motor != full_turn_motor]
print(
f"Move all joints except '{full_turn_motor}' sequentially through their "
Expand Down Expand Up @@ -168,8 +192,19 @@ def configure(self) -> None:
self.bus.write("Protection_Current", motor, 250) # 50% of max current to avoid burnout
self.bus.write("Overload_Torque", motor, 25) # 25% torque when overloaded

def setup_motors(self) -> None:
for motor in reversed(self.bus.motors):
def setup_motors(self, motors: list[str] | None = None) -> None:
motors_to_setup: dict[str, Motor] = self.bus.motors

if motors is not None:
motors_to_setup = {}
for motor in motors:
if motor not in self.bus.motors:
raise ValueError(
f"Motor '{motor}' not found in the bus. Available motors: {list(self.bus.motors.keys())}"
)
motors_to_setup[motor] = self.bus.motors[motor]

for motor in reversed(motors_to_setup):
input(f"Connect the controller board to the '{motor}' motor only and press enter.")
self.bus.setup_motor(motor)
print(f"'{motor}' motor id set to {self.bus.motors[motor].id}")
Expand Down
27 changes: 26 additions & 1 deletion src/lerobot/scripts/lerobot_calibrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,27 @@

Example:

Default: calibrate all the motors sequentially
```shell
lerobot-calibrate \
--teleop.type=so100_leader \
--teleop.port=/dev/tty.usbmodem58760431551 \
--teleop.id=blue
```

Optional: calibrate specific motors by passing a list of motor names (e.g. "shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper")
Use the flag `--motors` to specify a comma-separated list of motor names to calibrate. This is useful when you have to recalibrate a specific motor and don't want to redo the calibration for the other motors.
```shell
lerobot-calibrate \
--teleop.type=so101_leader \
--teleop.port=/dev/tty.usbmodem58760431551 \
--teleop.id=test \
--motors=shoulder_pan,elbow_flex
```
"""

import logging
import re
from dataclasses import asdict, dataclass
from pprint import pformat

Expand Down Expand Up @@ -70,10 +82,23 @@
class CalibrateConfig:
teleop: TeleoperatorConfig | None = None
robot: RobotConfig | None = None
motors: str | None = None # comma-separated list of motors to calibrate, e.g. "shoulder_pan,elbow_flex"

def __post_init__(self):
if bool(self.teleop) == bool(self.robot):
raise ValueError("Choose either a teleop or a robot.")
self.motors_list: list[str] | None = None
if self.motors is not None:
if not re.fullmatch(r"[\w ,]+", self.motors):
raise ValueError(
f"Invalid characters in --motors='{self.motors}'. "
"Use comma-separated names (letters, digits, underscores), e.g. shoulder_pan,elbow_flex"
)
self.motors_list = [m.strip() for m in self.motors.split(",") if m.strip()]
if not self.motors_list:
raise ValueError(
"--motors flag is provided but the list is empty. Remove it or provide at least one motor name."
)

self.device = self.robot if self.robot else self.teleop

Expand All @@ -91,7 +116,7 @@ def calibrate(cfg: CalibrateConfig):
device.connect(calibrate=False)

try:
device.calibrate()
device.calibrate(motors=cfg.motors_list)
finally:
device.disconnect()

Expand Down
30 changes: 29 additions & 1 deletion src/lerobot/scripts/lerobot_setup_motors.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,26 @@

Example:

Default: setup all the motors sequentially

```shell
lerobot-setup-motors \
--teleop.type=so100_leader \
--teleop.port=/dev/tty.usbmodem575E0031751
```

Optional: setup specific motors by passing a list of motor names (e.g. "shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_pan", "gripper")
This is useful when you have to replace a motor and don't want to redo the setup for the existing motors.

```shell
lerobot-setup-motors \
--teleop.type=so101_leader \
--teleop.port=/dev/tty.usbmodem575E0031751 \
--motors=shoulder_pan,elbow_flex
```
"""

import re
from dataclasses import dataclass

import draccus
Expand Down Expand Up @@ -65,10 +78,25 @@
class SetupConfig:
teleop: TeleoperatorConfig | None = None
robot: RobotConfig | None = None
motors: str | None = (
None # comma-separated list of motor names to set up, e.g. "shoulder_pan,elbow_flex". If not provided, all motors will be set up.
)

def __post_init__(self):
if bool(self.teleop) == bool(self.robot):
raise ValueError("Choose either a teleop or a robot.")
self.motors_list: list[str] | None = None
if self.motors is not None:
if not re.fullmatch(r"[\w ,]+", self.motors):
raise ValueError(
f"Invalid characters in --motors='{self.motors}'. "
"Use comma-separated names (letters, digits, underscores), e.g. shoulder_pan,elbow_flex"
)
self.motors_list = [m.strip() for m in self.motors.split(",") if m.strip()]
if not self.motors_list:
raise ValueError(
"--motors flag is provided but the list is empty. Remove it or provide at least one motor name."
)

self.device = self.robot if self.robot else self.teleop

Expand All @@ -83,7 +111,7 @@ def setup_motors(cfg: SetupConfig):
else:
device = make_teleoperator_from_config(cfg.device)

device.setup_motors()
device.setup_motors(motors=cfg.motors_list)


def main():
Expand Down
45 changes: 40 additions & 5 deletions src/lerobot/teleoperators/so_leader/so_leader.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# !/usr/bin/env python
#!/usr/bin/env python

# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
Expand All @@ -22,6 +22,7 @@
FeetechMotorsBus,
OperatingMode,
)
from lerobot.motors.feetech.calibration import calibrate_partial
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected

from ..teleoperator import Teleoperator
Expand Down Expand Up @@ -81,7 +82,31 @@ def connect(self, calibrate: bool = True) -> None:
def is_calibrated(self) -> bool:
return self.bus.is_calibrated

def calibrate(self) -> None:
def calibrate(self, motors: list[str] | None = None) -> None:
full_turn_motor = "wrist_roll"
if motors is not None:
try:
new_calibration = calibrate_partial(
bus=self.bus,
existing_calibration=self.calibration,
motors=motors,
device_name=str(self),
full_turn_motors={full_turn_motor},
)
self.calibration = new_calibration
self.bus.write_calibration(self.calibration)
self._save_calibration()
print(f"Partial calibration of motors {set(motors)} saved to {self.calibration_fpath}")
return
except Exception:
logger.exception(
"Error occurred during partial calibration. Your previous calibration has been restored."
)
# Restore the original homing offsets of the motors that may have been modified during
# calibration before the error occurred.
self.bus.write_calibration(self.calibration)
raise

if self.calibration:
# Calibration file exists, ask user whether to use it or run new calibration
user_input = input(
Expand All @@ -100,7 +125,6 @@ def calibrate(self) -> None:
input(f"Move {self} to the middle of its range of motion and press ENTER....")
homing_offsets = self.bus.set_half_turn_homings()

full_turn_motor = "wrist_roll"
unknown_range_motors = [motor for motor in self.bus.motors if motor != full_turn_motor]
print(
f"Move all joints except '{full_turn_motor}' sequentially through their "
Expand Down Expand Up @@ -130,8 +154,19 @@ def configure(self) -> None:
for motor in self.bus.motors:
self.bus.write("Operating_Mode", motor, OperatingMode.POSITION.value)

def setup_motors(self) -> None:
for motor in reversed(self.bus.motors):
def setup_motors(self, motors: list[str] | None = None) -> None:
motors_to_setup: dict[str, Motor] = self.bus.motors

if motors is not None:
motors_to_setup = {}
for motor in motors:
if motor not in self.bus.motors:
raise ValueError(
f"Motor '{motor}' not found in the bus. Available motors: {list(self.bus.motors.keys())}"
)
motors_to_setup[motor] = self.bus.motors[motor]

for motor in reversed(motors_to_setup):
input(f"Connect the controller board to the '{motor}' motor only and press enter.")
self.bus.setup_motor(motor)
print(f"'{motor}' motor id set to {self.bus.motors[motor].id}")
Expand Down
Loading