Skip to content
Merged
Show file tree
Hide file tree
Changes from 32 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
2df3304
refactor(robots): lewiki v0.1
imstevenpmwork Mar 12, 2025
e69dc18
refactor(robots): lekiwi v0.2
imstevenpmwork Mar 13, 2025
84a2852
refactor(robots): lewiki v0.3
imstevenpmwork Mar 13, 2025
130108a
refactor(robots): lekiwi v0.4
imstevenpmwork Mar 14, 2025
9a2aeaa
refactor(robots): lekiwi v0.5
imstevenpmwork Mar 14, 2025
748a534
fix(lekiwi): HW fixes v0.1
pre-commit-ci[bot] Mar 14, 2025
0f590fe
fix(lekiwi): HW fixes v0.2
imstevenpmwork Mar 17, 2025
7f7fa82
fix(lekiwi): HW fixes v0.3
imstevenpmwork Mar 17, 2025
877dc1c
fix(lekiwi): HW fixes v0.4
imstevenpmwork Mar 17, 2025
e8283c0
fix(lekiwi): fix calibration issue
imstevenpmwork Mar 18, 2025
1b9a1a2
feat(lekiwi): de-couple classes + make it single-threaded
imstevenpmwork Mar 19, 2025
0167e6c
feat(lekiwi): Make dataset recording work
imstevenpmwork Mar 19, 2025
a909190
chore(doc): update todos + license
imstevenpmwork Mar 20, 2025
067209a
refactor(kiwi): update to latest motor API
imstevenpmwork Mar 21, 2025
49a0f96
Update Lekiwi with new MotorsBus
Apr 4, 2025
bf8d44f
Rename Lekiwi files & classes
Apr 4, 2025
62e6829
Cleanup imports
Apr 4, 2025
f8cf826
Group config files
Apr 4, 2025
d73ad2d
refactor(robots): update lekiwi for the latest motor bus api
imstevenpmwork Apr 4, 2025
85667c7
refactor(robots): multiple changes from feedback
imstevenpmwork Apr 17, 2025
1627e3e
Apply suggestions from code review
imstevenpmwork Apr 17, 2025
69dc3c9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 17, 2025
b59c430
Apply suggestions from code review (lekiwi_client.py)
imstevenpmwork Apr 17, 2025
979f0f7
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 17, 2025
1a13eff
Apply suggestions from code review (code changes)
imstevenpmwork Apr 18, 2025
21170d1
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 18, 2025
7274ad0
Apply suggestions from code review
imstevenpmwork Apr 18, 2025
e5f0902
chore(robots): apply suggestions from code review
imstevenpmwork Apr 18, 2025
efeb15d
chore(robots): apply suggestions from code review
imstevenpmwork Apr 22, 2025
707b5e8
refactor(robots): refactor _get_data in lekiwi for clarity
imstevenpmwork Apr 22, 2025
c882875
fix(robots): bootstrap dataset recording again and improve watchdog b…
imstevenpmwork Apr 22, 2025
857de96
chore(robots): update to latest motor bus API
imstevenpmwork Apr 23, 2025
1b23d86
Apply suggestions from code review
imstevenpmwork Apr 24, 2025
eeb1d1b
chore(robots): apply suggestions from code review 2
imstevenpmwork Apr 24, 2025
4488273
Apply suggestions from code review
imstevenpmwork Apr 25, 2025
e52bfc0
refactor(teleop): remove _is_connected member variable from KeyboardT…
imstevenpmwork Apr 25, 2025
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
98 changes: 98 additions & 0 deletions examples/robots/lekiwi_client_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Copyright 2024 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 lerobot.common.datasets.lerobot_dataset import LeRobotDataset
from lerobot.common.robots.lekiwi.config_lekiwi import LeKiwiClientConfig
from lerobot.common.robots.lekiwi.lekiwi_client import OBS_STATE, LeKiwiClient
from lerobot.common.teleoperators.keyboard import KeyboardTeleop, KeyboardTeleopConfig
from lerobot.common.teleoperators.so100 import SO100Leader, SO100LeaderConfig

