diff --git a/dimos/hardware/manipulators/so101/__init__.py b/dimos/hardware/manipulators/so101/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/dimos/hardware/manipulators/so101/calibration/so101_arm.json b/dimos/hardware/manipulators/so101/calibration/so101_arm.json
new file mode 100644
index 0000000000..da957f0a1e
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/calibration/so101_arm.json
@@ -0,0 +1,44 @@
+{
+ "shoulder_pan": {
+ "id": 1,
+ "drive_mode": 0,
+ "homing_offset": -2020,
+ "range_min": 744,
+ "range_max": 3362
+ },
+ "shoulder_lift": {
+ "id": 2,
+ "drive_mode": 0,
+ "homing_offset": -1618,
+ "range_min": 782,
+ "range_max": 3154
+ },
+ "elbow_flex": {
+ "id": 3,
+ "drive_mode": 0,
+ "homing_offset": 1436,
+ "range_min": 897,
+ "range_max": 3126
+ },
+ "wrist_flex": {
+ "id": 4,
+ "drive_mode": 0,
+ "homing_offset": -1499,
+ "range_min": 903,
+ "range_max": 3258
+ },
+ "wrist_roll": {
+ "id": 5,
+ "drive_mode": 0,
+ "homing_offset": -601,
+ "range_min": 160,
+ "range_max": 3981
+ },
+ "gripper": {
+ "id": 6,
+ "drive_mode": 0,
+ "homing_offset": 1365,
+ "range_min": 2037,
+ "range_max": 3508
+ }
+}
diff --git a/dimos/hardware/manipulators/so101/lerobot_kinematics.py b/dimos/hardware/manipulators/so101/lerobot_kinematics.py
new file mode 100644
index 0000000000..253e167b8b
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/lerobot_kinematics.py
@@ -0,0 +1,275 @@
+# Copyright 2025-2026 Dimensional Inc.
+#
+# 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 __future__ import annotations
+
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from lerobot.model.kinematics import RobotKinematics # type: ignore[import-not-found]
+import numpy as np
+from scipy.spatial.transform import Rotation as R # type: ignore[import-untyped]
+
+if TYPE_CHECKING:
+ from numpy.typing import NDArray
+
+
+class LerobotKinematics:
+ """LeRobot-based kinematics wrapper.
+
+ - Uses lerobot.model.kinematics.RobotKinematics (placo-based) internally.
+ - Provides:
+ * FK: q -> (pos, quat_wxyz) [q in degrees]
+ * IK: (q_init, target_pos, target_quat_wxyz) -> q_sol [degrees]
+ * J: jacobian(q) [q in radians, uses numerical differentiation]
+
+ Note: FK/IK work in degrees (matching lerobot convention), while
+ jacobian/joint_velocity work in radians for compatibility with existing code.
+ """
+
+ def __init__(
+ self,
+ urdf_path: str | Path,
+ ee_link_name: str,
+ *,
+ max_iters: int = 100,
+ tol: float = 1e-4,
+ damping: float = 1e-3,
+ joint_names: list[str] | None = None,
+ ) -> None:
+ """
+ Parameters
+ ----------
+ urdf_path:
+ Path to the URDF file.
+ ee_link_name:
+ Name of the end-effector link as defined in the URDF.
+ max_iters:
+ Maximum number of iterations for iterative IK (not used by lerobot, kept for compatibility).
+ tol:
+ Pose error tolerance (not used by lerobot, kept for compatibility).
+ damping:
+ Damping λ for damped least-squares IK / velocity control.
+ joint_names:
+ Optional list of joint names. If None, will be inferred from URDF.
+ """
+ urdf_path = Path(urdf_path)
+
+ self._lerobot_kin = RobotKinematics(
+ urdf_path=str(urdf_path),
+ target_frame_name=ee_link_name,
+ joint_names=joint_names,
+ )
+
+ self.joint_names: list[str] = self._lerobot_kin.joint_names
+ self.motor_names: list[str] = self.joint_names
+ self.dof: int = len(self.joint_names)
+
+ self._max_iters = int(max_iters)
+ self._tol = float(tol)
+ self._damping = float(damping)
+
+ self._jacobian_eps = 1e-6
+
+ # ------------------------------------------------------------------
+ # Forward Kinematics
+ # ------------------------------------------------------------------
+ def fk(self, positions: list[float]) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
+ """
+ Forward kinematics: joint vector -> (position, quaternion_wxyz).
+
+ Parameters
+ ----------
+ positions:
+ Joint configuration in degrees, shape (dof,).
+
+ Returns
+ -------
+ pos:
+ End-effector position in meters, shape (3,).
+ quat_wxyz:
+ End-effector orientation quaternion (w, x, y, z), shape (4,).
+ """
+ q = np.asarray(positions, dtype=float).reshape(-1)
+ if q.shape[0] != self.dof:
+ raise ValueError(f"Expected {self.dof} DoF, got {q.shape[0]}")
+ T = self._lerobot_kin.forward_kinematics(q)
+
+ pos = T[:3, 3]
+ R_matrix = T[:3, :3]
+ rot = R.from_matrix(R_matrix)
+ quat_xyzw = rot.as_quat()
+ quat_wxyz = np.array([quat_xyzw[3], quat_xyzw[0], quat_xyzw[1], quat_xyzw[2]], dtype=float)
+ return pos, quat_wxyz
+
+ # ------------------------------------------------------------------
+ # Inverse Kinematics
+ # ------------------------------------------------------------------
+ def ik(
+ self,
+ positions: list[float],
+ target_position: list[float],
+ target_quaternion_wxyz: list[float],
+ position_weight: float = 1.0,
+ orientation_weight: float = 1.0,
+ ) -> NDArray[np.float64]:
+ """
+ Inverse kinematics using Jacobian-based iterative solver.
+
+ This uses damped least-squares (DLS) iteration to find joint angles
+ that achieve the target pose.
+
+ Parameters
+ ----------
+ positions:
+ Initial joint configuration in degrees, shape (dof,).
+ target_position:
+ Target position in world frame (meters), shape (3,).
+ target_quaternion_wxyz:
+ Target orientation quaternion (w, x, y, z), shape (4,).
+ position_weight:
+ Weight for position error (default 1.0).
+ orientation_weight:
+ Weight for orientation error (default 1.0).
+
+ Returns
+ -------
+ q_sol:
+ Joint vector in degrees, shape (dof,), in the same order as `joint_names`.
+ """
+ q_init = np.asarray(positions, dtype=float).reshape(-1)
+ if q_init.shape[0] != self.dof:
+ raise ValueError(f"Expected {self.dof} DoF, got {q_init.shape[0]}")
+ target_pos = np.asarray(target_position, dtype=float).reshape(3)
+ target_quat_wxyz = np.asarray(target_quaternion_wxyz, dtype=float).reshape(4)
+
+ target_quat_xyzw = np.array(
+ [target_quat_wxyz[1], target_quat_wxyz[2], target_quat_wxyz[3], target_quat_wxyz[0]],
+ dtype=float,
+ )
+ target_rot = R.from_quat(target_quat_xyzw)
+
+ q = np.radians(q_init.copy())
+ q_deg = np.degrees(q)
+
+ # Iterative IK using Jacobian
+ step_size = 0.5 # Damping factor for stability
+
+ for _ in range(self._max_iters):
+ current_pos, current_quat_wxyz = self.fk(q_deg.tolist())
+
+ pos_error = target_pos - current_pos
+ pos_error_norm = np.linalg.norm(pos_error)
+
+ # Orientation error (axis-angle representation)
+ current_quat_xyzw = np.array(
+ [
+ current_quat_wxyz[1],
+ current_quat_wxyz[2],
+ current_quat_wxyz[3],
+ current_quat_wxyz[0],
+ ],
+ dtype=float,
+ )
+ current_rot = R.from_quat(current_quat_xyzw)
+
+ rot_error = target_rot * current_rot.inv()
+
+ # --- 5DoF orientation projection ---
+ w_world = rot_error.as_rotvec()
+ w_tool = current_rot.inv().apply(w_world)
+ w_tool[0] = 0.0 # block tool-x; keep tool-y + tool-z
+ orientation_error = current_rot.apply(w_tool)
+
+ orientation_error_norm = np.linalg.norm(orientation_error)
+
+ if pos_error_norm < self._tol and orientation_error_norm < self._tol:
+ break
+
+ twist = np.concatenate(
+ [position_weight * pos_error, orientation_weight * orientation_error]
+ )
+ J = self.jacobian(q.tolist())
+
+ JJt = J @ J.T
+ A = JJt + (self._damping**2) * np.eye(6, dtype=float)
+ dq_active = J.T @ np.linalg.solve(A, twist)
+
+ q = q + step_size * dq_active
+
+ # Safety check: verify the solution accuracy
+ q_sol_deg = np.degrees(q)
+ final_pos, _ = self.fk(q_sol_deg.tolist())
+ final_pos_error = np.linalg.norm(target_pos - final_pos)
+
+ if final_pos_error > 0.1: # 10cm threshold
+ raise ValueError(
+ "IK solver unable to find satisfactory solution. "
+ f"Final position error: {final_pos_error * 100:.2f}cm (threshold: 10cm). "
+ f"Target position: {target_pos}, Final position: {final_pos}"
+ )
+
+ return np.array(q_sol_deg, dtype=np.float64)
+
+ # ------------------------------------------------------------------
+ # Jacobian & velocity-level control
+ # ------------------------------------------------------------------
+ def jacobian(self, positions: list[float]) -> NDArray[np.float64]:
+ """
+ Return the 6 x dof geometric Jacobian at configuration q.
+
+ Uses numerical differentiation since lerobot doesn't provide analytical jacobian.
+
+ Parameters
+ ----------
+ positions:
+ Joint configuration in radians, shape (dof,).
+
+ Returns
+ -------
+ J:
+ Geometric Jacobian matrix, shape (6, dof).
+ First 3 rows: linear velocity (vx, vy, vz)
+ Last 3 rows: angular velocity (wx, wy, wz)
+ """
+ q = np.asarray(positions, dtype=float).reshape(-1)
+ if q.shape[0] != self.dof:
+ raise ValueError(f"Expected {self.dof} DoF, got {q.shape[0]}")
+ q_deg = np.degrees(q)
+ pos0, quat0_wxyz = self.fk(q_deg.tolist())
+ quat0_xyzw = np.array([quat0_wxyz[1], quat0_wxyz[2], quat0_wxyz[3], quat0_wxyz[0]])
+ rot0 = R.from_quat(quat0_xyzw)
+ R0 = rot0.as_matrix()
+
+ J = np.zeros((6, self.dof), dtype=float)
+ eps_rad = self._jacobian_eps
+
+ for i in range(self.dof):
+ q_pert_deg = q_deg.copy()
+ q_pert_deg[i] += np.degrees(eps_rad)
+
+ pos1, quat1_wxyz = self.fk(q_pert_deg.tolist())
+ J[:3, i] = (pos1 - pos0) / eps_rad
+
+ quat1_xyzw = np.array([quat1_wxyz[1], quat1_wxyz[2], quat1_wxyz[3], quat1_wxyz[0]])
+ rot1 = R.from_quat(quat1_xyzw)
+ R1 = rot1.as_matrix()
+
+ dR = R1 @ R0.T
+ skew = (dR - dR.T) / (2.0 * eps_rad)
+ J[3, i] = skew[2, 1]
+ J[4, i] = skew[0, 2]
+ J[5, i] = skew[1, 0]
+
+ return J
diff --git a/dimos/hardware/manipulators/so101/so101_driver.py b/dimos/hardware/manipulators/so101/so101_driver.py
new file mode 100644
index 0000000000..3f525a16f9
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/so101_driver.py
@@ -0,0 +1,162 @@
+# Copyright 2025-2026 Dimensional Inc.
+#
+# 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.
+
+"""SO101 driver using the generalized component-based architecture."""
+
+import logging
+import time
+from typing import Any
+
+from dimos.hardware.manipulators.base import (
+ BaseManipulatorDriver,
+ StandardMotionComponent,
+ StandardServoComponent,
+ StandardStatusComponent,
+)
+
+from .so101_wrapper import SO101SDKWrapper
+
+logger = logging.getLogger(__name__)
+
+
+class SO101Driver(BaseManipulatorDriver):
+ """SO101 driver using component-based architecture.
+
+ This driver supports the SO101 LeRobot manipulator.
+ All the complex logic is handled by the base class and standard components.
+ This file just assembles the pieces.
+ """
+
+ def __init__(self, **kwargs: Any) -> None:
+ """Initialize the SO101 driver.
+
+ Args:
+ **kwargs: Arguments for Module initialization.
+ Driver configuration can be passed via 'config' keyword arg:
+ - port: USB port id (e.g., '/dev/ttyUSB0')
+ - has_gripper: Whether gripper is attached
+ - calibration_path: Path of SO101 calibration file
+ - enable_on_start: Whether to enable servos on start
+ """
+ config: dict[str, Any] = kwargs.pop("config", {})
+
+ # Extract driver-specific params that might be passed directly
+ driver_params = [
+ "port",
+ "has_gripper",
+ "calibration_path",
+ "enable_on_start",
+ "control_rate",
+ "monitor_rate",
+ ]
+ for param in driver_params:
+ if param in kwargs:
+ config[param] = kwargs.pop(param)
+
+ logger.info(f"Initializing SO101Driver with config: {config}")
+
+ # Create SDK wrapper
+ sdk = SO101SDKWrapper()
+
+ # Create standard components
+ components = [
+ StandardMotionComponent(sdk),
+ StandardServoComponent(sdk),
+ StandardStatusComponent(sdk),
+ ]
+
+ # Optional: Add gripper component if configured
+ # if config.get('has_gripper', False):
+ # from dimos.hardware.manipulators.base.components import StandardGripperComponent
+ # components.append(StandardGripperComponent(sdk))
+
+ # Remove any kwargs that would conflict with explicit arguments
+ kwargs.pop("sdk", None)
+ kwargs.pop("components", None)
+ kwargs.pop("name", None)
+
+ # Initialize base driver with SDK and components
+ super().__init__(
+ sdk=sdk, components=components, config=config, name="SO101Driver", **kwargs
+ )
+
+ # Initialize position target for velocity integration
+ self._position_target: list[float] | None = None
+ self._last_velocity_time: float = 0.0
+
+ # Enable on start if configured
+ if config.get("enable_on_start", False):
+ logger.info("Enabling SO101 servos on start...")
+ servo_component = self.get_component(StandardServoComponent)
+ if servo_component:
+ result = servo_component.enable_servo()
+ if result["success"]:
+ logger.info("SO101 servos enabled successfully")
+ else:
+ logger.warning(f"Failed to enable servos: {result.get('error')}")
+
+ def _process_command(self, command: Any) -> None:
+ """Override to implement velocity control via position integration.
+
+ Args:
+ command: Command to process
+ """
+ # Handle velocity commands specially for SO101
+ if command.type == "velocity":
+ # SO101 doesn't have native velocity control - integrate to position
+ current_time = time.time()
+
+ # Initialize position target from current state on first velocity command
+ if self._position_target is None:
+ positions = self.shared_state.joint_positions
+ if positions:
+ self._position_target = list(positions)
+ logger.info(
+ f"Velocity control: Initialized position target from current state: {self._position_target}"
+ )
+ else:
+ logger.warning("Cannot start velocity control - no current position available")
+ return
+
+ # Calculate dt since last velocity command
+ if self._last_velocity_time > 0:
+ dt = current_time - self._last_velocity_time
+ else:
+ dt = 1.0 / self.control_rate # Use nominal period for first command
+
+ self._last_velocity_time = current_time
+
+ # Integrate velocity to position: pos += vel * dt
+ velocities = command.data["velocities"]
+ for i in range(min(len(velocities), len(self._position_target))):
+ self._position_target[i] += velocities[i] * dt
+
+ # Send integrated position command
+ success = self.sdk.set_joint_positions(
+ self._position_target,
+ wait=False,
+ use_ptp=False, # type: ignore[call-arg]
+ )
+
+ if success:
+ self.shared_state.target_positions = self._position_target
+ self.shared_state.target_velocities = velocities
+ else:
+ # Reset velocity integration when switching to position mode
+ if command.type == "position":
+ self._position_target = None
+ self._last_velocity_time = 0.0
+
+ # Use base implementation for other command types
+ super()._process_command(command)
diff --git a/dimos/hardware/manipulators/so101/so101_wrapper.py b/dimos/hardware/manipulators/so101/so101_wrapper.py
new file mode 100644
index 0000000000..871fbe2966
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/so101_wrapper.py
@@ -0,0 +1,703 @@
+# Copyright 2025-2026 Dimensional Inc.
+#
+# 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 json
+import logging
+import threading
+import time
+from typing import Any
+
+from lerobot.motors import Motor, MotorCalibration, MotorNormMode # type: ignore[import-not-found]
+from lerobot.motors.feetech import FeetechMotorsBus, OperatingMode # type: ignore[import-not-found]
+import numpy as np
+from scipy.spatial.transform import Rotation as R # type: ignore[import-untyped]
+
+from ..base.sdk_interface import BaseManipulatorSDK, ManipulatorInfo
+from .lerobot_kinematics import LerobotKinematics
+
+
+class SO101SDKWrapper(BaseManipulatorSDK):
+ def __init__(
+ self,
+ port: str = "/dev/ttyUSB0",
+ urdf_path: str = "dimos/hardware/manipulators/so101/urdf/so101_new_calib.urdf",
+ ee_link_name: str = "gripper_frame_link",
+ calibration_path: str = "dimos/hardware/manipulators/so101/calibration/so101_arm.json",
+ ):
+ self.logger = logging.getLogger(self.__class__.__name__)
+ self.dof = 5 # SO101 is always 5-DOF
+ self._connected = False
+ self._enabled = False
+ self._lock = threading.Lock()
+ self.gripper_max_open_m = 0.1
+
+ self.port = port
+ self.urdf_path = urdf_path
+ self.ee_link_name = ee_link_name
+ self.calibration_path = calibration_path
+
+ self.bus: FeetechMotorsBus | None = None
+
+ # Motor configuration: 5 DOF arm + 1 gripper
+ self.motor_names = [
+ "shoulder_pan",
+ "shoulder_lift",
+ "elbow_flex",
+ "wrist_flex",
+ "wrist_roll",
+ ]
+ self.gripper_name = "gripper"
+ # Joint angle offsets in degrees
+ # Measured angles when the arm is supposed to be mechanically at zero.
+ self.joint_offsets_deg = np.array(
+ [0, 0.26373626, 2.59340659, 0.65934066, 0.21978022], dtype=float
+ )
+
+ self.motor_ids: dict[str, int] = {
+ "shoulder_pan": 1,
+ "shoulder_lift": 2,
+ "elbow_flex": 3,
+ "wrist_flex": 4,
+ "wrist_roll": 5,
+ "gripper": 6,
+ }
+
+ self.min_speed = 0.2
+ self.max_speed = 1.0
+
+ # Initialize kinematics
+ self.kinematics: LerobotKinematics | None = None
+ try:
+ self.kinematics = LerobotKinematics(
+ self.urdf_path,
+ self.ee_link_name,
+ joint_names=self.motor_names,
+ )
+ self.logger.info(
+ "Initialized LerobotKinematics with URDF %s, EE link %s",
+ self.urdf_path,
+ self.ee_link_name,
+ )
+ except Exception as e:
+ self.logger.warning(f"Failed to initialize kinematics: {e}")
+
+ # ============= Connection Management =============
+
+ def _load_calibration(self, calibration_path: str = "") -> dict[str, MotorCalibration]:
+ """Load motor calibration from JSON file."""
+ try:
+ with open(calibration_path) as f:
+ calib_data = json.load(f)
+ calibration: dict[str, MotorCalibration] = {}
+ for name, data in calib_data.items():
+ calibration[name] = MotorCalibration(**data)
+ return calibration
+ except Exception as e:
+ self.logger.warning(f"Failed to load calibration from {calibration_path}: {e}")
+ return {}
+
+ def configure_motors(self) -> None:
+ """
+ Configure motors: operating mode + PID.
+
+ - Puts all motors (joints + gripper) into POSITION mode.
+ - Sets PID coefficients tuned to reduce shakiness.
+ """
+ if not self.bus:
+ raise RuntimeError("Bus not connected.")
+
+ self.logger.info("Configuring motors (OperatingMode + PID gains)")
+
+ # Disable torque while tweaking settings
+ with self._lock:
+ with self.bus.torque_disabled():
+ # Let LeRobot configure registers (limits, accel, etc.)
+ self.bus.configure_motors()
+
+ for motor in self.bus.motors:
+ # Position control for all motors
+ self.bus.write("Operating_Mode", motor, OperatingMode.POSITION.value)
+
+ # PID gains – tuned for smoother motion
+ self.bus.write("P_Coefficient", motor, 16)
+ self.bus.write("I_Coefficient", motor, 0)
+ self.bus.write("D_Coefficient", motor, 32)
+
+ self.logger.info("Motor configuration complete")
+
+ def connect(self, config: dict[str, Any]) -> bool:
+ """Connect to SO101 via bus.
+
+ Args:
+ config: Configuration with 'port' (e.g., 'dev/ttyUSB0') and config path
+
+ Returns:
+ True if connection successful
+ """
+ try:
+ port = config.get("port", "/dev/ttyUSB0")
+ calibration_path = config.get(
+ "calibration_path", "dimos/hardware/manipulators/so101/calibration/so101_arm.json"
+ )
+ self.control_rate = config.get("control_rate", 50)
+ self.control_dt = 1.0 / self.control_rate
+ self.logger.info("Connecting to SO-101 arm on port %s", port)
+
+ norm_mode_body = MotorNormMode.DEGREES
+ motors: dict[str, Motor] = {}
+
+ # Arm joints: normalized in degrees
+ for name in self.motor_names:
+ motors[name] = Motor(self.motor_ids[name], "sts3215", norm_mode_body)
+
+ # Gripper: normalized in [0, 100]
+ motors[self.gripper_name] = Motor(
+ self.motor_ids[self.gripper_name],
+ "sts3215",
+ MotorNormMode.RANGE_0_100,
+ )
+
+ calibration = self._load_calibration(calibration_path)
+
+ self.bus = FeetechMotorsBus(
+ port=port,
+ motors=motors,
+ calibration=calibration,
+ )
+ self.bus.connect()
+ self.logger.info("FeetechMotorsBus connected")
+
+ # Configure modes, PID, etc.
+ self.configure_motors()
+ self._connected = True
+ return True
+ except Exception as e:
+ self.logger.error(f"Failed to connect to SO-101 arm: {e}")
+ return False
+
+ def disconnect(self) -> None:
+ """Disconnect from the arm."""
+ if self.bus:
+ with self._lock:
+ self.bus.disconnect()
+ self.logger.info("SO-101 bus disconnected")
+ self._connected = False
+
+ def is_connected(self) -> bool:
+ return self._connected
+
+ # ============= Joint State Query =============
+
+ def get_joint_positions(self, degree: bool = False) -> list[float]:
+ """
+ Get current joint angles with joint offsets applied.
+
+ Returns:
+ Joint positions in RADIANS or DEGREES (default: RADIANS)
+ """
+ if not self.bus:
+ return [0.0] * len(self.motor_names)
+
+ with self._lock:
+ values = self.bus.sync_read("Present_Position")
+ q_deg_raw = np.array([values[name] for name in self.motor_names], dtype=float)
+ q_deg = q_deg_raw - self.joint_offsets_deg
+ return q_deg.tolist() if degree else np.radians(q_deg).tolist() # type: ignore[no-any-return]
+
+ def get_joint_velocities(self) -> list[float]:
+ """Get current joint velocities.
+
+ Returns:
+ Joint velocities in RAD/S (0-indexed)
+ """
+ try:
+ if not self.bus:
+ return [0.0] * len(self.motor_names)
+
+ with self._lock:
+ values = self.bus.sync_read("Present_Velocity")
+ vel_deg = np.array([values[name] for name in self.motor_names], dtype=float)
+ return np.radians(vel_deg).tolist() # type: ignore[no-any-return]
+ except Exception as e:
+ self.logger.debug(f"Error when reading Present_Velocity: {e}")
+ return [0.0] * len(self.motor_names)
+
+ def get_joint_efforts(self) -> list[float]:
+ """Get current joint efforts/torques.
+
+ Returns:
+ Joint efforts in Nm (0-indexed)
+ """
+ # Not possible with Feetech motors so returning zeros
+ return [0.0] * len(self.motor_names)
+
+ # ============= Joint Motion Control =============
+
+ def move_joint_ptp(
+ self, positions: list[float], velocity: float = 1.0, duration: float | None = None
+ ) -> None:
+ """
+ Joint-space PTP interpolation.
+
+ Args:
+ q_target: target joint angles [rad], shape (5,)
+ velocity: Max velocity fraction (0-1)
+ duration: total motion time (s). If None, derived from velocity.
+ """
+ if not self.bus:
+ raise RuntimeError("Bus not connected.")
+
+ q_target = np.asarray(positions, dtype=float)
+ if q_target.shape[0] != len(self.motor_names):
+ raise ValueError(f"Expected {len(self.motor_names)} joints, got {q_target.shape[0]}")
+
+ q_start = np.array(self.get_joint_positions(degree=False))
+ dq = q_target - q_start
+ max_delta = float(np.max(np.abs(dq)))
+
+ if max_delta < 1e-6:
+ q_deg = np.degrees(q_target)
+ cmd = {name: float(q_deg[i]) for i, name in enumerate(self.motor_names)}
+ with self._lock:
+ self.bus.sync_write("Goal_Position", cmd)
+ return
+
+ if duration is None:
+ # Derive from velocity: 0..1 → 0.2..1.0 rad/s (tunable)
+ joint_speed = self.min_speed + (self.max_speed - self.min_speed) * velocity
+ if joint_speed < 1e-3:
+ joint_speed = self.min_speed
+
+ duration = max_delta / joint_speed
+ if duration < self.control_dt:
+ duration = self.control_dt
+
+ steps = max(int(duration / self.control_dt), 1)
+
+ for i in range(1, steps + 1):
+ alpha = i / steps
+ q_interp = q_start + alpha * dq
+ q_deg = np.degrees(q_interp)
+ cmd = {name: float(q_deg[j]) for j, name in enumerate(self.motor_names)}
+ with self._lock:
+ self.bus.sync_write("Goal_Position", cmd)
+ time.sleep(self.control_dt)
+
+ def set_joint_positions(
+ self,
+ positions: list[float],
+ velocity: float = 1.0,
+ _acceleration: float = 1.0,
+ wait: bool = False,
+ use_ptp: bool = True,
+ ) -> bool:
+ """Move joints to target positions.
+
+ Args:
+ positions: Target positions in RADIANS (0-indexed)
+ velocity: Max velocity fraction (0-1)
+ _acceleration: [UNUSED] Kept for interface compatibility
+ wait: If True, block until motion completes
+ use_ptp: If True, use PTP interpolation.
+ If False, send command directly (assumes trajectory generated externally).
+
+ Returns:
+ True if command accepted
+ """
+
+ if not self.bus:
+ return False
+
+ if use_ptp:
+ self.move_joint_ptp(positions, velocity=velocity)
+ result = True
+ else:
+ q_target = np.asarray(positions, dtype=float)
+ q_deg = np.degrees(q_target)
+ cmd = {name: float(q_deg[i]) for i, name in enumerate(self.motor_names)}
+ with self._lock:
+ self.bus.sync_write("Goal_Position", cmd)
+ result = True
+
+ if wait and result:
+ start_time = time.time()
+ timeout = 30.0 # 30 second timeout
+
+ while time.time() - start_time < timeout:
+ try:
+ # Check if reached target (within tolerance)
+ current = self.get_joint_positions()
+ tolerance = 0.05 # radians
+ if all(
+ abs(current[i] - positions[i]) < tolerance
+ for i in range(len(self.motor_names))
+ ):
+ break
+ except Exception:
+ pass # Continue waiting
+ time.sleep(0.01)
+
+ return result
+
+ def set_joint_velocities(self, velocities: list[float]) -> bool:
+ """Set joint velocity targets.
+
+ Note: Lerobot doesn't have native velocity control. The driver should
+ implement velocity control via position integration if needed.
+
+ Args:
+ velocities: Target velocities in RAD/S (0-indexed)
+
+ Returns:
+ False - velocity control not supported at SDK level
+ """
+ # Lerobot doesn't have native velocity control
+ # The driver layer should implement this via position integration
+ self.logger.debug("Velocity control not supported at SDK level - use position integration")
+ return False
+
+ def set_joint_efforts(self, efforts: list[float]) -> bool:
+ """Set joint effort/torque targets.
+
+ Note: Lerobot doesn't have native torque control. The driver should
+ implement torque control via position integration if needed.
+ Args:
+ efforts: Target efforts in Nm (0-indexed)
+
+ Returns:
+ False - torque control not supported at SDK level
+ """
+ self.logger.debug("Torque control not supported at SDK level - use position integration")
+ return False
+
+ def stop_motion(self) -> bool:
+ """Stop all ongoing motion.
+
+ Returns:
+ True if stop successful
+ """
+ # SO101 emergency stop
+ try:
+ self.emergency_stop()
+ except Exception:
+ curr_pos = self.get_joint_positions()
+ self.set_joint_positions(curr_pos)
+ return True
+
+ # ============= Servo Control =============
+ def enable_servos(self) -> bool:
+ """Enable motor control.
+
+ Returns:
+ True if servos enabled
+ """
+ if not self.bus:
+ return False
+ with self._lock:
+ self.bus.enable_torque()
+ self._enabled = True
+ return True
+
+
+ def disable_servos(self) -> bool:
+ """Disable motor control.
+
+ Returns:
+ True if servos disabled
+ """
+ if not self.bus:
+ return False
+ with self._lock:
+ self.bus.disable_torque()
+ self._enabled = False
+ return True
+
+ def are_servos_enabled(self) -> bool:
+ """Check if servos are enabled.
+
+ Returns:
+ True if enabled
+ """
+ return self._enabled
+
+ # ============= System State =============
+ def get_robot_state(self) -> dict[str, Any]:
+ """Get current robot state.
+
+ Returns:
+ State dictionary
+ """
+ if not self.bus:
+ return {
+ "state": 2, # Error if can't get status
+ "mode": 0,
+ "error_code": 999,
+ "warn_code": 0,
+ "is_moving": False,
+ "cmd_num": 0,
+ }
+
+ # Default state mapping
+ state = 0 # idle
+ mode = 0 # position mode
+ error_code = 0
+
+ error_code = self.get_error_code()
+ state = 2 if error_code != 0 else 0
+
+ return {
+ "state": state,
+ "mode": mode,
+ "error_code": error_code,
+ "warn_code": 0,
+ "is_moving": False,
+ "cmd_num": 0,
+ }
+
+ def get_error_code(self) -> int:
+ """Get current error code.
+
+ Returns:
+ Error code (0 = no error)
+ """
+ if not self.bus:
+ return 0
+
+ try:
+ with self._lock:
+ err_codes = {k: int(v) for k, v in self.bus.sync_read("Status").items()}
+ except Exception:
+ self.logger.exception("sync_read(Status) failed")
+ return 0
+ agg = 0
+ for _, code in err_codes.items():
+ agg |= int(code)
+ return agg
+
+ def _decode_hw_error_bits(self, err_code: int) -> list[str]:
+ error_map = {
+ 0: "Voltage error",
+ 1: "Sensor/encoder error",
+ 2: "Overtemperature",
+ 3: "Overcurrent",
+ 4: "Angle/position error",
+ 5: "Overload",
+ }
+
+ msgs = []
+ for bit, label in error_map.items():
+ if err_code & (1 << bit):
+ msgs.append(label)
+ return msgs
+
+ def get_error_message(self) -> str:
+ """Get human-readable error message.
+
+ Returns:
+ Error message string
+ """
+ error_code = self.get_error_code()
+ if error_code == 0:
+ return ""
+
+ msgs = self._decode_hw_error_bits(error_code)
+ if msgs:
+ return ", ".join(msgs)
+ return f"Unknown error code: {error_code}"
+
+ def clear_errors(self) -> bool:
+ """Clear error states.
+
+ Returns:
+ True if errors cleared
+ """
+ self.disable_servos()
+ time.sleep(0.1)
+ return self.enable_servos()
+
+ def emergency_stop(self) -> bool:
+ """Execute emergency stop.
+
+ Returns:
+ True if e-stop executed
+ """
+ return self.disable_servos()
+
+ # ============= Information =============
+ def get_info(self) -> ManipulatorInfo:
+ """Get manipulator information.
+
+ Returns:
+ ManipulatorInfo object
+ """
+ return ManipulatorInfo(
+ vendor="LeRobot",
+ model="SO101",
+ dof=self.dof,
+ firmware_version=None, # Lerobot does not expose firmware version
+ serial_number=None, # Lerobot does not expose serial number
+ )
+
+ def get_joint_limits(self) -> tuple[list[float], list[float]]:
+ """Get joint position limits.
+
+ Returns:
+ Tuple of (lower_limits, upper_limits) in RADIANS
+ """
+ lower_limits = [-1.919, -1.74, -1.69, -1.65, -2.74]
+ upper_limits = [1.919, 1.74, 1.69, 1.65, 2.84]
+
+ return (lower_limits, upper_limits)
+
+ def get_velocity_limits(self) -> list[float]:
+ """Get joint velocity limits.
+
+ Returns:
+ Maximum velocities in RAD/S
+ """
+ # SO101 max velocities (approximate)
+ max_vel = 2.0 # rad/s
+ return [max_vel] * self.dof
+
+ def get_acceleration_limits(self) -> list[float]:
+ """Get joint acceleration limits.
+
+ Returns:
+ Maximum accelerations in RAD/S²
+ """
+ # SO101 max accelerations (approximate)
+ max_acc = 8.0 # rad/s²
+ return [max_acc] * self.dof
+
+ # ============= Optional Methods =============
+ def set_gripper_position(self, position: float) -> bool:
+ """
+ Move gripper to target position.
+
+ Args:
+ position: 0.0 (closed) to 0.1 (open) in meters.
+ """
+ if not self.bus:
+ self.logger.warning("Robot not connected")
+ return False
+
+ # Map 0–0.1 m → 0–100 normalized range
+ val = (position / 0.1) * 100.0
+ val = max(0.0, min(100.0, val))
+ with self._lock:
+ self.bus.write("Goal_Position", self.gripper_name, val)
+ return True
+
+ def get_gripper_position(self) -> float | None:
+ """
+ Get gripper position.
+
+ Returns:
+ Position in meters ( 0.0 [closed] to 0.1 [open]) or None
+ """
+ if not self.bus:
+ return None
+
+ with self._lock:
+ raw = float(self.bus.read("Present_Position", self.gripper_name))
+ pos_m = (raw / 100.0) * self.gripper_max_open_m
+ return max(0.0, min(self.gripper_max_open_m, pos_m))
+
+ def get_cartesian_position(self) -> dict[str, float] | None:
+ """Get current end-effector pose.
+
+ Returns:
+ Pose dict or None if not supported
+ """
+ if self.kinematics:
+ q = self.get_joint_positions(degree=True)
+ pos, quat_wxyz = self.kinematics.fk(q)
+
+ # Convert quaternion (w, x, y, z) to (x, y, z, w) for scipy
+ quat_xyzw = [quat_wxyz[1], quat_wxyz[2], quat_wxyz[3], quat_wxyz[0]]
+ rot = R.from_quat(quat_xyzw)
+ roll, pitch, yaw = rot.as_euler("xyz", degrees=False)
+
+ return {
+ "x": float(pos[0]),
+ "y": float(pos[1]),
+ "z": float(pos[2]),
+ "roll": float(roll),
+ "pitch": float(pitch),
+ "yaw": float(yaw),
+ }
+ return None
+
+ def set_cartesian_position(
+ self,
+ pose: dict[str, float],
+ velocity: float = 1.0,
+ acceleration: float = 1.0,
+ wait: bool = False,
+ ) -> bool:
+ """Move end-effector to target pose.
+
+ Args:
+ pose: Target pose dict
+ velocity: Max velocity fraction (0-1)
+ acceleration: Max acceleration fraction (0-1)
+ wait: Block until complete
+
+ Returns:
+ True if command accepted
+ """
+ if not self.kinematics:
+ self.logger.warning("Cartesian control not available")
+ return False
+
+ curr_joint_angles = self.get_joint_positions(degree=True)
+ target_pos = [pose["x"], pose["y"], pose["z"]]
+
+ # Convert RPY to Quaternion (wxyz)
+ r = R.from_euler("xyz", [pose["roll"], pose["pitch"], pose["yaw"]], degrees=False)
+ quat_xyzw = r.as_quat()
+ target_quat_wxyz = [quat_xyzw[3], quat_xyzw[0], quat_xyzw[1], quat_xyzw[2]]
+ try:
+ q_target_kin_deg = self.kinematics.ik(curr_joint_angles, target_pos, target_quat_wxyz)
+ q_target = np.radians(q_target_kin_deg)
+ self.set_joint_positions(q_target.tolist())
+ except ValueError as e:
+ self.logger.error(f"Value Error Raised: {e}")
+ return False
+
+ # Wait if requested
+ if wait:
+ start_time = time.time()
+ timeout = 30.0
+
+ while time.time() - start_time < timeout:
+ current = self.get_cartesian_position()
+ if current:
+ # Check if reached target (within tolerance)
+ tol_pos = 0.01 # 1cm
+ tol_rot = 0.1 # ~3 degrees
+
+ if (
+ abs(current["x"] - pose["x"]) < tol_pos
+ and abs(current["y"] - pose["y"]) < tol_pos
+ and abs(current["z"] - pose["z"]) < tol_pos
+ and abs(current["roll"] - pose["roll"]) < tol_rot
+ and abs(current["pitch"] - pose["pitch"]) < tol_rot
+ and abs(current["yaw"] - pose["yaw"]) < tol_rot
+ ):
+ break
+
+ time.sleep(0.01)
+
+ return True
diff --git a/dimos/hardware/manipulators/so101/urdf/README.md b/dimos/hardware/manipulators/so101/urdf/README.md
new file mode 100644
index 0000000000..c50dc327be
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/README.md
@@ -0,0 +1,35 @@
+# SO101 Robot - URDF and MuJoCo Description
+
+This repository contains the URDF and MuJoCo (MJCF) files for the SO101 robot.
+
+## Overview
+
+- The robot model files were generated using the [onshape-to-robot](https://github.com/Rhoban/onshape-to-robot) plugin from a CAD model designed in Onshape.
+- The generated URDFs were modified to allow meshes with relative paths instead of `package://...`.
+- Base collision meshes were removed due to problematic collision behavior during simulation and planning.
+
+## Calibration Methods
+
+The MuJoCo file `scene.xml` supports two differenly calibrated SO101 robot files:
+
+- **New Calibration (Default)**: Each joint's virtual zero is set to the **middle** of its joint range. Use -> `so101_new_calib.xml`.
+- **Old Calibration**: Each joint's virtual zero is set to the configuration where the robot is **fully extended horizontally**. Use -> `so101_old_calib.xml`.
+
+To switch between calibration methods, modify the included robot file in `scene.xml`.
+
+## Motor Parameters
+
+Motor properties for the STS3215 motors used in the robot are adapted from the [Open Duck Mini project](https://github.com/apirrone/Open_Duck_Mini).
+
+## Gripper Note
+
+In LeRobot, the gripper is represented as a **linear joint**, where:
+
+* `0` = fully closed
+* `100` = fully open
+
+This mapping is **not yet reflected** in the current URDF and MuJoCo files.
+
+---
+
+Feel free to open an issue or contribute improvements!
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/base_motor_holder_so101_v1.part b/dimos/hardware/manipulators/so101/urdf/assets/base_motor_holder_so101_v1.part
new file mode 100644
index 0000000000..6d3ac50049
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/base_motor_holder_so101_v1.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "84d8ae1881704ebae1ffb70a",
+ "documentMicroversion": "0eea3500852bdb2f58b1cb79",
+ "documentVersion": "a5c3b0dfaa52ddd6829011cd",
+ "elementId": "22efbe4e0bef24fcd20f96e5",
+ "fullConfiguration": "default",
+ "id": "MCOhripg0ry51VlsC",
+ "isStandardContent": false,
+ "name": "Base_motor_holder_SO101 v1 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/base_motor_holder_so101_v1.stl b/dimos/hardware/manipulators/so101/urdf/assets/base_motor_holder_so101_v1.stl
new file mode 100644
index 0000000000..cac2b8dd98
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/base_motor_holder_so101_v1.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/base_so101_v2.part b/dimos/hardware/manipulators/so101/urdf/assets/base_so101_v2.part
new file mode 100644
index 0000000000..f41563fa8b
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/base_so101_v2.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "bf61a6bc85b1d1a8bf9ea51b",
+ "documentMicroversion": "20484d37162a32a8a41a37f2",
+ "documentVersion": "25801b070e5b360715de8a30",
+ "elementId": "312f32f0073fa6e8e36fba7a",
+ "fullConfiguration": "default",
+ "id": "MY69cJlqvSzIiODdH",
+ "isStandardContent": false,
+ "name": "Base_SO101 v2 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/base_so101_v2.stl b/dimos/hardware/manipulators/so101/urdf/assets/base_so101_v2.stl
new file mode 100644
index 0000000000..ed10aa61d1
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/base_so101_v2.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_base_v1.part b/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_base_v1.part
new file mode 100644
index 0000000000..bf899dd0c0
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_base_v1.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "652d5731024e57367badfda6",
+ "documentMicroversion": "56a8b8013480c176fd87df8d",
+ "documentVersion": "984ac31c92cac3664c8effb3",
+ "elementId": "6fb7b7f9315511b548d670ff",
+ "fullConfiguration": "default",
+ "id": "Mf4ZebMr4BkShucFj",
+ "isStandardContent": false,
+ "name": "Motor_holder_SO101_Base v1 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_base_v1.stl b/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_base_v1.stl
new file mode 100644
index 0000000000..9eaf67243a
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_base_v1.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_wrist_v1.part b/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_wrist_v1.part
new file mode 100644
index 0000000000..dd57728f58
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_wrist_v1.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "4bd66da73cacb4d946d43e44",
+ "documentMicroversion": "2bf56247e58b70e90806e318",
+ "documentVersion": "df78bb7089f1de7d5588d238",
+ "elementId": "d7dfe76e402c21bbd8124e43",
+ "fullConfiguration": "default",
+ "id": "MN9BZ1p69dQQtKTjq",
+ "isStandardContent": false,
+ "name": "Motor_holder_SO101_Wrist v1 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_wrist_v1.stl b/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_wrist_v1.stl
new file mode 100644
index 0000000000..d0aa18b15a
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/motor_holder_so101_wrist_v1.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/moving_jaw_so101_v1.part b/dimos/hardware/manipulators/so101/urdf/assets/moving_jaw_so101_v1.part
new file mode 100644
index 0000000000..959a7eb0c6
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/moving_jaw_so101_v1.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "46218c02ef80d36172edbb35",
+ "documentMicroversion": "68b7d387e2500c451586ae59",
+ "documentVersion": "79c101d1a0207b77362b561a",
+ "elementId": "d4b1411d5d7333298f6e2458",
+ "fullConfiguration": "default",
+ "id": "MrHPLr9hZkrXwcSA4",
+ "isStandardContent": false,
+ "name": "Moving_Jaw_SO101 v1 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/moving_jaw_so101_v1.stl b/dimos/hardware/manipulators/so101/urdf/assets/moving_jaw_so101_v1.stl
new file mode 100644
index 0000000000..7e87366865
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/moving_jaw_so101_v1.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/rotation_pitch_so101_v1.part b/dimos/hardware/manipulators/so101/urdf/assets/rotation_pitch_so101_v1.part
new file mode 100644
index 0000000000..6870d9d55b
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/rotation_pitch_so101_v1.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "14078aa6723c502d07d6902e",
+ "documentMicroversion": "c0fca717407275159bcc6ed7",
+ "documentVersion": "3d9a887ff68fa477d98162b8",
+ "elementId": "43d24b3857ff686b275578bf",
+ "fullConfiguration": "default",
+ "id": "MrQ6Kmk9QDZlwbp95",
+ "isStandardContent": false,
+ "name": "Rotation_Pitch_SO101 v1 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/rotation_pitch_so101_v1.stl b/dimos/hardware/manipulators/so101/urdf/assets/rotation_pitch_so101_v1.stl
new file mode 100644
index 0000000000..5c05dee570
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/rotation_pitch_so101_v1.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_no_horn_v1.part b/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_no_horn_v1.part
new file mode 100644
index 0000000000..0b420437d2
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_no_horn_v1.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "56e5f3702dad85e17841d2e2",
+ "documentMicroversion": "7958a6acbc8e0d0a0a611746",
+ "documentVersion": "29a4c51b8bf277a22743a333",
+ "elementId": "8c14fb13a6557ec89ff5d227",
+ "fullConfiguration": "default",
+ "id": "MOcaIFg8XgL+Ybg9z",
+ "isStandardContent": false,
+ "name": "STS3215_03a_no_horn v1 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_no_horn_v1.stl b/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_no_horn_v1.stl
new file mode 100644
index 0000000000..2a89597702
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_no_horn_v1.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_v1.part b/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_v1.part
new file mode 100644
index 0000000000..e57e2dd6c5
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_v1.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "d2941bdba816affebdc6d6f0",
+ "documentMicroversion": "5904ef3cea04a0d0bc88b698",
+ "documentVersion": "dd4f7470101215836a4ae8c9",
+ "elementId": "e670b72d49b06f88fad5dbd8",
+ "fullConfiguration": "default",
+ "id": "M5vQNpe0onRFueych",
+ "isStandardContent": false,
+ "name": "STS3215_03a v1 <5>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_v1.stl b/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_v1.stl
new file mode 100644
index 0000000000..fe05403dbf
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/sts3215_03a_v1.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/under_arm_so101_v1.part b/dimos/hardware/manipulators/so101/urdf/assets/under_arm_so101_v1.part
new file mode 100644
index 0000000000..bd97af90b7
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/under_arm_so101_v1.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "9f5d6db47eb112442b9f130f",
+ "documentMicroversion": "e99cf45162e34789bd99512b",
+ "documentVersion": "817ebf29c5663d412edc0753",
+ "elementId": "2813aaffe3c8a342616d3527",
+ "fullConfiguration": "default",
+ "id": "M9yAEiX02J3c4HqXa",
+ "isStandardContent": false,
+ "name": "Under_arm_SO101 v1 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/under_arm_so101_v1.stl b/dimos/hardware/manipulators/so101/urdf/assets/under_arm_so101_v1.stl
new file mode 100644
index 0000000000..480e0583c8
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/under_arm_so101_v1.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/upper_arm_so101_v1.part b/dimos/hardware/manipulators/so101/urdf/assets/upper_arm_so101_v1.part
new file mode 100644
index 0000000000..0ef64e9a29
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/upper_arm_so101_v1.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "57f3eae43434311c28ac752b",
+ "documentMicroversion": "33eeab136e831427f0f0ca74",
+ "documentVersion": "435d47b71ef26075bf82672c",
+ "elementId": "a8e0c02dc43f7ccb373c52e4",
+ "fullConfiguration": "default",
+ "id": "Ml3rwO4kV53jDRgcs",
+ "isStandardContent": false,
+ "name": "Upper_arm_SO101 v1 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/upper_arm_so101_v1.stl b/dimos/hardware/manipulators/so101/urdf/assets/upper_arm_so101_v1.stl
new file mode 100644
index 0000000000..9c0f9b275a
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/upper_arm_so101_v1.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/waveshare_mounting_plate_so101_v2.part b/dimos/hardware/manipulators/so101/urdf/assets/waveshare_mounting_plate_so101_v2.part
new file mode 100644
index 0000000000..3189de5738
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/waveshare_mounting_plate_so101_v2.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "066f8b5064455ec46759cd8c",
+ "documentMicroversion": "04c5790374bf3edfbbb7e818",
+ "documentVersion": "408440a116f7d8700bbb11c2",
+ "elementId": "dc35e56269e36de39738b34d",
+ "fullConfiguration": "default",
+ "id": "MjhXxhyF1+iAgCtUh",
+ "isStandardContent": false,
+ "name": "WaveShare_Mounting_Plate_SO101 v2 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/waveshare_mounting_plate_so101_v2.stl b/dimos/hardware/manipulators/so101/urdf/assets/waveshare_mounting_plate_so101_v2.stl
new file mode 100644
index 0000000000..4dba1f22b0
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/waveshare_mounting_plate_so101_v2.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_follower_so101_v1.part b/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_follower_so101_v1.part
new file mode 100644
index 0000000000..77c7ef5eee
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_follower_so101_v1.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "e02f1e1d3fdd766a19a55890",
+ "documentMicroversion": "03f1dfc090db6bbecdb14475",
+ "documentVersion": "8a15327cfbde0344e0951076",
+ "elementId": "2317bd70c68862eeebd64492",
+ "fullConfiguration": "default",
+ "id": "MpI0voU28BOAZ6D9x",
+ "isStandardContent": false,
+ "name": "Wrist_Roll_Follower_SO101 v1 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_follower_so101_v1.stl b/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_follower_so101_v1.stl
new file mode 100644
index 0000000000..123ce374ad
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_follower_so101_v1.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_pitch_so101_v2.part b/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_pitch_so101_v2.part
new file mode 100644
index 0000000000..388f5efa44
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_pitch_so101_v2.part
@@ -0,0 +1,14 @@
+{
+ "configuration": "default",
+ "documentId": "eb144d215e733b8dbbb50b81",
+ "documentMicroversion": "4fef760722dee3a9b5ff19b1",
+ "documentVersion": "5880c1e9413206cac10772d0",
+ "elementId": "3c22c2c23cb0ce545b9df2ba",
+ "fullConfiguration": "default",
+ "id": "Ma99J59HxnSe2TArb",
+ "isStandardContent": false,
+ "name": "Wrist_Roll_Pitch_SO101 v2 <1>",
+ "partId": "JFD",
+ "suppressed": false,
+ "type": "Part"
+}
diff --git a/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_pitch_so101_v2.stl b/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_pitch_so101_v2.stl
new file mode 100644
index 0000000000..ac5172a35e
Binary files /dev/null and b/dimos/hardware/manipulators/so101/urdf/assets/wrist_roll_pitch_so101_v2.stl differ
diff --git a/dimos/hardware/manipulators/so101/urdf/joints_properties.xml b/dimos/hardware/manipulators/so101/urdf/joints_properties.xml
new file mode 100644
index 0000000000..684ffd4576
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/joints_properties.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dimos/hardware/manipulators/so101/urdf/robot.urdf b/dimos/hardware/manipulators/so101/urdf/robot.urdf
new file mode 100644
index 0000000000..120b5eef24
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/robot.urdf
@@ -0,0 +1,450 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
diff --git a/dimos/hardware/manipulators/so101/urdf/scene.xml b/dimos/hardware/manipulators/so101/urdf/scene.xml
new file mode 100644
index 0000000000..cc166be2c4
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/scene.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dimos/hardware/manipulators/so101/urdf/so101_new_calib.urdf b/dimos/hardware/manipulators/so101/urdf/so101_new_calib.urdf
new file mode 100644
index 0000000000..120b5eef24
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/so101_new_calib.urdf
@@ -0,0 +1,450 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
diff --git a/dimos/hardware/manipulators/so101/urdf/so101_new_calib.xml b/dimos/hardware/manipulators/so101/urdf/so101_new_calib.xml
new file mode 100644
index 0000000000..e24ff77732
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/so101_new_calib.xml
@@ -0,0 +1,162 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dimos/hardware/manipulators/so101/urdf/so101_old_calib.urdf b/dimos/hardware/manipulators/so101/urdf/so101_old_calib.urdf
new file mode 100644
index 0000000000..c75bb38ea2
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/so101_old_calib.urdf
@@ -0,0 +1,435 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/PositionJointInterface
+
+
+ hardware_interface/PositionJointInterface
+ 1
+
+
+
+
diff --git a/dimos/hardware/manipulators/so101/urdf/so101_old_calib.xml b/dimos/hardware/manipulators/so101/urdf/so101_old_calib.xml
new file mode 100644
index 0000000000..7bc79e982c
--- /dev/null
+++ b/dimos/hardware/manipulators/so101/urdf/so101_old_calib.xml
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pyproject.toml b/pyproject.toml
index 3faa358b4b..5132d3b2bd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -185,6 +185,11 @@ manipulation = [
# piper arm
"piper-sdk",
+ # lerobot so101 arm
+ "lerobot",
+ "lerobot[feetech]",
+ "lerobot[kinematics]",
+
# Visualization (Optional)
"kaleido>=0.2.1",
"plotly>=5.9.0",