Skip to content

feat(robot): add XLeRobot support — omni base, dual arms, head#2493

Open
a-bissell wants to merge 2 commits into
dimensionalOS:mainfrom
a-bissell:feat/xlerobot-support
Open

feat(robot): add XLeRobot support — omni base, dual arms, head#2493
a-bissell wants to merge 2 commits into
dimensionalOS:mainfrom
a-bissell:feat/xlerobot-support

Conversation

@a-bissell

Copy link
Copy Markdown

Summary

Adds full DimOS integration for the XLeRobot mobile manipulator — a 3-wheel omni-directional base with dual 6-DOF Feetech STS3215 arms and a 2-DOF head (17 DOF total).

  • Hardware driver (connection.py): dual serial bus control, omni-wheel IK/FK, position-mode arms with safety clamping, velocity-mode wheels, USB camera, calibration loading, macOS + Linux serial port auto-detection
  • DimOS module (xlerobot_module.py): cmd_vel input, color_image/odom/joint_states outputs, 20 Hz sensor loop, dead-reckoning odometry, 9 skills (move_base, stop_base, move_arm, move_head, open/close_gripper, get_joint_positions, home_arms, observe)
  • Blueprints: xlerobot-basic (connection + camera + Rerun viz) and xlerobot-agentic (+ MCP server/client for LLM agent control)
  • Calibration script: python -m dimos.robot.xlerobot.calibrate — interactive calibration for arm/head servos
  • Optional dep: dimos[xlerobot] installs lerobot[feetech]

Hardware specs

Component Details
Base 3× omni wheels (velocity mode)
Left arm 6 DOF + gripper (bus 1, position mode)
Right arm 6 DOF + gripper (bus 2, position mode)
Head 2 DOF pan/tilt (bus 1, position mode)
Camera USB webcam (640×480 @ 30fps default)
Servos Feetech STS3215 on both buses

macOS support

Serial ports auto-detect platform: /dev/cu.usbmodem* on macOS vs /dev/ttyACM* on Linux. The pSHMTransport workaround is applied on Darwin (same pattern as existing Go2 blueprints).

Test plan

  • xlerobot-basic and xlerobot-agentic appear in all_blueprints after running the generation test
  • dimos[xlerobot] resolves lerobot[feetech] correctly
  • Module starts without hardware (graceful error logging)
  • With hardware: base movement, arm control, camera feed, and calibration script work end-to-end

🤖 Generated with Claude Code

Add full DimOS integration for the XLeRobot mobile manipulator:

Hardware driver (connection.py):
- Dual Feetech STS3215 servo buses (bus 1: left arm + head, bus 2: right arm + wheels)
- Omni-wheel inverse/forward kinematics for 3-wheel holonomic base
- Position-mode arm control with safety clamping
- Velocity-mode wheel control
- USB camera via OpenCV
- Calibration loading from file
- Graceful handling of missing motors (strict_handshake=False)
- macOS + Linux serial port auto-detection

DimOS module (xlerobot_module.py):
- cmd_vel input (Twist → wheel velocities)
- color_image, odom, joint_states outputs
- Dead-reckoning odometry from wheel encoders
- 20 Hz sensor polling loop
- Skills: move_base, stop_base, move_arm, move_head, open/close_gripper,
  get_joint_positions, home_arms, observe
- Joint state recording to disk

Blueprints:
- xlerobot-basic: connection + camera + Rerun visualization
- xlerobot-agentic: basic + MCP server/client for LLM agent control

Also includes:
- Interactive calibration script (python -m dimos.robot.xlerobot.calibrate)
- XLeRobotConfig with full servo ID mapping and PID tuning
- XLeRobotSpec protocol for dependency injection
- Optional dependency: dimos[xlerobot] installs lerobot[feetech]

Requires: pip install lerobot[feetech] (or uv pip install dimos[xlerobot])

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds full DimOS integration for the XLeRobot mobile manipulator — a 3-wheel omni-directional base with dual 6-DOF arms and a 2-DOF head (17 DOF total). The implementation addresses the previously noted concurrency issue (per-bus RLock) and the wheel-pruning shape crash, and follows established codebase patterns for blueprints, macOS transport workarounds, and module structure.

  • connection.py: Low-level hardware driver with omni-wheel IK/FK, serial-bus serialization via _io_lock (RLock), calibration loading, safety position clamping, and graceful motor-pruning when strict_handshake=False.
  • xlerobot_module.py: DimOS module with 9 skills, a 20 Hz sensor loop publishing color_image/odom/joint_states, dead-reckoning odometry, and an @rpc recording facility.
  • Blueprints + calibration script: xlerobot-basic and xlerobot-agentic with an MCP-based agentic system prompt, plus an interactive calibration CLI that now wraps the calibration steps in try/finally to ensure both buses are disconnected on error.