NB_CYCLES_CLIENT_CONNECTION = 250


def main():
logging.info("Configuring Teleop Devices")
leader_arm_config = SO100LeaderConfig(port="/dev/tty.usbmodem58760434171")
leader_arm = SO100Leader(leader_arm_config)

keyboard_config = KeyboardTeleopConfig()
keyboard = KeyboardTeleop(keyboard_config)

logging.info("Configuring LeKiwi Client")
robot_config = LeKiwiClientConfig(id="lekiwi")
robot = LeKiwiClient(robot_config)

logging.info("Creating LeRobot Dataset")

# The observations that we get are expected to be in body frame (x,y,theta)
obs_dict = {f"{OBS_STATE}." + key: value for key, value in robot.state_feature_client.items()}
# The actions that we send are expected to be in wheel frame (motor encoders)
act_dict = {"action." + key: value for key, value in robot.action_feature.items()}

features_dict = {
**act_dict,
**obs_dict,
**robot.camera_features,
}
dataset = LeRobotDataset.create(
repo_id="user/lekiwi" + str(int(time.time())),
fps=10,
features=features_dict,
)

logging.info("Connecting Teleop Devices")
leader_arm.connect()
keyboard.connect()

logging.info("Connecting remote LeKiwi")
robot.connect()

if not robot.is_connected or not leader_arm.is_connected or not keyboard.is_connected:
logging.error("Failed to connect to all devices")
return

logging.info("Starting LeKiwi teleoperation")
i = 0
while i < NB_CYCLES_CLIENT_CONNECTION:
arm_action = leader_arm.get_action()
base_action = keyboard.get_action()
action = {**arm_action, **base_action} if len(base_action) > 0 else arm_action

action_sent = robot.send_action(action)
observation = robot.get_observation()

frame = {**action_sent, **observation}
frame.update({"task": "Dummy Example Task Dataset"})

logging.info("Saved a frame into the dataset")
dataset.add_frame(frame)
i += 1

logging.info("Disconnecting Teleop Devices and LeKiwi Client")
robot.disconnect()
leader_arm.disconnect()
keyboard.disconnect()

logging.info("Uploading dataset to the hub")
dataset.save_episode()
dataset.push_to_hub()

logging.info("Finished LeKiwi cleanly")


if __name__ == "__main__":
main()
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ def main():
i += 1

keyboard.disconnect()
logging.info("Finished LeKiwiRobot cleanly")
logging.info("Finished LeKiwi cleanly")


if __name__ == "__main__":
Expand Down
2 changes: 1 addition & 1 deletion lerobot/common/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,5 @@
HF_LEROBOT_HOME = Path(os.getenv("HF_LEROBOT_HOME", default_cache_path)).expanduser()

# calibration dir
default_calibration_path = HF_LEROBOT_HOME / ".calibration"
default_calibration_path = HF_LEROBOT_HOME / "calibration"
HF_LEROBOT_CALIBRATION = Path(os.getenv("HF_LEROBOT_CALIBRATION", default_calibration_path)).expanduser()
11 changes: 11 additions & 0 deletions lerobot/common/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,14 @@ def __init__(
):
self.message = message
super().__init__(self.message)


class InvalidActionError(ValueError):
"""Exception raised when an action is already invalid."""

