Compare commits

...

12 Commits

Author SHA1 Message Date
dependabot[bot] fef1f0ca98 chore(deps): bump pytest in the uv group across 1 directory
Bumps the uv group with 1 update in the / directory: [pytest](https://github.com/pytest-dev/pytest).


Updates `pytest` from 8.4.2 to 9.0.3
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/8.4.2...9.0.3)

---
updated-dependencies:
- dependency-name: pytest
  dependency-version: 9.0.3
  dependency-type: direct:production
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-24 15:16:56 +00:00
Steven Palma ac5c7b8600 chore(deps): bump diffusers to >=0.38.0,<0.40.0 (#4145)
* fix(deps): bump diffusers cap to <0.39.0 (security)

Diffusers 0.35.x is affected by GHSA-98h9-4798-4q5v (HIGH, CVSS 8.8):
'trust_remote_code bypass via custom_pipeline and local custom components'.
Fixed in diffusers 0.38.0.

Current cap 'diffusers<0.36.0' blocks downstream consumers (e.g.
strands-labs/robots) from picking up the security fix.

The lerobot diffusers surface area is narrow and stable across 0.36-0.38:
- diffusers.schedulers.scheduling_ddim.DDIMScheduler
- diffusers.schedulers.scheduling_ddpm.DDPMScheduler
- diffusers.optimization.get_scheduler
- diffusers.ConfigMixin / ModelMixin / register_to_config
- diffusers.models.attention.{Attention,FeedForward}
- diffusers.models.embeddings.*

None of these were removed, renamed, or had breaking changes in 0.36, 0.37,
or 0.38 release notes. Bumping the cap to <0.39.0 unblocks the security
fix while keeping a major-version safety bound.

* chore(dependecies): bump diffusers

* chore(deps): update uv.lock

---------

Co-authored-by: Cagatay Cali <cagataycali@users.noreply.github.com>
2026-07-24 17:13:53 +02:00
Steven Palma a6befef0ba chore(dependencies): update uv.lock (#3963) 2026-07-24 16:30:36 +02:00
Steven Palma 53843007ea feat(robot): Make SO follower P coefficient configurable (#4142)
* Make SO follower P coefficient configurable

* chore(test): minimize tests

* feat(robots): expose PID coeff in SO arms

---------

Co-authored-by: taivu1998 <46636857+taivu1998@users.noreply.github.com>
2026-07-24 16:03:04 +02:00
Maxime Ellerbach d3bed0feee chore(agents): adding additional infos to AGENTS.md and bring-your-own-policies.mdx (#3904)
* chore(agents): adding additional infos to AGENTS.md

* adding `lerobot-train` requirement inside PR checklist

* prefer using code already implemented from transformers / diffusers instead of re-implementing in tree

---------

Signed-off-by: Maxime Ellerbach <maxime.ellerbach@huggingface.co>
2026-07-24 14:58:43 +02:00
Steven Palma a0eb860d1e feat(dataset): add slice support to LeRobotDataset.__getitem__ (#4129)
* feat(dataset): add efficient slice support

* fix(dataset): handle empty dataset slices

* refactor(dataset): reuse scalar path for slices

---------

Co-authored-by: Francesco Capuano <fc.francescocapuano@gmail.com>
2026-07-23 22:05:29 +02:00
Steven Palma cfd9ff969c fix(envs): set LiberoEnvConfig.fps default to 20 to match robosuite (#4124)
* fix(envs): set LiberoEnvConfig.fps default to 20 to match robosuite

LiberoEnvConfig.fps was set to 30, but the underlying robosuite
OffScreenRenderEnv always runs at its default control_freq of 20 Hz
since fps is never passed through. This mismatch silently decouples
the dataset/eval loop rate from the actual simulation step rate.

Set the default to 20 to match the real sim rate and avoid the
footgun.

Fixes #3368

* fix(libero): apply configured control frequency

---------

Co-authored-by: xinmotlanthua <275663218+xinmotlanthua@users.noreply.github.com>
2026-07-23 19:49:19 +02:00
Steven Palma f59eae4e27 fix(robots): add retries while recording motor ranges (#4126)
* Add retries while recording motor ranges

* fix(motors): throttle calibration reads consistently

---------

Co-authored-by: tom-doerr <tomdoerr96@gmail.com>
2026-07-23 18:41:48 +02:00
Martino Russi a993af9c51 fix(openarms): stop set_zero_position()ing on connect (#4058)
* fix(damiao): make is_calibrated a plain property, not cached

`is_calibrated` was a `@cached_property`, so it froze at its first-read
value and never reflected later changes to `self.calibration` (set by
connect/calibrate/load). This caused the OpenArm teleop to re-run
calibration even when a calibration file existed, and to skip
`set_zero_position()` after a fresh calibration.

Switch to `@property` (matching the MotorsBus base contract and the
Feetech/SO-100 buses) and drop the now-unused `functools.cached_property`
import.

Co-authored-by: Cursor <cursoragent@cursor.com>

* don't set_zero_position() on connect

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 18:34:13 +02:00
Steven Palma 392246feaf feat(diffusion): add gradient checkpointing for memory optimization (#4127)
* feat(diffusion): add gradient checkpointing for memory optimization

Add gradient_checkpointing config option to DiffusionPolicy. When
enabled, wraps the UNet encoder, mid, and decoder residual blocks
with torch.utils.checkpoint.checkpoint to trade compute for memory.

Allows training with larger batch sizes or higher-resolution inputs
on memory-constrained GPUs. Disabled by default.

Usage: --policy.gradient_checkpointing=true

Part of the 0.6.0 roadmap item 3.3 (gradient checkpointing for all
policies).

* test(diffusion): verify gradient checkpointing parity

---------

Co-authored-by: Jash Shah <jashshah.999@gmail.com>
2026-07-23 18:33:10 +02:00
Steven Palma 19dcbc19f1 fix(gamepad): Gamepad on macos often does not need fallback (#4125)
* gamepad does often work on macos

* review comments

* fix(gamepad): expose hidapi fallback in config

---------

Co-authored-by: Maxim Bonnaerens <maxim@bonnaerens.be>
2026-07-23 18:21:48 +02:00
Steven Palma 679faeaafc fix(scripts): register third-party plugins in lerobot_setup_motors (#4123)
* fix(scripts): register third-party plugins in setup-motors

* test(setup-motors): cover plugin registration

---------

Co-authored-by: Janos von Gencsy <janos.von-gencsy@tum.de>
2026-07-23 18:06:44 +02:00
24 changed files with 1137 additions and 938 deletions
+2 -1
View File
@@ -51,6 +51,7 @@ pre-commit run --all-files # Lint + format (ruff, typo
## Notes
- **Mypy is gradual**: strict only for `lerobot.envs`, `lerobot.configs`, `lerobot.optim`, `lerobot.model`, `lerobot.cameras`, `lerobot.motors`, `lerobot.transport`. Add type annotations when modifying these modules.
- **Optional dependencies**: many policies, envs, and robots are behind extras (e.g., `lerobot[aloha]`). New imports for optional packages must be guarded or lazy. See `pyproject.toml [project.optional-dependencies]`.
- **Imports**: prefer top-level imports; relative (`from .sibling import X`) across sibling files within a module, absolute (`from lerobot.module import X`) across modules.
- **Optional dependencies**: many policies, envs, and robots are behind extras (e.g., `lerobot[aloha]`, see `pyproject.toml`). Guard optional imports with `TYPE_CHECKING or _foo_available` at module top + a `require_package(...)` check at use time. Reuse the `_foo_available` flags in `utils/import_utils.py`; don't call `is_package_available`.
- **Video decoding**: datasets can store observations as video files. `LeRobotDataset` handles frame extraction, but tests need ffmpeg installed.
- **Prioritize use of `uv run`** to execute Python commands (not raw `python` or `pip`).
+6 -1
View File
@@ -165,6 +165,8 @@ Batches are flat dictionaries keyed by the constants in [`lerobot.utils.constant
LeRobot uses `PolicyProcessorPipeline`s to normalize inputs and de-normalize outputs around your policy. For a concrete reference, see [`processor_act.py`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/act/processor_act.py) or [`processor_diffusion.py`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/diffusion/processor_diffusion.py).
Pay close attention here: processors are the most common reproducibility pain point. A mismatch in normalization mode (`IDENTITY` vs `MEAN_STD` vs `MIN_MAX` vs `QUANTILES`/`QUANTILE10`) or in which features get normalized will train and eval without erroring, yet silently wreck results. Make sure the modes match how the checkpoint was trained, that the required stats exist (e.g. `QUANTILES` needs `q01`/`q99`), and that the pre- and post-processors stay consistent.
```python
# processor_my_policy.py
from typing import Any
@@ -304,7 +306,9 @@ Mirror an existing policy that's structurally similar to yours; the diff is smal
### Heavy / optional dependencies
Most policies need a heavy backbone (transformers, diffusers, a specific VLM SDK). The convention is **two-step gating**: a `TYPE_CHECKING`-guarded import at module top, and a `require_package` runtime check in the constructor. [`modeling_diffusion.py`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/diffusion/modeling_diffusion.py) is the canonical reference:
Most policies need a heavy backbone (transformers, diffusers, a specific VLM SDK). Wherever one exists, prefer loading it e.g from `transformers` or `diffusers` rather than re-implementing the architecture in-tree.
The convention is **two-step gating**: a `TYPE_CHECKING`-guarded import at module top, and a `require_package` runtime check in the constructor. [`modeling_diffusion.py`](https://github.com/huggingface/lerobot/blob/main/src/lerobot/policies/diffusion/modeling_diffusion.py) is the canonical reference:
```python
from typing import TYPE_CHECKING
@@ -374,6 +378,7 @@ The general expectations are in [`CONTRIBUTING.md`](https://github.com/huggingfa
- [ ] Optional deps live behind a `[project.optional-dependencies]` extra and the `TYPE_CHECKING + require_package` guard.
- [ ] `tests/policies/` updated; backward-compat artifact committed & policy-specific tests.
- [ ] `src/lerobot/policies/<name>/README.md` symlinked into `docs/source/policy_<name>_README.md`; user-facing `docs/source/<name>.mdx` written and added to `_toctree.yml`.
- [ ] `lerobot-train --policy.type my_policy ...` runs end-to-end for at least a few steps + save a checkpoint that can be loaded and run by `lerobot-eval` or `lerobot-rollout`.
- [ ] `templates/lerobot_modelcard_template.md` has a description entry and a `policy_docs` link for your policy.
- [ ] The models table in the root `README.md` lists your policy in the right category, linking to your doc page.
- [ ] At least one reproducible benchmark eval in the policy MDX with a published checkpoint (sim benchmark, or real-robot dataset + checkpoint).
+2 -2
View File
@@ -155,7 +155,7 @@ accelerate-dep = ["accelerate>=1.14.0,<2.0.0"]
can-dep = ["python-can>=4.2.0,<5.0.0"]
peft-dep = ["peft>=0.18.0,<1.0.0"]
scipy-dep = ["scipy>=1.14.0,<2.0.0"]
diffusers-dep = ["diffusers>=0.27.2,<0.36.0"]
diffusers-dep = ["diffusers>=0.38.0,<0.40.0"]
qwen-vl-utils-dep = ["qwen-vl-utils>=0.0.11,<0.1.0"]
matplotlib-dep = ["matplotlib>=3.10.3,<4.0.0", "contourpy>=1.3.0,<2.0.0"] # NOTE: Explicitly listing contourpy helps the resolver converge faster.
pyserial-dep = ["pyserial>=3.5,<4.0"]
@@ -261,7 +261,7 @@ annotations = [
# Development
dev = ["pre-commit>=3.7.0,<5.0.0", "debugpy>=1.8.1,<1.9.0", "lerobot[grpcio-dep]", "grpcio-tools>=1.73.1,<2.0.0", "mypy>=1.19.1", "ruff>=0.14.1", "lerobot[notebook]"]
notebook = ["jupyter>=1.0.0,<2.0.0", "ipykernel>=6.0.0,<7.0.0"]
test = ["pytest>=8.1.0,<9.0.0", "pytest-timeout>=2.4.0,<3.0.0", "pytest-cov>=5.0.0,<8.0.0", "mock-serial>=0.0.1,<0.1.0 ; sys_platform != 'win32'"]
test = ["pytest>=8.1.0,<10.0.0", "pytest-timeout>=2.4.0,<3.0.0", "pytest-cov>=5.0.0,<8.0.0", "mock-serial>=0.0.1,<0.1.0 ; sys_platform != 'win32'"]
video_benchmark = ["scikit-image>=0.23.2,<0.26.0", "pandas>=2.2.2,<2.4.0"]
# Simulation
+9 -5
View File
@@ -478,18 +478,19 @@ class LeRobotDataset(torch.utils.data.Dataset):
"""Return the number of frames in the selected episodes."""
return self.num_frames
def __getitem__(self, idx) -> dict:
"""Return a single frame by index, with all transforms applied.
def __getitem__(self, idx: int | slice) -> dict | list[dict]:
"""Return one frame or a slice of frames, with all transforms applied.
Loads the frame from the underlying HF dataset, expands delta-timestamp
windows, decodes video frames, and applies image transforms. Delegates
the core logic to :meth:`DatasetReader.get_item`.
the core logic to :class:`DatasetReader`.
Args:
idx: Index into the (possibly episode-filtered) dataset.
idx: Integer index or slice into the possibly episode-filtered dataset.
Returns:
Dict mapping feature names to their tensor values for this frame.
A frame dictionary for an integer index, or a list of frame
dictionaries for a slice.
Raises:
RuntimeError: If the dataset is currently being recorded and
@@ -499,6 +500,9 @@ class LeRobotDataset(torch.utils.data.Dataset):
raise RuntimeError(
"Cannot read from a dataset that is being recorded. Call finalize() first, then access items."
)
if isinstance(idx, slice):
return [self[item_idx] for item_idx in range(*idx.indices(len(self)))]
reader = self._ensure_reader()
if reader.hf_dataset is None:
# One-shot load after finalize()
+5 -1
View File
@@ -322,7 +322,7 @@ class HILSerlRobotEnvConfig(EnvConfig):
class LiberoEnv(EnvConfig):
task: str = "libero_10" # can also choose libero_spatial, libero_object, etc.
task_ids: list[int] | None = None
fps: int = 30
fps: int = 20 # Must match robosuite's default control_freq (20 Hz)
episode_length: int | None = None
obs_type: str = "pixels_agent_pos"
render_mode: str = "rgb_array"
@@ -354,6 +354,9 @@ class LiberoEnv(EnvConfig):
control_mode: str = "relative" # or "absolute"
def __post_init__(self):
if self.fps <= 0:
raise ValueError(f"fps must be positive, got {self.fps}")
if self.obs_type == "pixels":
self.features[LIBERO_KEY_PIXELS_AGENTVIEW] = PolicyFeature(
type=FeatureType.VISUAL, shape=(self.observation_height, self.observation_width, 3)
@@ -412,6 +415,7 @@ class LiberoEnv(EnvConfig):
"render_mode": self.render_mode,
"observation_height": self.observation_height,
"observation_width": self.observation_width,
"control_freq": self.fps,
}
if self.task_ids is not None:
kwargs["task_ids"] = self.task_ids
+5
View File
@@ -125,10 +125,13 @@ class LiberoEnv(gym.Env):
n_envs: int = 1,
camera_name_mapping: dict[str, str] | None = None,
num_steps_wait: int = 10,
control_freq: int = 20,
control_mode: str = "relative",
is_libero_plus: bool = False,
):
super().__init__()
if control_freq <= 0:
raise ValueError(f"control_freq must be positive, got {control_freq}")
self.task_id = task_id
self.is_libero_plus = is_libero_plus
self.obs_type = obs_type
@@ -154,6 +157,7 @@ class LiberoEnv(gym.Env):
}
self.camera_name_mapping = camera_name_mapping
self.num_steps_wait = num_steps_wait
self.control_freq = control_freq
self.episode_index = episode_index
self.episode_length = episode_length
# Load once and keep
@@ -260,6 +264,7 @@ class LiberoEnv(gym.Env):
bddl_file_name=self._task_bddl_file,
camera_heights=self.observation_height,
camera_widths=self.observation_width,
control_freq=self.control_freq,
)
env.reset()
self._env = env
+1 -2
View File
@@ -20,7 +20,6 @@ import logging
import time
from contextlib import contextmanager
from copy import deepcopy
from functools import cached_property
from typing import TYPE_CHECKING, Any, TypedDict
from lerobot.utils.decorators import check_if_already_connected, check_if_not_connected
@@ -854,7 +853,7 @@ class DamiaoMotorsBus(MotorsBusBase):
else:
raise ValueError(f"Motor {motor_obj} doesn't have a valid recv_id (None).")
@cached_property
@property
def is_calibrated(self) -> bool:
"""Check if motors are calibrated."""
return bool(self.calibration)
+7 -3
View File
@@ -23,6 +23,7 @@ from __future__ import annotations
import abc
import logging
import time
from collections.abc import Sequence
from contextlib import contextmanager
from dataclasses import dataclass
@@ -818,13 +819,13 @@ class SerialMotorsBus(MotorsBusBase):
"""
motor_names = self._get_motors_list(motors)
start_positions = self.sync_read("Present_Position", motor_names, normalize=False)
start_positions = self.sync_read("Present_Position", motor_names, normalize=False, num_retry=5)
mins = start_positions.copy()
maxes = start_positions.copy()
user_pressed_enter = False
while not user_pressed_enter:
positions = self.sync_read("Present_Position", motor_names, normalize=False)
positions = self.sync_read("Present_Position", motor_names, normalize=False, num_retry=5)
mins = {motor: min(positions[motor], min_) for motor, min_ in mins.items()}
maxes = {motor: max(positions[motor], max_) for motor, max_ in maxes.items()}
@@ -837,9 +838,12 @@ class SerialMotorsBus(MotorsBusBase):
if enter_pressed():
user_pressed_enter = True
if display_values and not user_pressed_enter:
if not user_pressed_enter:
if display_values:
# Move cursor up to overwrite the previous output
move_cursor_up(len(motor_names) + 3)
# Throttle reads even when the live table is disabled.
time.sleep(0.02)
same_min_max = [motor for motor in motor_names if mins[motor] == maxes[motor]]
if same_min_max:
@@ -79,6 +79,8 @@ class DiffusionConfig(PreTrainedConfig):
use_film_scale_modulation: FiLM (https://huggingface.co/papers/1709.07871) is used for the Unet conditioning.
Bias modulation is used be default, while this parameter indicates whether to also use scale
modulation.
gradient_checkpointing: Whether to checkpoint the Unet residual blocks during training. This reduces
activation memory at the cost of recomputing those blocks during the backward pass.
noise_scheduler_type: Name of the noise scheduler to use. Supported options: ["DDPM", "DDIM"].
num_train_timesteps: Number of diffusion steps for the forward diffusion schedule.
beta_schedule: Name of the diffusion beta schedule as per DDPMScheduler from Hugging Face diffusers.
@@ -132,6 +134,7 @@ class DiffusionConfig(PreTrainedConfig):
n_groups: int = 8
diffusion_step_embed_dim: int = 128
use_film_scale_modulation: bool = True
gradient_checkpointing: bool = False
# Noise scheduler.
noise_scheduler_type: str = "DDPM"
num_train_timesteps: int = 100
@@ -31,6 +31,7 @@ import torch
import torch.nn.functional as F # noqa: N812
import torchvision
from torch import Tensor, nn
from torch.utils.checkpoint import checkpoint
from lerobot.utils.constants import ACTION, OBS_ENV_STATE, OBS_IMAGES, OBS_STATE
from lerobot.utils.import_utils import _diffusers_available, require_package
@@ -727,20 +728,33 @@ class DiffusionConditionalUnet1d(nn.Module):
else:
global_feature = timesteps_embed
use_gc = self.config.gradient_checkpointing and self.training
# Run encoder, keeping track of skip features to pass to the decoder.
encoder_skip_features: list[Tensor] = []
for resnet, resnet2, downsample in self.down_modules:
if use_gc:
x = checkpoint(resnet, x, global_feature, use_reentrant=False)
x = checkpoint(resnet2, x, global_feature, use_reentrant=False)
else:
x = resnet(x, global_feature)
x = resnet2(x, global_feature)
encoder_skip_features.append(x)
x = downsample(x)
for mid_module in self.mid_modules:
if use_gc:
x = checkpoint(mid_module, x, global_feature, use_reentrant=False)
else:
x = mid_module(x, global_feature)
# Run decoder, using the skip features from the encoder.
for resnet, resnet2, upsample in self.up_modules:
x = torch.cat((x, encoder_skip_features.pop()), dim=1)
if use_gc:
x = checkpoint(resnet, x, global_feature, use_reentrant=False)
x = checkpoint(resnet2, x, global_feature, use_reentrant=False)
else:
x = resnet(x, global_feature)
x = resnet2(x, global_feature)
x = upsample(x)
@@ -58,6 +58,9 @@ class BiSOFollower(BimanualMixin, Robot):
port=config.left_arm_config.port,
disable_torque_on_disconnect=config.left_arm_config.disable_torque_on_disconnect,
max_relative_target=config.left_arm_config.max_relative_target,
position_p_coefficient=config.left_arm_config.position_p_coefficient,
position_i_coefficient=config.left_arm_config.position_i_coefficient,
position_d_coefficient=config.left_arm_config.position_d_coefficient,
use_degrees=config.left_arm_config.use_degrees,
cameras=left_arm_cameras,
)
@@ -68,6 +71,9 @@ class BiSOFollower(BimanualMixin, Robot):
port=config.right_arm_config.port,
disable_torque_on_disconnect=config.right_arm_config.disable_torque_on_disconnect,
max_relative_target=config.right_arm_config.max_relative_target,
position_p_coefficient=config.right_arm_config.position_p_coefficient,
position_i_coefficient=config.right_arm_config.position_i_coefficient,
position_d_coefficient=config.right_arm_config.position_d_coefficient,
use_degrees=config.right_arm_config.use_degrees,
cameras=config.right_arm_config.cameras,
)
@@ -150,9 +150,6 @@ class OpenArmFollower(Robot):
self.configure()
if self.is_calibrated:
self.bus.set_zero_position()
self.bus.enable_torque()
logger.info(f"{self} connected.")
@@ -41,6 +41,11 @@ class SOFollowerConfig:
# Set to `True` for backward compatibility with previous policies/dataset
use_degrees: bool = True
# Position-mode PID gains written to Feetech STS3215 motors at connect time.
position_p_coefficient: int = 16
position_i_coefficient: int = 0
position_d_coefficient: int = 32
@RobotConfig.register_subclass("so101_follower")
@RobotConfig.register_subclass("so100_follower")
@@ -161,11 +161,9 @@ class SOFollower(Robot):
self.bus.configure_motors()
for motor in self.bus.motors:
self.bus.write("Operating_Mode", motor, OperatingMode.POSITION.value)
# Set P_Coefficient to lower value to avoid shakiness (Default is 32)
self.bus.write("P_Coefficient", motor, 16)
# Set I_Coefficient and D_Coefficient to default value 0 and 32
self.bus.write("I_Coefficient", motor, 0)
self.bus.write("D_Coefficient", motor, 32)
self.bus.write("P_Coefficient", motor, self.config.position_p_coefficient)
self.bus.write("I_Coefficient", motor, self.config.position_i_coefficient)
self.bus.write("D_Coefficient", motor, self.config.position_d_coefficient)
if motor == "gripper":
self.bus.write("Max_Torque_Limit", motor, 500) # 50% of max torque to avoid burnout
+6 -17
View File
@@ -51,19 +51,7 @@ from lerobot.teleoperators import ( # noqa: F401
rebot_102_leader,
so_leader,
)
COMPATIBLE_DEVICES = [
"koch_follower",
"koch_leader",
"omx_follower",
"omx_leader",
"openarm_mini",
"so100_follower",
"so100_leader",
"so101_follower",
"so101_leader",
"lekiwi",
]
from lerobot.utils.import_utils import register_third_party_plugins
@dataclass
@@ -80,18 +68,19 @@ class SetupConfig:
@draccus.wrap()
def setup_motors(cfg: SetupConfig):
if cfg.device.type not in COMPATIBLE_DEVICES:
raise NotImplementedError
if isinstance(cfg.device, RobotConfig):
device = make_robot_from_config(cfg.device)
else:
device = make_teleoperator_from_config(cfg.device)
device.setup_motors()
setup = getattr(device, "setup_motors", None)
if not callable(setup):
raise NotImplementedError(f"Device type '{cfg.device.type}' does not support motor setup.")
setup()
def main():
register_third_party_plugins()
setup_motors()
@@ -23,3 +23,5 @@ from ..config import TeleoperatorConfig
@dataclass
class GamepadTeleopConfig(TeleoperatorConfig):
use_gripper: bool = True
# Use hidapi instead of pygame for controllers that pygame cannot detect reliably.
hidapi_fallback: bool = False
@@ -14,6 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import sys
from enum import IntEnum
from typing import Any
@@ -27,6 +28,8 @@ from ..teleoperator import Teleoperator
from ..utils import TeleopEvents
from .configuration_gamepad import GamepadTeleopConfig
logger = logging.getLogger(__name__)
class GripperAction(IntEnum):
CLOSE = 0
@@ -56,6 +59,13 @@ class GamepadTeleop(Teleoperator):
self.gamepad = None
self.hidapi_fallback = config.hidapi_fallback
if sys.platform == "darwin" and not self.hidapi_fallback:
logger.warning(
"On macOS, pygame may not reliably detect input from some controllers. "
"If you experience issues, set `hidapi_fallback=true`."
)
@property
def action_features(self) -> dict:
if self.config.use_gripper:
@@ -76,9 +86,7 @@ class GamepadTeleop(Teleoperator):
return {}
def connect(self) -> None:
# use HidApi for macos
if sys.platform == "darwin":
# NOTE: On macOS, pygame doesnt reliably detect input from some controllers so we fall back to hidapi
if self.hidapi_fallback:
from .gamepad_utils import GamepadControllerHID as Gamepad
else:
from .gamepad_utils import GamepadController as Gamepad
+46
View File
@@ -114,6 +114,20 @@ def test_dataset_initialization(tmp_path, lerobot_dataset_factory):
assert dataset.num_frames == len(dataset)
def test_dataset_slice(tmp_path, lerobot_dataset_factory):
dataset = lerobot_dataset_factory(
root=tmp_path / "test", total_episodes=3, total_frames=30, use_videos=False
)
assert len(dataset[:5]) == 5
assert len(dataset[::2]) == (len(dataset) + 1) // 2
assert [item["index"].item() for item in dataset[4::-1]] == [4, 3, 2, 1, 0]
assert [item["index"].item() for item in dataset[-3:]] == list(range(len(dataset) - 3, len(dataset)))
assert dataset[len(dataset) :] == []
assert isinstance(dataset[0], dict)
assert dataset[:1][0].keys() == dataset[0].keys()
# TODO(rcadene, aliberts): do not run LeRobotDataset.create, instead refactor LeRobotDatasetMetadata.create
# and test the small resulting function that validates the features
def test_dataset_feature_with_forward_slash_raises_error():
@@ -1741,6 +1755,38 @@ def test_delta_timestamps_query_returns_correct_values(tmp_path, empty_lerobot_d
assert is_pad == [True, False], f"Expected [True, False], got {is_pad}"
def test_dataset_slice_with_delta_timestamps(tmp_path, empty_lerobot_dataset_factory):
features = {
"observation.state": {"dtype": "float32", "shape": (1,), "names": ["x"]},
}
dataset = empty_lerobot_dataset_factory(
root=tmp_path / "test_slice_delta", features=features, use_videos=False, fps=10
)
for frame_idx in range(5):
dataset.add_frame(
{
"observation.state": torch.tensor([frame_idx], dtype=torch.float32),
"task": "task_0",
}
)
dataset.save_episode()
dataset.finalize()
sliced_dataset = LeRobotDataset(
dataset.repo_id,
root=dataset.root,
delta_timestamps={"observation.state": [-0.1, 0.0]},
tolerance_s=0.04,
)
items = sliced_dataset[:2]
assert items[0]["observation.state"].tolist() == [0.0, 0.0]
assert items[0]["observation.state_is_pad"].tolist() == [True, False]
assert items[1]["observation.state"].tolist() == [0.0, 1.0]
def test_episode_filter_filters_dataset(tmp_path, lerobot_dataset_factory):
"""episode_filter on LeRobotDataset narrows the loaded dataset to matching episodes."""
dataset = lerobot_dataset_factory(root=tmp_path / "test", total_episodes=8, total_frames=200)
+11
View File
@@ -35,6 +35,17 @@ def test_unknown_type():
make_env_config("nonexistent")
def test_libero_fps_controls_simulator_frequency():
cfg = LiberoEnv(fps=17)
assert cfg.gym_kwargs["control_freq"] == 17
def test_libero_rejects_nonpositive_fps():
with pytest.raises(ValueError, match="fps must be positive"):
LiberoEnv(fps=0)
def test_identity_processors():
"""Base class get_env_processors() returns identity pipelines."""
cfg = make_env_config("aloha")
+7 -1
View File
@@ -405,12 +405,18 @@ def test_record_ranges_of_motion(mock_motors, dummy_motors):
read_pos_stub = mock_motors.build_sequential_sync_read_stub(
*X_SERIES_CONTROL_TABLE["Present_Position"], positions
)
with patch("lerobot.motors.motors_bus.enter_pressed", side_effect=[False, True]):
bus = DynamixelMotorsBus(port=mock_motors.port, motors=dummy_motors)
bus.connect(handshake=False)
with (
patch("lerobot.motors.motors_bus.enter_pressed", side_effect=[False, True]),
patch("lerobot.motors.motors_bus.time.sleep") as mock_sleep,
patch.object(bus, "sync_read", wraps=bus.sync_read) as mock_sync_read,
):
mins, maxes = bus.record_ranges_of_motion(display_values=False)
assert mock_motors.stubs[read_pos_stub].calls == 3
assert all(call.kwargs["num_retry"] == 5 for call in mock_sync_read.call_args_list)
mock_sleep.assert_called_once_with(0.02)
assert mins == expected_mins
assert maxes == expected_maxes
+7 -1
View File
@@ -509,12 +509,18 @@ def test_record_ranges_of_motion(mock_motors, dummy_motors):
stub = mock_motors.build_sequential_sync_read_stub(
*STS_SMS_SERIES_CONTROL_TABLE["Present_Position"], positions
)
with patch("lerobot.motors.motors_bus.enter_pressed", side_effect=[False, True]):
bus = FeetechMotorsBus(port=mock_motors.port, motors=dummy_motors)
bus.connect(handshake=False)
with (
patch("lerobot.motors.motors_bus.enter_pressed", side_effect=[False, True]),
patch("lerobot.motors.motors_bus.time.sleep") as mock_sleep,
patch.object(bus, "sync_read", wraps=bus.sync_read) as mock_sync_read,
):
mins, maxes = bus.record_ranges_of_motion(display_values=False)
assert mock_motors.stubs[stub].calls == 3
assert all(call.kwargs["num_retry"] == 5 for call in mock_sync_read.call_args_list)
mock_sleep.assert_called_once_with(0.02)
assert mins == expected_mins
assert maxes == expected_maxes
+19
View File
@@ -109,3 +109,22 @@ def test_send_action(follower):
goal_pos = {m: (i + 1) * 10 for i, m in enumerate(follower.bus.motors)}
follower.bus.sync_write.assert_called_once_with("Goal_Position", goal_pos)
def test_configure_writes_position_pid_coefficients():
bus_mock = _make_bus_mock()
bus_mock.motors = ["shoulder_pan"]
robot = MagicMock()
robot.bus = bus_mock
robot.config = SO100FollowerConfig(
port="/dev/null",
position_p_coefficient=32,
position_i_coefficient=1,
position_d_coefficient=16,
)
SO100Follower.configure(robot)
bus_mock.write.assert_any_call("P_Coefficient", "shoulder_pan", 32)
bus_mock.write.assert_any_call("I_Coefficient", "shoulder_pan", 1)
bus_mock.write.assert_any_call("D_Coefficient", "shoulder_pan", 16)
+49
View File
@@ -0,0 +1,49 @@
# 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.
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
import lerobot.scripts.lerobot_setup_motors as motors_module
def test_main_registers_plugins_before_parsing(monkeypatch):
calls = []
monkeypatch.setattr(motors_module, "register_third_party_plugins", lambda: calls.append("register"))
monkeypatch.setattr(motors_module, "setup_motors", lambda: calls.append("setup"))
motors_module.main()
assert calls == ["register", "setup"]
def test_setup_motors_accepts_third_party_device(monkeypatch):
device = MagicMock()
monkeypatch.setattr(motors_module, "make_teleoperator_from_config", lambda _: device)
cfg = SimpleNamespace(device=SimpleNamespace(type="third_party"))
motors_module.setup_motors.__wrapped__(cfg)
device.setup_motors.assert_called_once_with()
def test_setup_motors_reports_unsupported_device(monkeypatch):
device = object()
monkeypatch.setattr(motors_module, "make_teleoperator_from_config", lambda _: device)
cfg = SimpleNamespace(device=SimpleNamespace(type="third_party"))
with pytest.raises(NotImplementedError, match="third_party"):
motors_module.setup_motors.__wrapped__(cfg)
Generated
+900 -882
View File
File diff suppressed because it is too large Load Diff