Confidence Score: 5/5

Safe to merge — new hardware integration with no impact on existing modules and both previously flagged concurrency issues resolved.

Both previously flagged issues are resolved. The remaining findings are minor quality items that do not affect correctness under default configuration.

dimos/robot/xlerobot/xlerobot_module.py — two inline suggestions worth a quick look.

Important Files Changed

Filename Overview
dimos/robot/xlerobot/connection.py Core hardware driver — adds per-bus RLock serialization, pruning guard for missing wheels, omni-wheel IK/FK, camera capture, and calibration loading; addresses the previously noted concurrency and shape-crash concerns.
dimos/robot/xlerobot/xlerobot_module.py DimOS module with 9 skills, 20 Hz sensor loop, and dead-reckoning odometry; two minor issues — sensor-loop exceptions logged at DEBUG and home_arms skill guard omits head_enabled.
dimos/robot/xlerobot/config.py Clean Pydantic config covering serial ports, camera, kinematics, motor IDs, PID tuning, and calibration; platform-aware port defaults are correct.
dimos/robot/xlerobot/calibrate.py Interactive calibration script with try/finally ensuring both buses are disconnected on error; minor edge case if bus2.connect() fails before the try block.
dimos/robot/xlerobot/blueprints/basic/xlerobot_basic.py Basic blueprint following existing Go2/unitree patterns with the macOS pSHMTransport workaround applied correctly.
dimos/robot/xlerobot/blueprints/agentic/xlerobot_agentic.py Thin agentic blueprint extending xlerobot_basic with MCP server/client and a detailed system prompt listing all 9 skills.
dimos/robot/all_blueprints.py Registers xlerobot-basic, xlerobot-agentic blueprints and x-le-robot-connection module; correctly ordered.
pyproject.toml Adds xlerobot optional extra (lerobot[feetech]) and includes it in the all extra.

Reviews (2): Last reviewed commit: "fix(xlerobot): address PR review — seria..." | Re-trigger Greptile

Comment on lines +100 to +119
def _on_cmd_vel(self, twist: Twist) -> None:
if self._driver and self._driver.connected:
self._driver.move(
x=twist.linear.x,
y=twist.linear.y,
theta=math.degrees(twist.angular.z),
)

def _sensor_loop(self) -> None:
"""Poll camera, wheel encoders, and joint states at ~20 Hz."""
while self._running:
try:
if self._driver and self._driver.connected:
self._update_camera()
self._update_odom()
self._update_joint_states()
time.sleep(0.05)
except Exception as e:
logger.debug(f"Sensor loop error: {e}")
time.sleep(0.1)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Concurrent serial-bus access without locking

_sensor_loop runs in a dedicated background thread and calls sync_read on both buses every 50 ms (_update_odomread_wheel_velocities on _bus2; _update_joint_statesread_joint_positions on both buses). Concurrently, _on_cmd_vel calls driver.move()_bus2.sync_write("Goal_Velocity", ...), and any @skill invocation (move_arm, open_gripper, etc.) issues additional sync_write calls. Serial-port communication is not re-entrant; two concurrent sync_read/sync_write operations on the same FeetechMotorsBus will interleave bytes, corrupting packets and potentially sending garbage position targets to the arms or wheels.

A threading.Lock per bus (or a single driver-level lock) acquired around every sync_read/sync_write call is needed to serialize access.

Comment on lines +311 to +316
def _wheel_raw_to_body(self, raw: dict[str, int]) -> dict[str, float]:
"""Convert wheel feedback back to body-frame velocities."""
wheel_degps = np.array([_raw_to_degps(raw[n]) for n in self._wheel_names])
wheel_linear = np.radians(wheel_degps) * self._config.wheel_radius
body = self._kin_matrix_inv.dot(wheel_linear)
return {"x_vel": body[0], "y_vel": body[1], "theta_vel": np.degrees(body[2])}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Shape crash when fewer than 3 wheels survive pruning

_kin_matrix_inv is always 3×3 (constructed in __init__ regardless of how many motors are later pruned). When strict_handshake=False and one or more wheel motors fail to ping, _prune_missing_motors removes them from _wheel_names. _wheel_raw_to_body then builds wheel_degps = np.array([...]) with 1 or 2 elements, and self._kin_matrix_inv.dot(wheel_linear) raises ValueError: shapes (3,3) and (N,) not aligned. This crashes read_wheel_velocities and get_observation — the latter being called unconditionally in _sensor_loop — so the sensor thread dies (silently swallowed at logger.debug) and odometry stops updating entirely.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb9387c413

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread dimos/robot/xlerobot/xlerobot_module.py Outdated
Comment on lines +126 to +130
data=frame,
height=frame.shape[0],
width=frame.shape[1],
encoding="bgr8",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Construct camera Image messages with supported fields