def __init__(
self,
message="The action is invalid. Check the value follows what it is expected from the action space.",
):
self.message = message
super().__init__(self.message)
21 changes: 21 additions & 0 deletions lerobot/common/motors/dynamixel/tables.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,24 @@
# TODO(Steven): Consider doing the following:
# from enum import Enum
# class MyControlTableKey(Enum):
# ID = "ID"
# GOAL_SPEED = "Goal_Speed"
# ...
#
# MY_CONTROL_TABLE ={
# MyControlTableKey.ID.value: (5,1)
# MyControlTableKey.GOAL_SPEED.value: (46, 2)
# ...
# }
# This allows me do to:
# bus.write(MyControlTableKey.GOAL_SPEED, ...)
# Instead of:
# bus.write("Goal_Speed", ...)
# This is important for two reasons:
# 1. The linter will tell me if I'm trying to use an invalid key, instead of me realizing when I get the RunTimeError
# 2. We can change the value of the MyControlTableKey enums without impacting the client code


# {data_name: (address, size_byte)}
# https://emanual.robotis.com/docs/en/dxl/x/{MODEL}/#control-table
X_SERIES_CONTROL_TABLE = {
Expand Down
20 changes: 20 additions & 0 deletions lerobot/common/motors/feetech/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@
FIRMWARE_MINOR_VERSION = (1, 1)
MODEL_NUMBER = (3, 2)

# TODO(Steven): Consider doing the following:
# from enum import Enum
# class MyControlTableKey(Enum):
# ID = "ID"
# GOAL_SPEED = "Goal_Speed"
# ...
#
# MY_CONTROL_TABLE ={
# MyControlTableKey.ID.value: (5,1)
# MyControlTableKey.GOAL_SPEED.value: (46, 2)
# ...
# }
# This allows me do to:
# bus.write(MyControlTableKey.GOAL_SPEED, ...)
# Instead of:
# bus.write("Goal_Speed", ...)
# This is important for two reasons:
# 1. The linter will tell me if I'm trying to use an invalid key, instead of me realizing when I get the RunTimeError
# 2. We can change the value of the MyControlTableKey enums without impacting the client code

# data_name: (address, size_byte)
# http://doc.feetech.cn/#/prodinfodownload?srcType=FT-SMS-STS-emanual-229f4476422d4059abfb1cb0
STS_SMS_SERIES_CONTROL_TABLE = {
Expand Down
4 changes: 3 additions & 1 deletion lerobot/common/motors/motors_bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@ def set_half_turn_homings(self, motors: NameOrID | list[NameOrID] | None = None)
motors = list(self.motors)
elif isinstance(motors, (str, int)):
motors = [motors]
else:
elif not isinstance(motors, list):
raise TypeError(motors)

self.reset_calibration(motors)
Expand Down Expand Up @@ -633,6 +633,8 @@ def _normalize(self, data_name: str, ids_values: dict[int, int]) -> dict[int, fl
min_ = self.calibration[motor].range_min
max_ = self.calibration[motor].range_max
bounded_val = min(max_, max(min_, val))
# TODO(Steven): normalization can go boom if max_ == min_, we should add a check probably in record_ranges_of_motions
# (which probably indicates the user forgot to move a motor, most likely a gripper-like one)
if self.motors[motor].norm_mode is MotorNormMode.RANGE_M100_100:
normalized_values[id_] = (((bounded_val - min_) / (max_ - min_)) * 200) - 100
elif self.motors[motor].norm_mode is MotorNormMode.RANGE_0_100:
Expand Down
8 changes: 4 additions & 4 deletions lerobot/common/robots/lekiwi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,11 +194,11 @@ sudo chmod 666 /dev/ttyACM1

#### d. Update config file

IMPORTANTLY: Now that you have your ports of leader and follower arm and ip address of the mobile-so100, update the **ip** in Network configuration, **port** in leader_arms and **port** in lekiwi. In the [`LeKiwiRobotConfig`](../lerobot/common/robot_devices/robots/configs.py) file. Where you will find something like:
IMPORTANTLY: Now that you have your ports of leader and follower arm and ip address of the mobile-so100, update the **ip** in Network configuration, **port** in leader_arms and **port** in lekiwi. In the [`LeKiwiConfig`](../lerobot/common/robot_devices/robots/configs.py) file. Where you will find something like:
```python
@RobotConfig.register_subclass("lekiwi")
@dataclass
class LeKiwiRobotConfig(RobotConfig):
class LeKiwiConfig(RobotConfig):
# `max_relative_target` limits the magnitude of the relative positional target vector for safety purposes.
# Set this to a positive scalar to have the same value for all motors, or a list that is the same length as
# the number of motors in your follower arms.
Expand Down Expand Up @@ -281,7 +281,7 @@ For the wired LeKiwi version your configured IP address should refer to your own
```python
@RobotConfig.register_subclass("lekiwi")
@dataclass
class LeKiwiRobotConfig(RobotConfig):
class LeKiwiConfig(RobotConfig):
# `max_relative_target` limits the magnitude of the relative positional target vector for safety purposes.
# Set this to a positive scalar to have the same value for all motors, or a list that is the same length as
# the number of motors in your follower arms.
Expand Down Expand Up @@ -446,7 +446,7 @@ You should see on your laptop something like this: ```[INFO] Connected to remote
| F | Decrease speed |

> [!TIP]
> If you use a different keyboard you can change the keys for each command in the [`LeKiwiRobotConfig`](../lerobot/common/robot_devices/robots/configs.py).
> If you use a different keyboard you can change the keys for each command in the [`LeKiwiConfig`](../lerobot/common/robot_devices/robots/configs.py).

### Wired version
If you have the **wired** LeKiwi version please run all commands including both these teleoperation commands on your laptop.
Expand Down
3 changes: 3 additions & 0 deletions lerobot/common/robots/lekiwi/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .config_lekiwi import LeKiwiClientConfig, LeKiwiConfig
from .lekiwi import LeKiwi
from .lekiwi_client import LeKiwiClient
89 changes: 89 additions & 0 deletions lerobot/common/robots/lekiwi/config_lekiwi.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Copyright 2024 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.common.cameras.configs import CameraConfig
from lerobot.common.cameras.opencv.configuration_opencv import OpenCVCameraConfig

from ..config import RobotConfig


@RobotConfig.register_subclass("lekiwi")
@dataclass
class LeKiwiConfig(RobotConfig):
port = "/dev/ttyACM0" # port to connect to the bus

disable_torque_on_disconnect: bool = True

# `max_relative_target` limits the magnitude of the relative positional target vector for safety purposes.
# Set this to a positive scalar to have the same value for all motors, or a list that is the same length as
# the number of motors in your follower arms.
max_relative_target: int | None = None

cameras: dict[str, CameraConfig] = field(
default_factory=lambda: {
"front": OpenCVCameraConfig(
camera_index="/dev/video0", fps=30, width=640, height=480, rotation=None
),
"wrist": OpenCVCameraConfig(
camera_index="/dev/video2", fps=30, width=640, height=480, rotation=180
),
}
)


@dataclass
class LeKiwiHostConfig:
# Network Configuration
port_zmq_cmd: int = 5555
port_zmq_observations: int = 5556

# Duration of the application
connection_time_s: int = 30

# Watchdog: stop the robot if no command is received for over 0.5 seconds.
watchdog_timeout_ms: int = 1000

# If robot jitters decrease the frequency and monitor cpu load with `top` in cmd
max_loop_freq_hz: int = 30


@RobotConfig.register_subclass("lekiwi_client")
@dataclass
class LeKiwiClientConfig(RobotConfig):
# Network Configuration
remote_ip: str = "172.18.129.208"
port_zmq_cmd: int = 5555
port_zmq_observations: int = 5556

teleop_keys: dict[str, str] = field(
default_factory=lambda: {
# Movement
"forward": "w",
"backward": "s",
"left": "a",
"right": "d",
"rotate_left": "z",
"rotate_right": "x",
# Speed control
"speed_up": "r",
"speed_down": "f",
# quit teleop
"quit": "q",
}
)

polling_timeout_ms: int = 15
connect_timeout_s: int = 5
Loading
Loading