When the camera returns any frame, these keywords are passed to dimos.msgs.sensor_msgs.Image, but that dataclass only accepts data, format, frame_id, and ts (or Image.from_numpy). This raises TypeError inside _update_camera, which the sensor loop catches and retries forever, so color_image is never published and the observe() skill remains empty for all XLeRobot runs with a working camera.

Useful? React with 👍 / 👎.

else:
_vis = _transports_base

xlerobot_basic = autoconnect(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Regenerate the XLeRobot registry entries

Adding this module-level blueprint requires regenerating dimos/robot/all_blueprints.py; I checked the generated registry and it has no xlerobot-basic or xlerobot-agentic entries. The CLI lookup in get_all_blueprints.py only consults that generated file, so dimos list/dimos run xlerobot-basic will treat the new blueprint as unknown, and the registry-generation test will report the file as stale.

Useful? React with 👍 / 👎.

Comment thread pyproject.toml
Comment on lines +322 to +324
xlerobot = [
"lerobot[feetech]",
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Include the XLeRobot extra in the aggregate install

This adds a new xlerobot extra, but the aggregate all extra just below still omits it despite being the path used by the install docs/scripts (uv sync --extra all). In that standard install, the blueprint can import because the lerobot import is caught, but starting the module constructs XLeRobotDriver and immediately raises the "lerobot[feetech] is required" ImportError, so users following the normal full install cannot run the new robot.

Useful? React with 👍 / 👎.

@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

❌ 4 Tests Failed:

Tests completed Failed Passed Skipped
1862 4 1858 155
View the top 3 failed test(s) by shortest run time
dimos.project.test_no_init_files::test_no_init_files
Stack Traces | 0.017s run time
def test_no_init_files():
        dimos_dir = DIMOS_PROJECT_ROOT / "dimos"
        init_files = sorted(dimos_dir.rglob("__init__.py"))
        # The root dimos/__init__.py is allowed for the porcelain lazy import.
        init_files = [f for f in init_files if f != dimos_dir / "__init__.py"]
        if init_files:
            listing = "\n".join(f"  - {f.relative_to(dimos_dir)}" for f in init_files)
>           raise AssertionError(
                f"Found __init__.py files in dimos/:\n{listing}\n\n"
                "__init__.py files are not allowed because they lead to unnecessary "
                "extraneous imports. Everything should be imported straight from the "
                "source module."
            )
E           AssertionError: Found __init__.py files in dimos/:
E             - robot/xlerobot/__init__.py
E             - .../xlerobot/blueprints/__init__.py
E             - .../blueprints/agentic/__init__.py
E             - .../blueprints/basic/__init__.py
E           
E           __init__.py files are not allowed because they lead to unnecessary extraneous imports. Everything should be imported straight from the source module.

dimos_dir  = PosixPath('.../dimos/dimos/dimos')
init_files = [PosixPath('.../dimos/dimos/dimos/robot/xlerobot/__init__.py'), PosixPath('.../work/dimos/dimos...ts/agentic/__init__.py'), PosixPath('.../dimos/dimos/dimos/.../blueprints/basic/__init__.py')]
listing    = '  - robot/xlerobot/__init__.py\n  - .../xlerobot/blueprints/__init__.py\n  - .../blueprints/agentic/__init__.py\n  - .../blueprints/basic/__init__.py'

dimos/project/test_no_init_files.py:25: AssertionError
dimos.project.test_get_logger::test_no_get_logger
Stack Traces | 0.183s run time
def test_no_get_logger():
        """
        Fail if any file uses `= logging.getLogger` outside the whitelist.
        """
        violations = find_get_logger_usages()
        if violations:
            report_lines = [
                f"Found {len(violations)} forbidden use(s) of `logging.getLogger`. "
                "Use `setup_logger` instead:",
                "",
                "    from dimos.utils.logging_config import setup_logger",
                "",
                "    logger = setup_logger()",
                "",
                "If the usage is legitimate (e.g. standalone script, logging "
                "infrastructure, or third-party logger suppression), add it to the "
                "WHITELIST in dimos/project/test_get_logger.py.",
                "",
            ]
            for path, lineno, text in violations:
                report_lines.append(f"  {path}:{lineno}: {text.strip()}")
>           raise AssertionError("\n".join(report_lines))
E           AssertionError: Found 3 forbidden use(s) of `logging.getLogger`. Use `setup_logger` instead:
E           
E               from dimos.utils.logging_config import setup_logger
E           
E               logger = setup_logger()
E           
E           If the usage is legitimate (e.g. standalone script, logging infrastructure, or third-party logger suppression), add it to the WHITELIST in dimos/project/test_get_logger.py.
E           
E             .../robot/xlerobot/connection.py:28: logger = logging.getLogger(__name__)
E             .../robot/xlerobot/calibrate.py:42: logger = logging.getLogger(__name__)
E             .../robot/xlerobot/xlerobot_module.py:40: logger = logging.getLogger(__name__)

lineno     = 40
path       = '.../robot/xlerobot/xlerobot_module.py'
report_lines = ['Found 3 forbidden use(s) of `logging.getLogger`. Use `setup_logger` instead:', '', '    from dimos.utils.logging_config import setup_logger', '', '    logger = setup_logger()', '', ...]
text       = 'logger = logging.getLogger(__name__)'
violations = [('.../robot/xlerobot/connection.py', 28, 'logger = logging.getLogger(__name__)'), ('.../robot/xlerobot/calibrate....logging.getLogger(__name__)'), ('.../robot/xlerobot/xlerobot_module.py', 40, 'logger = logging.getLogger(__name__)')]

dimos/project/test_get_logger.py:124: AssertionError
dimos.project.test_no_sections::test_no_section_markers
Stack Traces | 0.466s run time
def test_no_section_markers():
        """
        Fail if any file contains section-style comment markers.
    
        If a file is too complicated to be understood without sections, then the
        sections should be files. We don't need "subfiles".
        """
        violations = find_section_markers()
        if violations:
            report_lines = [
                f"Found {len(violations)} section marker(s). "
                "If a file is too complicated to be understood without sections, "
                'then the sections should be files. We don\'t need "subfiles".',
                "",
            ]
            for path, lineno, text in violations:
                report_lines.append(f"  {path}:{lineno}: {text.strip()}")
>           raise AssertionError("\n".join(report_lines))
E           AssertionError: Found 7 section marker(s). If a file is too complicated to be understood without sections, then the sections should be files. We don't need "subfiles".
E           
E             .../robot/xlerobot/connection.py:293: # ---- Base movement ----
E             .../robot/xlerobot/connection.py:348: # ---- Arm control ----
E             .../robot/xlerobot/connection.py:449: # ---- Full observation (mirrors XLerobot.get_observation) ----
E             .../robot/xlerobot/connection.py:517: # ---- Camera ----
E             .../robot/xlerobot/connection.py:535: # ---- Lifecycle ----
E             .../robot/xlerobot/xlerobot_module.py:210: # ---- Skills (exposed to MCP / agent) ----
E             .../robot/xlerobot/xlerobot_module.py:360: # ---- @rpc methods ----

lineno     = 360
path       = '.../robot/xlerobot/xlerobot_module.py'
report_lines = ['Found 7 section marker(s). If a file is too complicated to be understood without sections, then the sections should ...ervation (mirrors XLerobot.get_observation) ----', '  .../robot/xlerobot/connection.py:517: # ---- Camera ----', ...]
text       = '    # ---- @rpc methods ----'
violations = [('.../robot/xlerobot/connection.py', 293, '    # ---- Base movement ----'), ('.../robot/xlerobot/connection.py', ...cycle ----'), ('.../robot/xlerobot/xlerobot_module.py', 210, '    # ---- Skills (exposed to MCP / agent) ----'), ...]

dimos/project/test_no_sections.py:145: AssertionError
dimos.robot.test_all_blueprints_generation::test_all_blueprints_is_current
Stack Traces | 2.65s run time
def test_all_blueprints_is_current() -> None:
        root = DIMOS_PROJECT_ROOT / "dimos"
        all_blueprints, all_modules = _scan_for_blueprints(root)
    
        common = set(all_blueprints.keys()) & set(all_modules.keys())
        assert not common, (
            f"Names must be unique across blueprints and modules, "
            f"but these appear in both: {sorted(common)}"
        )
    
        generated_content = _generate_all_blueprints_content(all_blueprints, all_modules)
    
        file_path = root / "robot" / "all_blueprints.py"
    
        if "CI" in os.environ:
            if not file_path.exists():
                pytest.fail(f"all_blueprints.py does not exist at {file_path}")
    
            current_content = file_path.read_text()
            if current_content != generated_content:
                diff = difflib.unified_diff(
                    current_content.splitlines(keepends=True),
                    generated_content.splitlines(keepends=True),
                    fromfile="all_blueprints.py (current)",
                    tofile="all_blueprints.py (generated)",
                )
                diff_str = "".join(diff)
>               pytest.fail(
                    f"all_blueprints.py is out of date. Run "
                    f"`pytest dimos/robot/test_all_blueprints_generation.py` locally to update.\n\n"
                    f"Diff:\n{diff_str}"
                )
E               Failed: all_blueprints.py is out of date. Run `pytest dimos/robot/test_all_blueprints_generation.py` locally to update.
E               
E               Diff:
E               --- all_blueprints.py (current)
E               +++ all_blueprints.py (generated)
E               @@ -127,6 +127,8 @@
E                    "xarm6-planner-only": "dimos.manipulation.blueprints:xarm6_planner_only",
E                    "xarm7-planner-coordinator": "dimos.manipulation.blueprints:xarm7_planner_coordinator",
E                    "xarm7-planner-coordinator-agent": "dimos.manipulation.blueprints:xarm7_planner_coordinator_agent",
E               +    "xlerobot-agentic": "dimos.robot.xlerobot.blueprints.agentic.xlerobot_agentic:xlerobot_agentic",
E               +    "xlerobot-basic": "dimos.robot.xlerobot.blueprints.basic.xlerobot_basic:xlerobot_basic",
E                }
E                
E                
E               @@ -239,5 +241,6 @@
E                    "wavefront-frontier-explorer": "dimos.navigation.frontier_exploration.wavefront_frontier_goal_selector.WavefrontFrontierExplorer",
E                    "web-input": "dimos.agents.web_human_input.WebInput",
E                    "websocket-vis-module": "dimos.web.websocket_vis.websocket_vis_module.WebsocketVisModule",
E               +    "x-le-robot-connection": "dimos.robot.xlerobot.xlerobot_module.XLeRobotConnection",
E                    "zed-camera": "dimos.hardware.sensors.camera.zed.camera.ZEDCamera",
E                }

all_blueprints = {'alfred-nav': 'dimos.robot.diy.alfred.blueprints.alfred_nav:alfred_nav', 'coordinator-basic': 'dimos.control.blueprin...sian_ik_mock', 'coordinator-cartesian-ik-piper': 'dimos.control.blueprints.teleop:coordinator_cartesian_ik_piper', ...}
all_modules = {'alfred-high-level': 'dimos.robot.diy.alfred.effector_high_level.AlfredHighLevel', 'arm-teleop-module': 'dimos.teleop..._navigation.BBoxNavigationModule', 'b1-connection-module': 'dimos.robot.unitree.b1.connection.B1ConnectionModule', ...}
common     = set()
current_content = '# Copyright 2025-2026 Dimensional Inc.\n#\n# Licensed under the Apache License, Version 2.0 (the "License");\n# you m...ebsocket_vis_module.WebsocketVisModule",\n    "zed-camera": "dimos.hardware.sensors.camera.zed.camera.ZEDCamera",\n}\n'
diff       = <generator object unified_diff at 0xffdd113835e0>
diff_str   = '--- all_blueprints.py (current)\n+++ all_blueprints.py (generated)\n@@ -127,6 +127,8 @@\n     "xarm6-planner-only": "...t.xlerobot_module.XLeRobotConnection",\n     "zed-camera": "dimos.hardware.sensors.camera.zed.camera.ZEDCamera",\n }\n'
file_path  = PosixPath('.../dimos/robot/all_blueprints.py')
generated_content = '# Copyright 2025-2026 Dimensional Inc.\n#\n# Licensed under the Apache License, Version 2.0 (the "License");\n# you m...bot.xlerobot_module.XLeRobotConnection",\n    "zed-camera": "dimos.hardware.sensors.camera.zed.camera.ZEDCamera",\n}\n'
root       = PosixPath('.../dimos/dimos/dimos')

dimos/robot/test_all_blueprints_generation.py:66: Failed

To view more test analytics, go to the Test Analytics Dashboard
📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

…ra, lint

- Serialize all Feetech bus IO behind a reentrant _io_lock (read/write/ping/
  connect/disconnect) to prevent interleaved access from sensor + control threads
- Guard 3-wheel base math: return zeros / skip instead of crashing the sensor
  thread on a pruned wheel count
- Build head-camera frames via Image.from_numpy(..., ImageFormat.BGR) instead of
  invalid height/width/encoding kwargs
- Wrap calibrate bus calibration in try/finally so both buses disconnect on error
- Add xlerobot to the `all` extra in pyproject.toml
- CI: drop __init__.py files, use setup_logger() over logging.getLogger, remove
  section-marker comments, regenerate all_blueprints.py, ruff cleanups

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant