From 8c9d9401861b0bf059ac97aac581d0683254ecc2 Mon Sep 17 00:00:00 2001 From: Kentaro Wu Date: Wed, 15 Jul 2026 00:13:27 +0800 Subject: [PATCH 01/51] feat(manipulators): add Galaxea A1Z arm adapter GalaxeaA1ZAdapter implements the ManipulatorAdapter protocol on top of the vendor a1z SDK (CAN bus, MIT PD + gravity comp at 250 Hz). POSITION (min-jerk planned moves) and SERVO_POSITION (streaming) control modes, latching soft e-stop, FK-based cartesian reads, G1Z gripper support (requires the vendor SDK's 'gripper' branch; meters API, 10 cm measured stroke), and teach-and-play recording exposed as adapter extensions. Startup is a verified zero-force sequence: motors enable at kp=0, every motor must report fresh in-limit feedback before hold gains engage at the measured pose - the vendor's stock start() position-holds a single 50 ms read and snaps the arm to zero if feedback is late (reproduced on hardware). Shutdown re-sends disable frames to all motors including the gripper, whose single vendor disable frame can be lost on a busy bus. Opt out with safe_start=False for vendor-stock behavior. Hardware-validated on a physical A1Z + G1Z. --- .../manipulators/galaxea_a1z/_registry.py | 17 + .../manipulators/galaxea_a1z/adapter.py | 660 ++++++++++++++++++ .../manipulators/galaxea_a1z/test_adapter.py | 465 ++++++++++++ 3 files changed, 1142 insertions(+) create mode 100644 dimos/hardware/manipulators/galaxea_a1z/_registry.py create mode 100644 dimos/hardware/manipulators/galaxea_a1z/adapter.py create mode 100644 dimos/hardware/manipulators/galaxea_a1z/test_adapter.py diff --git a/dimos/hardware/manipulators/galaxea_a1z/_registry.py b/dimos/hardware/manipulators/galaxea_a1z/_registry.py new file mode 100644 index 0000000000..85c1fffe5b --- /dev/null +++ b/dimos/hardware/manipulators/galaxea_a1z/_registry.py @@ -0,0 +1,17 @@ +# 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. + +ADAPTER_FACTORIES = { + "galaxea_a1z": "dimos.hardware.manipulators.galaxea_a1z.adapter:GalaxeaA1ZAdapter", +} diff --git a/dimos/hardware/manipulators/galaxea_a1z/adapter.py b/dimos/hardware/manipulators/galaxea_a1z/adapter.py new file mode 100644 index 0000000000..e174d15def --- /dev/null +++ b/dimos/hardware/manipulators/galaxea_a1z/adapter.py @@ -0,0 +1,660 @@ +# 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. + +"""Galaxea A1Z adapter - implements ManipulatorAdapter protocol. + +SDK Units: angles=radians, velocity=rad/s, torque=Nm (SI throughout - no +conversion needed). + +The a1z SDK runs its own 250 Hz control thread (MIT PD + gravity compensation) +with built-in safety watchdogs (joint/velocity/temperature limits, stale +feedback, loop-frequency). This adapter issues commands to that loop rather +than driving CAN directly. + +Lifecycle mapping: +- connect(): construct the robot (opens the CAN bus; motors stay unpowered) +- activate() / write_enable(True): enable motors and start the control loop +- write_stop(): latching soft e-stop (pins current position; commands rejected + until write_clear_errors()) +- deactivate() / write_enable(False): stop the control loop and DISABLE motors + +SAFETY: the A1 arm has no brakes. Disabling motors (deactivate, write_enable +(False), disconnect) lets the arm fall freely. Lower or support the arm first. + +Zero-gravity (teaching) mode is chosen at construction time via the +zero_gravity kwarg and cannot be switched at runtime; teach recording requires +zero_gravity=True. +""" + +from __future__ import annotations + +from collections.abc import Iterator +import contextlib +from pathlib import Path +import platform +import threading +import time +from typing import Any + +import numpy as np + +from dimos.hardware.manipulators.spec import ( + ControlMode, + JointLimits, + ManipulatorInfo, +) + +# Joint limits from a1z/robots/get_robot.py (_JOINT_LIMITS) +_POSITION_LOWER = [-2.094, 0.0, -3.142, -1.484, -1.484, -2.007] +_POSITION_UPPER = [2.094, 3.142, 0.0, 1.484, 1.484, 2.007] +# Per-joint velocity caps from ArmRobot defaults (~70% of motor hardware max) +_VELOCITY_MAX = [12.0, 12.0, 12.0, 7.0, 20.0, 20.0] + +# Max average speed for planned moves. move_joints uses minimum-jerk +# interpolation with peak velocity 1.875x average and rejects speeds whose +# peak exceeds the SDK's 4.0 rad/s streaming cap, so keep 1.875 * max <= 4.0. +_PLANNED_SPEED_MAX_RAD_S = 2.0 + +# Motor error codes 0x0 (disabled) and 0x1 (normal) are healthy; anything +# else is a fault (matches ArmRobot._check_motor_errors). +_HEALTHY_MOTOR_CODES = (0, 1) + +# G1Z gripper max opening used to convert the SDK's normalized position +# (0.0=closed, 1.0=open) to meters. Measured on hardware: 10 cm jaw gap at +# full open. Override via the gripper_max_opening_m constructor kwarg. +_GRIPPER_MAX_OPENING_M = 0.1 + + +@contextlib.contextmanager +def _gs_usb_can_bus() -> Iterator[None]: + """Route the SDK's CAN bus construction through GsUsbMacBus. + + get_a1z_robot() hardcodes bustype="socketcan" (Linux-only) when opening + the bus, so on macOS we swap can.interface.Bus for the userspace gs_usb + transport for the duration of the factory call. Everything else in the + vendor stack is transport-agnostic. + """ + import can + + from dimos.hardware.manipulators.galaxea_a1z.gs_usb_bus import GsUsbMacBus + + original_bus = can.interface.Bus + + def _bus_factory(*args: Any, **kwargs: Any) -> GsUsbMacBus: + return GsUsbMacBus(bitrate=kwargs.get("bitrate", 1_000_000)) + + can.interface.Bus = _bus_factory # type: ignore[assignment] + try: + yield + finally: + can.interface.Bus = original_bus # type: ignore[assignment] + + +class GalaxeaA1ZAdapter: + """Galaxea A1Z 6-DOF arm adapter. + + Implements ManipulatorAdapter protocol via duck typing. + No inheritance required - just matching method signatures. + + Supported control modes: + - POSITION: minimum-jerk planned move (SDK move_joints, runs in a + background thread so the call does not block) + - SERVO_POSITION: high-frequency joint position streaming + (SDK command_joint_pos) + """ + + def __init__( + self, + address: str = "can0", + dof: int = 6, + *, + gravity_comp_factor: float = 1.0, + zero_gravity: bool = False, + control_freq_hz: int = 250, + urdf_path: str | None = None, + gripper: bool = False, + gripper_max_torque: float = 2.0, + gripper_max_opening_m: float = _GRIPPER_MAX_OPENING_M, + transport: str = "auto", + safe_start: bool = True, + **_: object, + ) -> None: + if transport not in ("auto", "socketcan", "gs_usb"): + raise ValueError(f"Unknown transport {transport!r}") + if dof != 6: + raise ValueError(f"GalaxeaA1ZAdapter only supports 6 DOF (got {dof})") + self._can_channel = address + self._dof = dof + self._gravity_comp_factor = gravity_comp_factor + self._zero_gravity = zero_gravity + self._control_freq_hz = control_freq_hz + self._urdf_path = urdf_path + self._gripper = gripper + self._gripper_max_torque = gripper_max_torque + self._gripper_max_opening_m = gripper_max_opening_m + if transport == "auto": + transport = "gs_usb" if platform.system() == "Darwin" else "socketcan" + self._transport = transport + # Dimensional addition on top of the vendor SDK (not Galaxea behavior): + # verified zero-force startup that prevents the enable-snap described + # in _safe_start(). Set safe_start=False for vendor-stock start(). + self._safe_start_enabled = safe_start + self._robot: Any = None + self._connected: bool = False + self._control_mode: ControlMode = ControlMode.POSITION + self._move_thread: threading.Thread | None = None + self._move_lock = threading.Lock() + self._kinematics: Any = None + + def connect(self) -> bool: + """Open the CAN bus and construct the robot. Motors stay unpowered.""" + try: + from a1z.robots.get_robot import get_a1z_robot + except ImportError: + print( + "ERROR: a1z SDK not installed. Install from github.com/userguide-galaxea/GALAXEA-A1Z" + ) + return False + + kwargs: dict[str, Any] = { + "can_channel": self._can_channel, + "gravity_comp_factor": self._gravity_comp_factor, + "zero_gravity_mode": self._zero_gravity, + "control_freq_hz": self._control_freq_hz, + "urdf_path": self._urdf_path, + } + if self._gripper: + # Only on the SDK's 'gripper' branch; main raises TypeError. + kwargs["with_gripper"] = True + kwargs["gripper_max_torque"] = self._gripper_max_torque + + try: + if self._transport == "gs_usb": + with _gs_usb_can_bus(): + self._robot = get_a1z_robot(**kwargs) + else: + self._robot = get_a1z_robot(**kwargs) + self._connected = True + print(f"Galaxea A1Z connected via {self._transport} (channel {self._can_channel})") + return True + except TypeError as e: + print( + "ERROR: installed a1z SDK does not support the gripper - " + f"install the SDK's 'gripper' branch: {e}" + ) + self._robot = None + return False + except Exception as e: + print(f"ERROR: Failed to connect to Galaxea A1Z on {self._can_channel}: {e}") + self._robot = None + return False + + def disconnect(self) -> None: + """Stop the control loop, disable motors, and close the CAN bus. + + SAFETY: the arm has no brakes and will fall when motors disable. + """ + if self._robot: + try: + if self._robot.is_running: + self._robot.stop() + except Exception: + pass + self._ensure_motors_disabled() + try: + # ArmRobot.stop() does not close the bus; shut it down so the + # CAN channel is reusable without recreating the process. + bus = getattr(self._robot, "_bus", None) + if bus is not None: + bus.shutdown() + except Exception: + pass + finally: + self._robot = None + self._connected = False + + def is_connected(self) -> bool: + """Check if connected (CAN bus open, robot constructed).""" + return self._connected and self._robot is not None + + def activate(self) -> bool: + """Enable motors and start the SDK control loop.""" + return self.write_enable(True) + + def deactivate(self) -> bool: + """Stop the control loop and disable motors. + + SAFETY: the arm has no brakes and will fall when motors disable. + Lower the arm (e.g. move_joints to a rest pose) before calling. + """ + return self.write_enable(False) + + def get_info(self) -> ManipulatorInfo: + """Get manipulator info.""" + return ManipulatorInfo(vendor="Galaxea", model="A1Z", dof=self._dof) + + def get_dof(self) -> int: + """Get degrees of freedom.""" + return self._dof + + def get_limits(self) -> JointLimits: + """Get joint limits.""" + return JointLimits( + position_lower=list(_POSITION_LOWER), + position_upper=list(_POSITION_UPPER), + velocity_max=list(_VELOCITY_MAX), + ) + + def set_control_mode(self, mode: ControlMode) -> bool: + """Set control mode. Only POSITION and SERVO_POSITION are supported. + + Both map onto the same underlying MIT position+PD loop, so switching + needs no SDK call. + """ + if mode not in (ControlMode.POSITION, ControlMode.SERVO_POSITION): + return False + self._control_mode = mode + return True + + def get_control_mode(self) -> ControlMode: + """Get current control mode.""" + return self._control_mode + + def read_joint_positions(self) -> list[float]: + """Read current joint positions (radians).""" + return self._joint_state()["pos"].tolist() + + def read_joint_velocities(self) -> list[float]: + """Read current joint velocities (rad/s).""" + return self._joint_state()["vel"].tolist() + + def read_joint_efforts(self) -> list[float]: + """Read current joint efforts (Nm).""" + return self._joint_state()["eff"].tolist() + + def read_state(self) -> dict[str, int]: + """Read robot state (0=idle, 1=running, 2=error/estopped).""" + if not self._robot: + return {"state": 0, "mode": 0, "error_code": 0} + + error_code, _ = self.read_error() + if error_code != 0 or self._robot.is_estopped: + state = 2 + elif self._robot.is_running: + state = 1 + else: + state = 0 + + joint_state = self._joint_state() + return { + "state": state, + "mode": 0, + "error_code": error_code, + "temp_mos_max": int(max(joint_state["temp_mos"].tolist())), + "temp_rotor_max": int(max(joint_state["temp_rotor"].tolist())), + } + + def read_error(self) -> tuple[int, str]: + """Read error code and message. (0, '') means no error.""" + if not self._robot: + return 0, "" + + codes = self._joint_state()["error_codes"].tolist() + for i, code in enumerate(codes): + if int(code) not in _HEALTHY_MOTOR_CODES: + return int(code), f"Motor fault on joint {i + 1}: code 0x{int(code):x}" + if self._robot.is_estopped: + return 1, "Soft e-stop latched (write_clear_errors to release)" + return 0, "" + + def write_joint_positions( + self, + positions: list[float], + velocity: float = 1.0, + ) -> bool: + """Command joint positions (radians). + + POSITION mode: minimum-jerk planned move in a background thread; + returns False if a planned move is already in progress. + SERVO_POSITION mode: single streamed position target. + + Args: + positions: Target positions in radians + velocity: Speed as fraction of max planned speed (0-1) + """ + if not self._robot or not self._robot.is_running or self._robot.is_estopped: + return False + + target = np.asarray(positions, dtype=float) + + if self._control_mode == ControlMode.SERVO_POSITION: + self._robot.command_joint_pos(target) + return True + + # POSITION mode: reject overlapping planned moves + if not self._move_lock.acquire(blocking=False): + return False + + speed = max(0.05, min(1.0, velocity)) * _PLANNED_SPEED_MAX_RAD_S + + def _move() -> None: + try: + self._robot.move_joints(target, speed=speed) + except Exception as e: + print(f"Galaxea A1Z planned move failed: {e}") + finally: + self._move_lock.release() + + self._move_thread = threading.Thread(target=_move, name="a1z_planned_move", daemon=True) + self._move_thread.start() + return True + + def write_joint_velocities(self, velocities: list[float]) -> bool: + """Not supported - the a1z SDK has no velocity command API.""" + return False + + def write_stop(self) -> bool: + """Latching soft e-stop: pins current position, rejects commands. + + Release with write_clear_errors() or write_enable(True). + """ + if not self._robot: + return False + try: + self._robot.estop() + return True + except Exception: + return False + + def write_enable(self, enable: bool) -> bool: + """Enable (start control loop) or disable (stop loop, motors off). + + SAFETY: disabling powers off motors and the arm falls freely. + """ + if not self._robot: + return False + + try: + if enable: + if self._robot.is_running: + if self._robot.is_estopped: + self._robot.release() + elif self._safe_start_enabled: + self._safe_start() + else: + # Vendor-stock startup; can snap to zero if a motor's + # first feedback is late (see _safe_start docstring). + self._robot.start() + return True + else: + if self._robot.is_running: + self._robot.stop() + self._ensure_motors_disabled() + return True + except Exception as e: + print(f"Galaxea A1Z enable={enable} failed: {e}") + return False + + def read_enabled(self) -> bool: + """Check if the control loop is running and not e-stopped.""" + return bool(self._robot and self._robot.is_running and not self._robot.is_estopped) + + def write_clear_errors(self) -> bool: + """Release the soft e-stop latch. + + Motor-level faults cannot be cleared here; use the SDK's + tools/motor_diag.py --clear-error with the arm in a safe pose. + """ + if not self._robot: + return False + try: + if self._robot.is_estopped: + self._robot.release() + return True + except Exception: + return False + + def read_cartesian_position(self) -> dict[str, float] | None: + """Read end-effector pose via forward kinematics on the bundled URDF. + + Returns: + Dict with keys: x, y, z (meters), roll, pitch, yaw (radians) + None if not connected or pinocchio is unavailable + """ + if not self._robot: + return None + + kin = self._get_kinematics() + if kin is None: + return None + + try: + q = np.asarray(self.read_joint_positions()) + T = kin.fk(q) # 4x4 homogeneous transform + R = T[:3, :3] + return { + "x": float(T[0, 3]), + "y": float(T[1, 3]), + "z": float(T[2, 3]), + "roll": float(np.arctan2(R[2, 1], R[2, 2])), + "pitch": float(np.arctan2(-R[2, 0], np.hypot(R[2, 1], R[2, 2]))), + "yaw": float(np.arctan2(R[1, 0], R[0, 0])), + } + except Exception: + return None + + def write_cartesian_position( + self, + pose: dict[str, float], + velocity: float = 1.0, + ) -> bool: + """Not supported - cartesian targets go through the planning stack.""" + return False + + def read_gripper_position(self) -> float | None: + """Read gripper opening (meters). None if no gripper attached. + + Converts the SDK's normalized position (0.0=closed, 1.0=open). + Requires the adapter constructed with gripper=True and the SDK's + 'gripper' branch. + """ + if not self._robot or not self._gripper: + return None + try: + fraction = self._robot.get_gripper_pos() + except Exception: + return None + if fraction is None: + return None + return float(fraction) * self._gripper_max_opening_m + + def write_gripper_position(self, position: float) -> bool: + """Command gripper opening (meters). False if no gripper attached.""" + if not self._robot or not self._gripper or not self._robot.is_running: + return False + fraction = max(0.0, min(1.0, position / self._gripper_max_opening_m)) + try: + self._robot.command_gripper(fraction) + return True + except Exception as e: + print(f"Galaxea A1Z gripper command failed: {e}") + return False + + def read_force_torque(self) -> list[float] | None: + """Not supported - no F/T sensor (per-joint efforts via read_joint_efforts).""" + return None + + # --- A1Z-specific extensions (beyond ManipulatorAdapter protocol) --- + + def start_teach_recording(self, sample_hz: int = 50) -> None: + """Start recording joint positions for teach-and-play. + + Requires the adapter constructed with zero_gravity=True so the arm + can be hand-guided. + """ + self._require_robot().start_recording(sample_hz=sample_hz) + + def stop_teach_recording(self) -> list[tuple[float, np.ndarray]]: + """Stop recording and return the trajectory as (timestamp_s, pos_rad) tuples.""" + return self._require_robot().stop_recording() + + def play_trajectory(self, trajectory: list, speed_factor: float = 1.0) -> None: + """Replay a recorded trajectory (blocking).""" + self._require_robot().play_trajectory(trajectory, speed_factor=speed_factor) + + def save_recording(self, trajectory: list, path: str) -> None: + """Save a recorded trajectory to a JSON file.""" + self._require_robot().save_recording(trajectory, path) + + def load_recording(self, path: str) -> list: + """Load a recorded trajectory from a JSON file.""" + return self._require_robot().load_recording(path) + + def set_gripper_free_drive(self, enabled: bool) -> bool: + """Toggle gripper free-drive (zero-torque) mode for hand teaching. + + Requires gripper=True and the SDK's 'gripper' branch. + """ + robot = self._require_robot() + if not self._gripper or not hasattr(robot, "set_gripper_free_drive"): + return False + robot.set_gripper_free_drive(enabled) + return True + + # --- internals --- + + def _ensure_motors_disabled(self) -> None: + """Re-send disable frames to every motor, gripper included. + + The SDK's shutdown sends the gripper's disable frame exactly once; + on a busy or degraded bus that frame can be lost, leaving the gripper + energized and unsupervised (observed twice on hardware). The SDK + double-sends arm-motor disables for this very reason but not the + gripper's, so we re-send all of them here. + """ + robot = self._robot + if robot is None: + return + motors: list[Any] = [] + chain = getattr(robot, "_motor_chain", None) + if chain is not None: + motors += list(getattr(chain, "_motor_a_list", [])) + motors += list(getattr(chain, "_motor_b_list", [])) + gripper = getattr(robot, "gripper", None) + if gripper is not None: + motors.append(gripper._motor) + for _ in range(2): + for motor in motors: + try: + motor.disable() + except Exception: + pass + + def _safe_start(self) -> None: + """Start the SDK control loop without ever commanding force toward + an unverified position. + + The SDK's start() reads feedback once after a fixed 50 ms wait and + position-holds whatever it read; if a motor's first report is late + (typical on USB transports), the hold target defaults to zero and + the arm snaps to neutral at full gain. Observed on hardware. + + Sequence here: start with kp=0 (gravity comp only - a position snap + is physically impossible), wait until every motor has actually + reported, verify the pose is inside limits and near-stationary, then + engage the hold gains at the measured pose (zero error, zero jerk). + Raises RuntimeError (and disables) instead of moving if verification + fails. + """ + robot = self._robot + dof = self._dof + default_kp = np.asarray( + getattr(robot, "_default_kp", np.array([30.0, 30.0, 30.0, 20.0, 5.0, 5.0])), + dtype=float, + ) + default_kd = np.asarray( + getattr(robot, "_default_kd", np.array([1.0, 1.0, 1.0, 0.5, 0.5, 0.5])), + dtype=float, + ) + + robot.start(initial_kp=np.zeros(dof), initial_kd=default_kd * 0.5) + + deadline = time.monotonic() + 2.0 + while time.monotonic() < deadline and not self._all_motors_reported(): + time.sleep(0.02) + if not self._all_motors_reported(): + robot.stop() + raise RuntimeError("no feedback from all motors within 2 s; motors disabled") + + state = robot.get_joint_state() + pos = np.asarray(state["pos"], dtype=float) + vel = np.asarray(state["vel"], dtype=float) + lower = np.asarray(_POSITION_LOWER) - 0.15 + upper = np.asarray(_POSITION_UPPER) + 0.15 + if np.any(pos < lower) or np.any(pos > upper): + robot.stop() + raise RuntimeError( + f"start pose {np.round(pos, 3).tolist()} outside joint limits - " + "move the arm inside its range by hand, then retry; motors disabled" + ) + if np.any(np.abs(vel) > 0.5): + robot.stop() + raise RuntimeError("arm is moving during startup; motors disabled") + + if not self._zero_gravity: + # Engage hold gains at the *measured* pose: error ~0, no jerk. + robot.command_joint_state( + { + "pos": pos.copy(), + "vel": np.zeros(dof), + "kp": default_kp, + "kd": default_kd, + } + ) + + def _all_motors_reported(self) -> bool: + """True once every motor in the SDK chain has sent real feedback.""" + chain = getattr(self._robot, "_motor_chain", None) + if chain is None: + return True # can't verify on this SDK version; rely on limit checks + motors = list(getattr(chain, "_motor_a_list", [])) + list( + getattr(chain, "_motor_b_list", []) + ) + if not motors: + return True + return all(m.last_feedback is not None for m in motors) + + def _require_robot(self) -> Any: + if not self._robot: + raise RuntimeError("Not connected") + return self._robot + + def _joint_state(self) -> dict[str, np.ndarray]: + return self._require_robot().get_joint_state() + + def _get_kinematics(self) -> Any: + """Lazily build and cache the FK solver from the SDK's bundled URDF.""" + if self._kinematics is not None: + return self._kinematics + try: + import a1z + from a1z.robots.kinematics import Kinematics + + urdf = self._urdf_path or str( + Path(a1z.__file__).parent / "robot_models" / "a1z" / "A1Z_Flange.urdf" + ) + self._kinematics = Kinematics(urdf) + except Exception: + return None + return self._kinematics diff --git a/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py new file mode 100644 index 0000000000..ecc96438d1 --- /dev/null +++ b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py @@ -0,0 +1,465 @@ +# 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 collections.abc import Iterator +import importlib +import sys +from types import ModuleType +from typing import Any, ClassVar + +import numpy as np +import pytest + + +class _FakeBus: + def __init__(self) -> None: + self.shut_down = False + + def shutdown(self) -> None: + self.shut_down = True + + +class _FakeMotor: + def __init__(self) -> None: + self.last_feedback = object() # motor has reported + + +class _FakeMotorChain: + def __init__(self) -> None: + self._motor_a_list = [_FakeMotor() for _ in range(3)] + self._motor_b_list = [_FakeMotor() for _ in range(3)] + + +class _FakeArmRobot: + """Mirrors the a1z ArmRobot surface the adapter relies on. + + Matches the real SDK: is_running/is_estopped are properties, start() + enables motors and clears the e-stop latch, stop() disables motors. + """ + + instances: ClassVar[list[_FakeArmRobot]] = [] + + def __init__(self, **factory_kwargs: Any) -> None: + self.__class__.instances.append(self) + self.factory_kwargs = factory_kwargs + self._running = False + self._estopped = False + self._bus = _FakeBus() + self._default_kp = np.array([30.0, 30.0, 30.0, 20.0, 5.0, 5.0]) + self._default_kd = np.array([1.0, 1.0, 1.0, 0.5, 0.5, 0.5]) + self._motor_chain = _FakeMotorChain() + self.actions: list[Any] = [] + self.state = { + "pos": np.zeros(6), + "vel": np.zeros(6), + "eff": np.zeros(6), + "error_codes": np.ones(6, dtype=int), # 0x1 = normal + "temp_mos": np.full(6, 35.0), + "temp_rotor": np.full(6, 40.0), + } + + @property + def is_running(self) -> bool: + return self._running + + @property + def is_estopped(self) -> bool: + return self._estopped + + def start(self, initial_kp: Any = None, initial_kd: Any = None) -> None: + self._running = True + self._estopped = False + self.actions.append(("start", initial_kp, initial_kd)) + self.actions.append("start") + + def command_joint_state(self, joint_state: dict[str, np.ndarray]) -> None: + self.actions.append(("command_joint_state", joint_state)) + + def stop(self) -> None: + self._running = False + self.actions.append("stop") + + def estop(self) -> None: + self._estopped = True + self.actions.append("estop") + + def release(self) -> None: + self._estopped = False + self.actions.append("release") + + def get_joint_state(self) -> dict[str, np.ndarray]: + return dict(self.state) + + def command_joint_pos(self, pos: np.ndarray) -> None: + self.actions.append(("command_joint_pos", pos.tolist())) + + def move_joints(self, target_pos: np.ndarray, speed: float = 0.5) -> None: + self.actions.append(("move_joints", target_pos.tolist(), speed)) + + def command_gripper(self, value: float) -> None: + if not self.factory_kwargs.get("with_gripper"): + raise RuntimeError("No gripper attached. Pass gripper= to get_a1z_robot().") + self.gripper_fraction = value + self.actions.append(("command_gripper", value)) + + def get_gripper_pos(self) -> float | None: + if not self.factory_kwargs.get("with_gripper"): + return None + return getattr(self, "gripper_fraction", 0.0) + + +@pytest.fixture +def a1z_adapter_module(monkeypatch: pytest.MonkeyPatch) -> Iterator[ModuleType]: + _FakeArmRobot.instances.clear() + + a1z_pkg = ModuleType("a1z") + a1z_robots = ModuleType("a1z.robots") + a1z_get_robot = ModuleType("a1z.robots.get_robot") + a1z_get_robot.get_a1z_robot = _FakeArmRobot # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "a1z", a1z_pkg) + monkeypatch.setitem(sys.modules, "a1z.robots", a1z_robots) + monkeypatch.setitem(sys.modules, "a1z.robots.get_robot", a1z_get_robot) + sys.modules.pop("dimos.hardware.manipulators.galaxea_a1z.adapter", None) + yield importlib.import_module("dimos.hardware.manipulators.galaxea_a1z.adapter") + sys.modules.pop("dimos.hardware.manipulators.galaxea_a1z.adapter", None) + + +def _connected_adapter(module: ModuleType, **kwargs: Any) -> Any: + adapter = module.GalaxeaA1ZAdapter(address="can0", **kwargs) + assert adapter.connect() + return adapter + + +def test_connect_constructs_robot_without_powering_motors( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module, gravity_comp_factor=0.7) + robot = _FakeArmRobot.instances[-1] + + assert adapter.is_connected() + assert robot.factory_kwargs["can_channel"] == "can0" + assert robot.factory_kwargs["gravity_comp_factor"] == 0.7 + assert "start" not in robot.actions + assert not adapter.read_enabled() + + +def test_activate_starts_control_loop_and_enables( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + + assert adapter.activate() + + assert "start" in robot.actions + assert adapter.read_enabled() + + +def test_safe_start_never_commands_force_toward_unverified_pose( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + robot.state["pos"] = np.array([0.1, 0.5, -0.5, 0.2, -0.1, 0.3]) + + assert adapter.activate() + + start_call = next(a for a in robot.actions if isinstance(a, tuple) and a[0] == "start") + assert np.allclose(start_call[1], np.zeros(6)) # kp=0: snap impossible + + engage = next( + a for a in robot.actions if isinstance(a, tuple) and a[0] == "command_joint_state" + ) + js = engage[1] + assert np.allclose(js["pos"], robot.state["pos"]) # hold target = measured pose + assert np.allclose(js["kp"], robot._default_kp) + + +def test_safe_start_false_uses_vendor_stock_startup( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module, safe_start=False) + robot = _FakeArmRobot.instances[-1] + + assert adapter.activate() + + start_call = next(a for a in robot.actions if isinstance(a, tuple) and a[0] == "start") + assert start_call[1] is None # vendor defaults, no gain override + assert not any(isinstance(a, tuple) and a[0] == "command_joint_state" for a in robot.actions) + + +def test_safe_start_refuses_out_of_limit_pose( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + robot.state["pos"] = np.array([0.0, 0.0, 0.0, -1.7, 0.0, 0.0]) # joint4 beyond limit + + assert not adapter.activate() + + assert "stop" in robot.actions # motors disabled, not yanked into range + assert not any(isinstance(a, tuple) and a[0] == "command_joint_state" for a in robot.actions) + + +def test_write_enable_false_stops_control_loop( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + assert adapter.activate() + + assert adapter.write_enable(False) + + assert "stop" in robot.actions + assert not adapter.read_enabled() + + +def test_disconnect_stops_robot_and_closes_bus( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + assert adapter.activate() + + adapter.disconnect() + + assert "stop" in robot.actions + assert robot._bus.shut_down + assert not adapter.is_connected() + + +def test_joint_state_reads_pass_through_si_units( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + + robot.state["pos"] = np.array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + robot.state["vel"] = np.array([1.0, 1.1, 1.2, 1.3, 1.4, 1.5]) + robot.state["eff"] = np.array([0.01, 0.02, 0.03, 0.04, 0.05, 0.06]) + + assert adapter.read_joint_positions() == pytest.approx([0.1, 0.2, 0.3, 0.4, 0.5, 0.6]) + assert adapter.read_joint_velocities() == pytest.approx([1.0, 1.1, 1.2, 1.3, 1.4, 1.5]) + assert adapter.read_joint_efforts() == pytest.approx([0.01, 0.02, 0.03, 0.04, 0.05, 0.06]) + + +def test_write_joint_positions_requires_activation( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + + assert not adapter.write_joint_positions([0.0] * 6) + + +def test_servo_position_mode_streams_command_joint_pos( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + assert adapter.activate() + assert adapter.set_control_mode(a1z_adapter_module.ControlMode.SERVO_POSITION) + + positions = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] + assert adapter.write_joint_positions(positions) + + assert ("command_joint_pos", pytest.approx(positions)) in [ + (a[0], a[1]) for a in robot.actions if isinstance(a, tuple) + ] + + +def test_position_mode_runs_planned_move_in_background( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + assert adapter.activate() + + positions = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] + assert adapter.write_joint_positions(positions, velocity=0.5) + adapter._move_thread.join(timeout=1.0) + + moves = [a for a in robot.actions if isinstance(a, tuple) and a[0] == "move_joints"] + assert len(moves) == 1 + assert moves[0][1] == pytest.approx(positions) + assert moves[0][2] == pytest.approx(0.5 * a1z_adapter_module._PLANNED_SPEED_MAX_RAD_S) + + +def test_estop_latches_and_release_restores_commands( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + assert adapter.activate() + + assert adapter.write_stop() + assert "estop" in robot.actions + assert not adapter.read_enabled() + assert not adapter.write_joint_positions([0.0] * 6) + code, message = adapter.read_error() + assert code != 0 + assert "e-stop" in message + + assert adapter.write_clear_errors() + assert "release" in robot.actions + assert adapter.write_joint_positions([0.0] * 6) + + +def test_read_state_reports_ints_and_motor_faults( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + assert adapter.activate() + + state = adapter.read_state() + assert state["state"] == 1 + assert state["error_code"] == 0 + assert isinstance(state["temp_mos_max"], int) + + robot.state["error_codes"] = np.array([1, 1, 8, 1, 1, 1]) + code, message = adapter.read_error() + assert code == 8 + assert "joint 3" in message + assert adapter.read_state()["state"] == 2 + + +def test_unsupported_interfaces_signal_cleanly( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + assert adapter.activate() + + assert not adapter.write_joint_velocities([0.0] * 6) + assert not adapter.write_cartesian_position( + {"x": 0.3, "y": 0.0, "z": 0.4, "roll": 0.0, "pitch": 0.0, "yaw": 0.0} + ) + assert adapter.read_gripper_position() is None + assert not adapter.write_gripper_position(0.05) + assert adapter.read_force_torque() is None + + +def test_gripper_round_trips_meters_to_normalized( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module, gripper=True, gripper_max_opening_m=0.1) + robot = _FakeArmRobot.instances[-1] + assert robot.factory_kwargs["with_gripper"] is True + assert adapter.activate() + + assert adapter.write_gripper_position(0.05) # half open + assert robot.gripper_fraction == pytest.approx(0.5) + assert adapter.read_gripper_position() == pytest.approx(0.05) + + # Out-of-range commands clamp to the physical stroke + assert adapter.write_gripper_position(1.0) + assert robot.gripper_fraction == pytest.approx(1.0) + + +def test_gripper_disabled_signals_unsupported( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) # gripper=False default + assert adapter.activate() + + assert adapter.read_gripper_position() is None + assert not adapter.write_gripper_position(0.05) + + +def test_set_control_mode_rejects_unsupported_modes( + a1z_adapter_module: ModuleType, +) -> None: + adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0") + + assert not adapter.set_control_mode(a1z_adapter_module.ControlMode.VELOCITY) + assert not adapter.set_control_mode(a1z_adapter_module.ControlMode.TORQUE) + assert adapter.set_control_mode(a1z_adapter_module.ControlMode.POSITION) + assert adapter.set_control_mode(a1z_adapter_module.ControlMode.SERVO_POSITION) + + +def test_get_limits_match_sdk_joint_limits( + a1z_adapter_module: ModuleType, +) -> None: + limits = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0").get_limits() + + assert limits.position_lower == pytest.approx([-2.094, 0.0, -3.142, -1.484, -1.484, -2.007]) + assert limits.position_upper == pytest.approx([2.094, 3.142, 0.0, 1.484, 1.484, 2.007]) + assert len(limits.velocity_max) == 6 + + +def test_connect_fails_gracefully_without_sdk( + monkeypatch: pytest.MonkeyPatch, +) -> None: + for name in list(sys.modules): + if name == "a1z" or name.startswith("a1z."): + monkeypatch.delitem(sys.modules, name) + monkeypatch.setitem(sys.modules, "a1z", None) # force ImportError + sys.modules.pop("dimos.hardware.manipulators.galaxea_a1z.adapter", None) + module = importlib.import_module("dimos.hardware.manipulators.galaxea_a1z.adapter") + + adapter = module.GalaxeaA1ZAdapter(address="can0") + assert not adapter.connect() + assert not adapter.is_connected() + + +def test_gs_usb_transport_swaps_bus_during_factory_call( + a1z_adapter_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import can + + class _FakeGsBus: + def __init__(self, **kwargs: Any) -> None: + self.kwargs = kwargs + + import dimos.hardware.manipulators.galaxea_a1z.gs_usb_bus as gs_usb_bus + + monkeypatch.setattr(gs_usb_bus, "GsUsbMacBus", _FakeGsBus) + + seen: dict[str, Any] = {} + + def _factory_calling_can_bus(**kwargs: Any) -> _FakeArmRobot: + seen["bus"] = can.interface.Bus(channel="can0", bustype="socketcan", bitrate=1_000_000) + return _FakeArmRobot(**kwargs) + + fake_get_robot = sys.modules["a1z.robots.get_robot"] + monkeypatch.setattr(fake_get_robot, "get_a1z_robot", _factory_calling_can_bus) + + original_bus = can.interface.Bus + adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0", transport="gs_usb") + assert adapter.connect() + + assert isinstance(seen["bus"], _FakeGsBus) + assert can.interface.Bus is original_bus # patch is scoped to the call + + +def test_socketcan_transport_leaves_can_bus_untouched( + a1z_adapter_module: ModuleType, +) -> None: + import can + + original_bus = can.interface.Bus + adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0", transport="socketcan") + assert adapter.connect() + assert can.interface.Bus is original_bus + + +def test_registry_entry_resolves() -> None: + from dimos.hardware.manipulators.galaxea_a1z._registry import ADAPTER_FACTORIES + + module_path, _, class_name = ADAPTER_FACTORIES["galaxea_a1z"].partition(":") + module = importlib.import_module(module_path) + assert hasattr(module, class_name) From e5101fac065ff7653c984c620130cbbc9fdf21e5 Mon Sep 17 00:00:00 2001 From: Kentaro Wu Date: Wed, 15 Jul 2026 00:13:27 +0800 Subject: [PATCH 02/51] feat(galaxea_a1z): native macOS support via userspace gs_usb CAN transport GsUsbMacBus is a python-can BusABC driving the bundled HHS USB-CANFD adapter over libusb - no SocketCAN, no Linux host. The adapter selects it automatically on macOS (transport="auto"); Linux keeps socketcan. Handles this device's quirks: TX endpoint discovered from descriptors (device uses 0x01, gs_usb lib assumes 0x02), no-op kernel-driver detach, TX echo filtering, RX queue flush on open (the device retains stale frames across sessions, which parse as garbage feedback), and up-to-5s discovery retry (the device drops off the USB bus briefly after a close). Validated on an M4 Pro against a live A1Z: 30 s sustained 250 Hz control loop (p99 cycle 5.2 ms, 0/7500 over the SDK's 12.5 ms watchdog limit), ~100% feedback from all 7 motors. Requires pyusb + gs_usb + libusb. --- .../manipulators/galaxea_a1z/gs_usb_bus.py | 170 ++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py diff --git a/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py b/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py new file mode 100644 index 0000000000..b51686f982 --- /dev/null +++ b/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py @@ -0,0 +1,170 @@ +# 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. + +"""python-can Bus for gs_usb CAN adapters via libusb - works on macOS. + +SocketCAN is Linux-only; this bus drives gs_usb-protocol adapters (candlelight +class, including the HHS "CANFD Analyser" Galaxea ships) entirely from +userspace over libusb, so the A1Z runs natively on macOS. Validated on an +M4 Pro at a sustained 250 Hz control loop (p99 cycle 5.2 ms, 0/7500 cycles +over the SDK's 12.5 ms limit, ~100% feedback from all 7 motors). + +Device quirks handled here: +- TX endpoint is discovered from the descriptors (this adapter uses 0x01; + the gs_usb library assumes 0x02) +- macOS has no kernel driver to detach; the detach call is skipped +- TX echo frames are filtered out of recv() + +Requires: pip install pyusb gs_usb (plus libusb, e.g. brew install libusb). +Lives in galaxea_a1z/ because it is the only user today; promote to a shared +location when a second CAN arm needs it. +""" + +from __future__ import annotations + +import time +from typing import Any + +import can + +# HHS USB-CANFD adapter bundled with the Galaxea A1Z +GALAXEA_VENDOR_ID = 0xA8FA +GALAXEA_PRODUCT_ID = 0x8598 + +_GS_USB_NONE_ECHO_ID = 0xFFFFFFFF +_GS_CAN_MODE_LISTEN_ONLY = 1 << 0 + + +class GsUsbMacBus(can.BusABC): + """CAN bus over a gs_usb adapter through libusb (macOS-friendly).""" + + def __init__( + self, + channel: str = "gs_usb", + *, + vendor_id: int = GALAXEA_VENDOR_ID, + product_id: int = GALAXEA_PRODUCT_ID, + bitrate: int = 1_000_000, + listen_only: bool = False, + discover_timeout: float = 5.0, + **_: Any, + ) -> None: + from gs_usb.gs_usb import GS_CAN_MODE_HW_TIMESTAMP, GsUsb + import usb.core + + # The adapter drops off the USB bus for a few seconds after a + # close (firmware reset on reopen, observed on hardware) - retry + # discovery instead of failing the first reconnect. + deadline = time.perf_counter() + discover_timeout + device = usb.core.find(idVendor=vendor_id, idProduct=product_id) + while device is None and time.perf_counter() < deadline: + time.sleep(0.25) + device = usb.core.find(idVendor=vendor_id, idProduct=product_id) + if device is None: + raise can.CanInitializationError( + f"gs_usb adapter {vendor_id:04x}:{product_id:04x} not found on USB " + f"(waited {discover_timeout:.0f}s)" + ) + # No kernel driver claims the interface on macOS; gs_usb's detach + # call would raise, so neutralize it. + device.detach_kernel_driver = lambda intf: None # type: ignore[method-assign] + + # The gs_usb library hardcodes TX endpoint 0x02; discover the real + # bulk OUT endpoint from the active configuration instead. + cfg = device.get_active_configuration() + intf = cfg[(0, 0)] + out_eps = [ep for ep in intf if not (ep.bEndpointAddress & 0x80)] + if not out_eps: + raise can.CanInitializationError("gs_usb adapter has no OUT endpoint") + self._out_endpoint = out_eps[0].bEndpointAddress + + self._gs = GsUsb(device) + if not self._gs.set_bitrate(bitrate): + raise can.CanInitializationError(f"failed to set bitrate {bitrate}") + self._hw_timestamp_flag = GS_CAN_MODE_HW_TIMESTAMP + self._gs.start(_GS_CAN_MODE_LISTEN_ONLY if listen_only else 0) + self._flush_rx() + + self.channel_info = f"gs_usb {vendor_id:04x}:{product_id:04x} @ {bitrate}" + super().__init__(channel=channel) + + def _flush_rx(self, max_frames: int = 1024) -> int: + """Discard frames queued in the device from a previous session. + + The device keeps its RX queue across open/close; stale frames (e.g. + disable-command acks) parse as motor feedback with garbage velocity + values and trip startup safety checks. Observed on hardware. + """ + from gs_usb.gs_usb_frame import GsUsbFrame + + frame = GsUsbFrame() + flushed = 0 + while flushed < max_frames and self._gs.read(frame, 5): + flushed += 1 + if flushed: + print(f"GsUsbMacBus: flushed {flushed} stale frames from device queue") + return flushed + + @property + def state(self) -> can.BusState: + return can.BusState.ACTIVE + + def send(self, msg: can.Message, timeout: float | None = None) -> None: + from gs_usb.gs_usb_frame import GsUsbFrame + + frame = GsUsbFrame(can_id=msg.arbitration_id, data=bytes(msg.data)) + hw_ts = bool(self._gs.device_flags & self._hw_timestamp_flag) + self._gs.gs_usb.write(self._out_endpoint, frame.pack(hw_ts)) + + def _recv_internal(self, timeout: float | None) -> tuple[can.Message | None, bool]: + from gs_usb.gs_usb_frame import GsUsbFrame + + # python-can treats timeout<=0 as a poll. gs_usb reads block for at + # least 1 ms, so a poll costs up to 1 ms when the queue is empty + # (returns immediately when a frame is pending). The SDK's feedback + # drain relies on recv(timeout=0.0) returning pending frames. + if timeout is not None and timeout <= 0: + timeout = 0.001 + deadline = None if timeout is None else time.perf_counter() + timeout + frame = GsUsbFrame() + while True: + if deadline is None: + wait_ms = 1000 + else: + remaining = deadline - time.perf_counter() + if remaining <= 0: + return None, False + wait_ms = max(1, int(remaining * 1000)) + + if not self._gs.read(frame, wait_ms): + if deadline is None: + continue + return None, False + if frame.echo_id != _GS_USB_NONE_ECHO_ID: + continue # our own TX echo, not bus traffic + + msg = can.Message( + arbitration_id=frame.can_id & 0x1FFFFFFF, + is_extended_id=bool(frame.can_id & 0x80000000), + data=bytes(frame.data[: frame.can_dlc]), + dlc=frame.can_dlc, + ) + return msg, False + + def shutdown(self) -> None: + try: + self._gs.stop() + except Exception: + pass + super().shutdown() From 66109865da70f257582b3017a10649b4986a52b5 Mon Sep 17 00:00:00 2001 From: Kentaro Wu Date: Wed, 15 Jul 2026 00:13:27 +0800 Subject: [PATCH 03/51] feat(galaxea_a1z): coordinator blueprint and robot config coordinator-galaxea-a1z runs the arm under the ControlCoordinator with a trajectory task. Hardware-certified end to end: coordinator boot, client-submitted multi-joint trajectories over LCM RPC, 0.07 rad return drift. Defaults to the stable arm-only configuration (SDK main branch). The vendor gripper branch ships a G1Z gravity model that mismatches at least some mountings - it pushes the arm during the zero-force startup window, and disabling gravity compensation as a workaround leaves the vendor's soft e-stop unable to catch the arm. Flip gripper=True after gravity/ zero-point calibration (vendor tools/set_zero.py). --- dimos/robot/all_blueprints.py | 1 + .../galaxea_a1z/blueprints/basic.py | 42 ++++++++++++ .../robot/manipulators/galaxea_a1z/config.py | 65 +++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py create mode 100644 dimos/robot/manipulators/galaxea_a1z/config.py diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 643830a719..a65fd74dc5 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -26,6 +26,7 @@ "coordinator-flowbase": "dimos.control.blueprints.mobile:coordinator_flowbase", "coordinator-flowbase-keyboard-teleop": "dimos.control.blueprints.mobile:coordinator_flowbase_keyboard_teleop", "coordinator-flowbase-nav": "dimos.control.blueprints.mobile:coordinator_flowbase_nav", + "coordinator-galaxea-a1z": "dimos.robot.manipulators.galaxea_a1z.blueprints.basic:coordinator_galaxea_a1z", "coordinator-mobile-manip-mock": "dimos.control.blueprints.mobile:coordinator_mobile_manip_mock", "coordinator-mock": "dimos.robot.manipulators.common.mock:coordinator_mock", "coordinator-mock-twist-base": "dimos.control.blueprints.mobile:coordinator_mock_twist_base", diff --git a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py new file mode 100644 index 0000000000..58651b4c22 --- /dev/null +++ b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py @@ -0,0 +1,42 @@ +# 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. + +"""Basic Galaxea A1Z coordinator blueprints.""" + +from __future__ import annotations + +from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.core.coordination.blueprints import autoconnect +from dimos.robot.manipulators.galaxea_a1z.config import galaxea_a1z_hardware + +# Arm-only stable configuration: the a1z SDK 'gripper' branch ships a G1Z +# gravity model that mismatches this unit's mounting (pushes the arm during +# zero-force startup; soft e-stop cannot catch the arm with comp disabled). +# Until the model/mount is calibrated, run the SDK *main* branch with +# gripper=False. Gripper code support remains in the adapter. +_a1z_hw = galaxea_a1z_hardware("arm", gripper=False) + +coordinator_galaxea_a1z = autoconnect( + ControlCoordinator.blueprint( + hardware=[_a1z_hw], + tasks=[ + TaskConfig( + name="traj_arm", + type="trajectory", + joint_names=_a1z_hw.joints, + priority=10, + ) + ], + ), +) diff --git a/dimos/robot/manipulators/galaxea_a1z/config.py b/dimos/robot/manipulators/galaxea_a1z/config.py new file mode 100644 index 0000000000..b041cb86f3 --- /dev/null +++ b/dimos/robot/manipulators/galaxea_a1z/config.py @@ -0,0 +1,65 @@ +# 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. + +"""Galaxea A1Z planning model configuration helpers.""" + +from __future__ import annotations + +from dimos.control.components import HardwareComponent, HardwareType, make_joints +from dimos.core.global_config import global_config + + +def make_galaxea_a1z_hardware( + hw_id: str = "arm", + *, + adapter_type: str = "mock", + address: str | None = None, + gripper: bool = True, + auto_enable: bool = True, + adapter_kwargs: dict[str, object] | None = None, +) -> HardwareComponent: + kwargs: dict[str, object] = {"gripper": gripper} if adapter_type == "galaxea_a1z" else {} + if adapter_kwargs: + kwargs.update(adapter_kwargs) + return HardwareComponent( + hardware_id=hw_id, + hardware_type=HardwareType.MANIPULATOR, + joints=make_joints(hw_id, 6), + adapter_type=adapter_type, + address=address, + auto_enable=auto_enable, + # G1Z gripper needs the a1z SDK's 'gripper' branch + gripper_joints=[f"{hw_id}/gripper"] if gripper else [], + adapter_kwargs=kwargs, + ) + + +def galaxea_a1z_hardware( + hw_id: str = "arm", + *, + gripper: bool = True, + mock_without_address: bool = False, +) -> HardwareComponent: + if global_config.simulation: + # TODO: Add sim support when A1Z MuJoCo model is available + return make_galaxea_a1z_hardware(hw_id, gripper=gripper) + address = global_config.can_port or "can0" + if mock_without_address and not global_config.can_port: + return make_galaxea_a1z_hardware(hw_id, gripper=gripper) + return make_galaxea_a1z_hardware( + hw_id, + adapter_type="galaxea_a1z", + address=address, + gripper=gripper, + ) From edeef0d042903e4fd9b65852b784374bb9783959 Mon Sep 17 00:00:00 2001 From: Kentaro Wu Date: Wed, 15 Jul 2026 18:44:08 +0800 Subject: [PATCH 04/51] feat(galaxea_a1z): planner and keyboard-teleop blueprints on real hardware Wire the galaxea_a1z hardware adapter into the a1z planning model that landed on main (dimos/robot/manipulators/a1z): a planner+coordinator blueprint for ManipulationModule-driven motion and a keyboard teleop blueprint using the eef twist task. Both run the arm-only stable configuration (gripper=False, A1Z_Flange model) consistent with coordinator-galaxea-a1z; the mock-based a1z-* blueprints are untouched. --- dimos/robot/all_blueprints.py | 2 + .../galaxea_a1z/blueprints/basic.py | 44 ++++++++++++++++++- 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 8f82ab50d7..e9da749ea3 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -63,8 +63,10 @@ "drone-agentic": "dimos.robot.drone.blueprints.agentic.drone_agentic:drone_agentic", "drone-basic": "dimos.robot.drone.blueprints.basic.drone_basic:drone_basic", "dual-xarm6-planner": "dimos.robot.manipulators.xarm.blueprints.basic:dual_xarm6_planner", + "galaxea-a1z-planner-coordinator": "dimos.robot.manipulators.galaxea_a1z.blueprints.basic:galaxea_a1z_planner_coordinator", "keyboard-teleop-a1z": "dimos.robot.manipulators.a1z.blueprints.teleop:keyboard_teleop_a1z", "keyboard-teleop-a750": "dimos.robot.manipulators.a750.blueprints.teleop:keyboard_teleop_a750", + "keyboard-teleop-galaxea-a1z": "dimos.robot.manipulators.galaxea_a1z.blueprints.basic:keyboard_teleop_galaxea_a1z", "keyboard-teleop-openarm": "dimos.robot.manipulators.openarm.blueprints.teleop:keyboard_teleop_openarm", "keyboard-teleop-openarm-mock": "dimos.robot.manipulators.openarm.blueprints.teleop:keyboard_teleop_openarm_mock", "keyboard-teleop-piper": "dimos.robot.manipulators.piper.blueprints.teleop:keyboard_teleop_piper", diff --git a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py index 58651b4c22..29ac9918f6 100644 --- a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py +++ b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py @@ -12,13 +12,26 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Basic Galaxea A1Z coordinator blueprints.""" +"""Basic Galaxea A1Z coordinator, planner, and teleop blueprints.""" from __future__ import annotations from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.core.coordination.blueprints import autoconnect +from dimos.manipulation.manipulation_module import ManipulationModule +from dimos.robot.manipulators.a1z.config import ( + A1Z_DOF, + A1Z_FK_MODEL, + make_a1z_model_config, +) +from dimos.robot.manipulators.common.blueprints import ( + coordinator, + eef_twist_task, + planner, + trajectory_task, +) from dimos.robot.manipulators.galaxea_a1z.config import galaxea_a1z_hardware +from dimos.teleop.keyboard.keyboard_teleop_module import KeyboardTeleopModule # Arm-only stable configuration: the a1z SDK 'gripper' branch ships a G1Z # gravity model that mismatches this unit's mounting (pushes the arm during @@ -40,3 +53,32 @@ ], ), ) + +# Planner (ManipulationModule) on real hardware. Arm-only model (A1Z_Flange) +# to stay consistent with the gripper=False hardware configuration above. +_planner_hw = galaxea_a1z_hardware("arm", gripper=False) + +galaxea_a1z_planner_coordinator = autoconnect( + planner(robots=[make_a1z_model_config(name="arm", has_gripper=False)]), + coordinator( + hardware=[_planner_hw], + tasks=[trajectory_task(_planner_hw)], + ), +) + +# Keyboard teleop on real hardware (eef twist task from the FK model). +_teleop_hw = galaxea_a1z_hardware("arm", gripper=False) + +keyboard_teleop_galaxea_a1z = autoconnect( + KeyboardTeleopModule.blueprint(), + ControlCoordinator.blueprint( + hardware=[_teleop_hw], + tasks=[ + eef_twist_task(_teleop_hw, model_path=A1Z_FK_MODEL, ee_joint_id=A1Z_DOF) + ], + ), + ManipulationModule.blueprint( + robots=[make_a1z_model_config(name="arm", has_gripper=False)], + visualization={"backend": "viser"}, + ), +) From 94a378e2d144f54b59e2ef96be13f41ee93e0492 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:52:13 +0000 Subject: [PATCH 05/51] [autofix.ci] apply automated fixes --- dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py index 29ac9918f6..91cf24e7e2 100644 --- a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py +++ b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py @@ -73,9 +73,7 @@ KeyboardTeleopModule.blueprint(), ControlCoordinator.blueprint( hardware=[_teleop_hw], - tasks=[ - eef_twist_task(_teleop_hw, model_path=A1Z_FK_MODEL, ee_joint_id=A1Z_DOF) - ], + tasks=[eef_twist_task(_teleop_hw, model_path=A1Z_FK_MODEL, ee_joint_id=A1Z_DOF)], ), ManipulationModule.blueprint( robots=[make_a1z_model_config(name="arm", has_gripper=False)], From 66e60fca06ceb8ba2cbed0add47e826aa498e71d Mon Sep 17 00:00:00 2001 From: Jetson Wu Date: Thu, 16 Jul 2026 14:52:08 +0800 Subject: [PATCH 06/51] feat(galaxea_a1z): fall back to userspace gs_usb when socketcan is unavailable The A1Z ships with an HHS USB-CANFD adapter that the kernel gs_usb driver cannot actually drive: the adapter's VID/PID (a8fa:8598) is not in the driver's device table, and its bulk OUT endpoint is 0x01 where kernels before 6.x hardcode 0x02, so every transmit fails even if the driver is force-bound. On such machines (found on a JetPack 6 Jetson, kernel 5.15) the arm is unreachable over socketcan no matter what, even though the adapter is plugged in and healthy. Teach transport="auto" to handle this: keep socketcan whenever the channel exists and is up, so stock kernels that bind the adapter keep working exactly as before, and only otherwise fall back to the userspace gs_usb bus already used on macOS, with a log line saying so. A channel that merely exists is not enough to pick socketcan: an unrelated on-board CAN controller can expose can0 with nothing wired to it, which is exactly what the Jetson does. Verified on hardware: zero-force scan reads all 7 motors, the coordinator blueprint boots and holds pose, and the first-trajectory script passes with 0.066 rad settled drift. --- .../manipulators/galaxea_a1z/adapter.py | 70 ++++++++++++++++++- .../manipulators/galaxea_a1z/test_adapter.py | 60 ++++++++++++++++ 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/dimos/hardware/manipulators/galaxea_a1z/adapter.py b/dimos/hardware/manipulators/galaxea_a1z/adapter.py index e174d15def..88ec06232b 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/adapter.py @@ -76,6 +76,74 @@ _GRIPPER_MAX_OPENING_M = 0.1 +def _socketcan_channel_is_up(channel: str) -> bool: + """True if the channel exists as a network interface and is IFF_UP.""" + try: + flags = int(Path("/sys/class/net", channel, "flags").read_text(), 16) + except (OSError, ValueError): + return False + return bool(flags & 0x1) + + +def _gs_usb_adapter_claimable() -> bool: + """True if the HHS USB-CANFD adapter is on the USB bus and no kernel + driver owns it (a bound kernel driver means socketcan is the right path + and userspace could not claim the interface anyway).""" + try: + import usb.core + + from dimos.hardware.manipulators.galaxea_a1z.gs_usb_bus import ( + GALAXEA_PRODUCT_ID, + GALAXEA_VENDOR_ID, + ) + + device = usb.core.find(idVendor=GALAXEA_VENDOR_ID, idProduct=GALAXEA_PRODUCT_ID) + if device is None: + return False + try: + return not device.is_kernel_driver_active(0) + except NotImplementedError: + return True + except usb.core.USBError: + print( + "Galaxea A1Z: found the USB-CANFD adapter but cannot open it " + "(insufficient permissions). Grant access with a udev rule: " + 'echo \'SUBSYSTEM=="usb", ATTRS{idVendor}=="a8fa", ' + 'ATTRS{idProduct}=="8598", MODE="0666"\' | ' + "sudo tee /etc/udev/rules.d/99-canfd.rules && " + "sudo udevadm control --reload-rules && sudo udevadm trigger" + ) + return False + except Exception: + return False + + +def _resolve_auto_transport(channel: str) -> str: + """Pick the CAN transport for transport="auto". + + socketcan stays the default whenever the channel is up (stock Linux + kernels bind gs_usb adapters through socketcan). When it is not, fall + back to the userspace gs_usb transport if the HHS adapter is on the USB + bus with no kernel driver bound - some kernels ship a gs_usb driver that + cannot drive this device (VID/PID not in its table, and pre-6.x gs_usb + hardcodes TX endpoint 0x02 where this adapter uses 0x01). A channel that + merely exists is not enough: an unrelated on-board CAN controller can + expose the same name with nothing wired to it. + """ + if platform.system() == "Darwin": + return "gs_usb" + if _socketcan_channel_is_up(channel): + return "socketcan" + if _gs_usb_adapter_claimable(): + print( + f"Galaxea A1Z: socketcan channel {channel!r} is not up but the " + "HHS USB-CANFD adapter is present with no kernel driver bound - " + "falling back to the userspace gs_usb transport" + ) + return "gs_usb" + return "socketcan" + + @contextlib.contextmanager def _gs_usb_can_bus() -> Iterator[None]: """Route the SDK's CAN bus construction through GsUsbMacBus. @@ -144,7 +212,7 @@ def __init__( self._gripper_max_torque = gripper_max_torque self._gripper_max_opening_m = gripper_max_opening_m if transport == "auto": - transport = "gs_usb" if platform.system() == "Darwin" else "socketcan" + transport = _resolve_auto_transport(address) self._transport = transport # Dimensional addition on top of the vendor SDK (not Galaxea behavior): # verified zero-force startup that prevents the enable-snap described diff --git a/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py index ecc96438d1..f7efa25922 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py @@ -457,6 +457,66 @@ def test_socketcan_transport_leaves_can_bus_untouched( assert can.interface.Bus is original_bus +def test_auto_transport_is_gs_usb_on_macos( + a1z_adapter_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(a1z_adapter_module.platform, "system", lambda: "Darwin") + + adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0") + assert adapter._transport == "gs_usb" + + +def test_auto_transport_prefers_socketcan_when_channel_is_up( + a1z_adapter_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(a1z_adapter_module.platform, "system", lambda: "Linux") + monkeypatch.setattr(a1z_adapter_module, "_socketcan_channel_is_up", lambda channel: True) + monkeypatch.setattr(a1z_adapter_module, "_gs_usb_adapter_claimable", lambda: True) + + adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0") + assert adapter._transport == "socketcan" + + +def test_auto_transport_falls_back_to_gs_usb_when_channel_down( + a1z_adapter_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(a1z_adapter_module.platform, "system", lambda: "Linux") + monkeypatch.setattr(a1z_adapter_module, "_socketcan_channel_is_up", lambda channel: False) + monkeypatch.setattr(a1z_adapter_module, "_gs_usb_adapter_claimable", lambda: True) + + adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0") + assert adapter._transport == "gs_usb" + + +def test_auto_transport_stays_socketcan_without_claimable_adapter( + a1z_adapter_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(a1z_adapter_module.platform, "system", lambda: "Linux") + monkeypatch.setattr(a1z_adapter_module, "_socketcan_channel_is_up", lambda channel: False) + monkeypatch.setattr(a1z_adapter_module, "_gs_usb_adapter_claimable", lambda: False) + + adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0") + assert adapter._transport == "socketcan" + + +def test_explicit_transport_bypasses_auto_detection( + a1z_adapter_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _boom(*args: Any) -> bool: + raise AssertionError("auto detection must not run for explicit transports") + + monkeypatch.setattr(a1z_adapter_module, "_socketcan_channel_is_up", _boom) + monkeypatch.setattr(a1z_adapter_module, "_gs_usb_adapter_claimable", _boom) + + adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0", transport="socketcan") + assert adapter._transport == "socketcan" + + def test_registry_entry_resolves() -> None: from dimos.hardware.manipulators.galaxea_a1z._registry import ADAPTER_FACTORIES From ada4100b7725cc3e0e02ee547d80f976a165cea8 Mon Sep 17 00:00:00 2001 From: Pim Van den Bosch Date: Fri, 17 Jul 2026 20:02:05 +0800 Subject: [PATCH 07/51] fix(galaxea_a1z): stabilize startup and gripper modeling --- .../manipulators/galaxea_a1z/adapter.py | 305 +++++++++++++----- .../manipulators/galaxea_a1z/test_adapter.py | 256 +++++++++++++-- dimos/hardware/test_adapter_registries.py | 10 +- dimos/manipulation/manipulation_module.py | 115 ++++++- dimos/manipulation/planning/spec/config.py | 5 + .../planning/world/roboplan_world.py | 74 ++++- dimos/manipulation/test_manipulation_unit.py | 153 ++++++++- dimos/manipulation/test_roboplan_world.py | 49 +++ dimos/robot/manipulators/a1z/config.py | 5 +- dimos/robot/manipulators/a1z/test_config.py | 36 +++ .../galaxea_a1z/blueprints/basic.py | 38 ++- .../robot/manipulators/galaxea_a1z/config.py | 2 + 12 files changed, 909 insertions(+), 139 deletions(-) create mode 100644 dimos/robot/manipulators/a1z/test_config.py diff --git a/dimos/hardware/manipulators/galaxea_a1z/adapter.py b/dimos/hardware/manipulators/galaxea_a1z/adapter.py index 88ec06232b..0d4d2121af 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/adapter.py @@ -75,72 +75,63 @@ # full open. Override via the gripper_max_opening_m constructor kwarg. _GRIPPER_MAX_OPENING_M = 0.1 - -def _socketcan_channel_is_up(channel: str) -> bool: - """True if the channel exists as a network interface and is IFF_UP.""" +_STARTUP_FEEDBACK_TIMEOUT_S = 0.5 +_STARTUP_RAMP_DURATION_S = 1.0 +_STARTUP_SAMPLE_PERIOD_S = 0.02 +_STARTUP_MAX_VELOCITY_RAD_S = 0.5 +_STARTUP_SETTLED_VELOCITY_RAD_S = 0.1 +_STARTUP_SETTLING_TIMEOUT_S = 1.0 +_STARTUP_HOLD_SAMPLES = 5 +_STARTUP_SETTLED_SAMPLES = 5 + +_SYS_CLASS_NET = Path("/sys/class/net") +_A1Z_SOCKETCAN_DRIVER = "gs_usb" + + +def _socketcan_channel_error(channel: str) -> str | None: + """Return why a channel is unsafe for A1Z SocketCAN, or None if ready.""" + interface_path = _SYS_CLASS_NET / channel try: - flags = int(Path("/sys/class/net", channel, "flags").read_text(), 16) - except (OSError, ValueError): - return False - return bool(flags & 0x1) - + flags = int((interface_path / "flags").read_text(), 16) + except FileNotFoundError: + return ( + f"SocketCAN interface {channel!r} does not exist. The HHS adapter must be " + "bound to the Linux gs_usb driver; pass its interface with --can-port." + ) + except (OSError, ValueError) as exc: + return f"cannot read SocketCAN interface {channel!r}: {exc}" -def _gs_usb_adapter_claimable() -> bool: - """True if the HHS USB-CANFD adapter is on the USB bus and no kernel - driver owns it (a bound kernel driver means socketcan is the right path - and userspace could not claim the interface anyway).""" try: - import usb.core - - from dimos.hardware.manipulators.galaxea_a1z.gs_usb_bus import ( - GALAXEA_PRODUCT_ID, - GALAXEA_VENDOR_ID, + driver = (interface_path / "device" / "driver").resolve(strict=True).name + except OSError as exc: + return f"cannot verify the kernel driver for SocketCAN interface {channel!r}: {exc}" + + if driver != _A1Z_SOCKETCAN_DRIVER: + return ( + f"SocketCAN interface {channel!r} belongs to kernel driver {driver!r}, not " + f"the HHS adapter driver {_A1Z_SOCKETCAN_DRIVER!r}. Pass the HHS SocketCAN " + "interface with --can-port." ) - - device = usb.core.find(idVendor=GALAXEA_VENDOR_ID, idProduct=GALAXEA_PRODUCT_ID) - if device is None: - return False - try: - return not device.is_kernel_driver_active(0) - except NotImplementedError: - return True - except usb.core.USBError: - print( - "Galaxea A1Z: found the USB-CANFD adapter but cannot open it " - "(insufficient permissions). Grant access with a udev rule: " - 'echo \'SUBSYSTEM=="usb", ATTRS{idVendor}=="a8fa", ' - 'ATTRS{idProduct}=="8598", MODE="0666"\' | ' - "sudo tee /etc/udev/rules.d/99-canfd.rules && " - "sudo udevadm control --reload-rules && sudo udevadm trigger" - ) - return False - except Exception: - return False + if not flags & 0x1: + return ( + f"SocketCAN interface {channel!r} is DOWN. Configure it for 1 Mbit/s and " + "bring it UP before starting DimOS." + ) + return None def _resolve_auto_transport(channel: str) -> str: """Pick the CAN transport for transport="auto". - socketcan stays the default whenever the channel is up (stock Linux - kernels bind gs_usb adapters through socketcan). When it is not, fall - back to the userspace gs_usb transport if the HHS adapter is on the USB - bus with no kernel driver bound - some kernels ship a gs_usb driver that - cannot drive this device (VID/PID not in its table, and pre-6.x gs_usb - hardcodes TX endpoint 0x02 where this adapter uses 0x01). A channel that - merely exists is not enough: an unrelated on-board CAN controller can - expose the same name with nothing wired to it. + macOS uses the userspace bus because SocketCAN is unavailable there. + Every other platform uses native SocketCAN. There is deliberately no + Linux userspace fallback: connect() validates the selected kernel + interface and fails closed if it is missing, down, or not backed by the + HHS adapter's gs_usb driver. """ + del channel if platform.system() == "Darwin": return "gs_usb" - if _socketcan_channel_is_up(channel): - return "socketcan" - if _gs_usb_adapter_claimable(): - print( - f"Galaxea A1Z: socketcan channel {channel!r} is not up but the " - "HHS USB-CANFD adapter is present with no kernel driver bound - " - "falling back to the userspace gs_usb transport" - ) - return "gs_usb" return "socketcan" @@ -213,10 +204,14 @@ def __init__( self._gripper_max_opening_m = gripper_max_opening_m if transport == "auto": transport = _resolve_auto_transport(address) + if transport == "gs_usb" and platform.system() != "Darwin": + raise ValueError( + "transport='gs_usb' is macOS-only; Linux A1Z must use native SocketCAN" + ) self._transport = transport # Dimensional addition on top of the vendor SDK (not Galaxea behavior): - # verified zero-force startup that prevents the enable-snap described - # in _safe_start(). Set safe_start=False for vendor-stock start(). + # staged startup that prevents the enable-snap described in + # _safe_start(). Set safe_start=False for vendor-stock start(). self._safe_start_enabled = safe_start self._robot: Any = None self._connected: bool = False @@ -227,6 +222,12 @@ def __init__( def connect(self) -> bool: """Open the CAN bus and construct the robot. Motors stay unpowered.""" + if self._transport == "socketcan": + channel_error = _socketcan_channel_error(self._can_channel) + if channel_error is not None: + print(f"ERROR: Galaxea A1Z SocketCAN configuration: {channel_error}") + return False + try: from a1z.robots.get_robot import get_a1z_robot except ImportError: @@ -630,20 +631,23 @@ def _ensure_motors_disabled(self) -> None: pass def _safe_start(self) -> None: - """Start the SDK control loop without ever commanding force toward - an unverified position. + """Start the SDK loop with measured-pose hold before model feedforward. The SDK's start() reads feedback once after a fixed 50 ms wait and position-holds whatever it read; if a motor's first report is late (typical on USB transports), the hold target defaults to zero and the arm snaps to neutral at full gain. Observed on hardware. - Sequence here: start with kp=0 (gravity comp only - a position snap - is physically impossible), wait until every motor has actually - reported, verify the pose is inside limits and near-stationary, then - engage the hold gains at the measured pose (zero error, zero jerk). - Raises RuntimeError (and disables) instead of moving if verification - fails. + Start with both position gain and model feedforward at zero, wait for + every motor to report, and validate the measured state. Establish a + measured-pose PD hold while feedforward is still zero, verify that + hold, and only then ramp the configured gravity factor. This ordering + matters: kp=0 alone is not zero force because the SDK's gravity + feedforward remains active independently of position gain. + + The A1Z has no brakes. It must be supported during activation and any + failed activation disables the motors after first removing commanded + gain and model feedforward. """ robot = self._robot dof = self._dof @@ -656,40 +660,163 @@ def _safe_start(self) -> None: dtype=float, ) - robot.start(initial_kp=np.zeros(dof), initial_kd=default_kd * 0.5) + configured_gravity_factor = float(robot.gravity_comp_factor) + robot.gravity_comp_factor = 0.0 - deadline = time.monotonic() + 2.0 - while time.monotonic() < deadline and not self._all_motors_reported(): - time.sleep(0.02) - if not self._all_motors_reported(): - robot.stop() - raise RuntimeError("no feedback from all motors within 2 s; motors disabled") + try: + robot.start(initial_kp=np.zeros(dof), initial_kd=default_kd * 0.5) + + deadline = time.monotonic() + _STARTUP_FEEDBACK_TIMEOUT_S + while time.monotonic() < deadline and not self._all_motors_reported(): + time.sleep(_STARTUP_SAMPLE_PERIOD_S) + if not self._all_motors_reported(): + raise RuntimeError( + f"no feedback from all motors within {_STARTUP_FEEDBACK_TIMEOUT_S:.1f} s" + ) + + hold_pos, _ = self._validated_startup_state( + robot.get_joint_state(), + phase="initial feedback", + max_velocity=_STARTUP_MAX_VELOCITY_RAD_S, + ) + + if not self._zero_gravity: + # The measured target has zero position error at this instant, + # so full holding gains add stiffness without requesting a + # position step. Establish this hold before applying any model + # torque. Teaching mode deliberately keeps kp at zero. + robot.command_joint_state( + { + "pos": hold_pos.copy(), + "vel": np.zeros(dof), + "kp": default_kp, + "kd": default_kd, + } + ) + for sample in range(1, _STARTUP_HOLD_SAMPLES + 1): + time.sleep(_STARTUP_SAMPLE_PERIOD_S) + self._validated_startup_state( + robot.get_joint_state(), + phase=f"position hold {sample}/{_STARTUP_HOLD_SAMPLES}", + max_velocity=_STARTUP_MAX_VELOCITY_RAD_S, + ) + + ramp_steps = max( + 1, + round(_STARTUP_RAMP_DURATION_S / _STARTUP_SAMPLE_PERIOD_S), + ) + for step in range(1, ramp_steps + 1): + alpha = step / ramp_steps + robot.gravity_comp_factor = configured_gravity_factor * alpha + time.sleep(_STARTUP_SAMPLE_PERIOD_S) + self._validated_startup_state( + robot.get_joint_state(), + phase=f"gravity ramp {step}/{ramp_steps}", + max_velocity=_STARTUP_MAX_VELOCITY_RAD_S, + ) + + self._wait_for_startup_settling() + except Exception: + self._quiesce_and_stop_after_failed_start() + raise + + def _wait_for_startup_settling(self) -> None: + """Wait for consecutive stable samples after the gravity ramp. + + Encoder-derived velocity occasionally contains an isolated sample just + above the settled threshold. Keep the hard startup velocity ceiling on + every sample, but only declare the arm settled after a consecutive + stable window. Sustained motion still fails within a bounded timeout. + """ + max_samples = max( + _STARTUP_SETTLED_SAMPLES, + round(_STARTUP_SETTLING_TIMEOUT_S / _STARTUP_SAMPLE_PERIOD_S), + ) + stable_samples = 0 + last_pos = np.zeros(self._dof) + last_vel = np.zeros(self._dof) + for sample in range(1, max_samples + 1): + time.sleep(_STARTUP_SAMPLE_PERIOD_S) + last_pos, last_vel = self._validated_startup_state( + self._robot.get_joint_state(), + phase=f"settling {sample}/{max_samples}", + max_velocity=_STARTUP_MAX_VELOCITY_RAD_S, + ) + if np.all(np.abs(last_vel) <= _STARTUP_SETTLED_VELOCITY_RAD_S): + stable_samples += 1 + if stable_samples >= _STARTUP_SETTLED_SAMPLES: + return + else: + stable_samples = 0 + + raise RuntimeError( + f"arm did not settle within {_STARTUP_SETTLING_TIMEOUT_S:.1f} s; " + f"required {_STARTUP_SETTLED_SAMPLES} consecutive samples at or below " + f"{_STARTUP_SETTLED_VELOCITY_RAD_S:.3f} rad/s; " + f"positions={np.round(last_pos, 3).tolist()}, " + f"velocities={np.round(last_vel, 3).tolist()}" + ) - state = robot.get_joint_state() + def _validated_startup_state( + self, + state: dict[str, Any], + *, + phase: str, + max_velocity: float, + ) -> tuple[np.ndarray, np.ndarray]: + """Validate one startup sample and return position and velocity.""" + if not self._robot or not self._robot.is_running: + raise RuntimeError(f"SDK control loop stopped during {phase}") pos = np.asarray(state["pos"], dtype=float) vel = np.asarray(state["vel"], dtype=float) + expected_shape = (self._dof,) + if pos.shape != expected_shape or vel.shape != expected_shape: + raise RuntimeError( + f"invalid state shape during {phase}: pos={pos.shape}, vel={vel.shape}" + ) + if not np.all(np.isfinite(pos)) or not np.all(np.isfinite(vel)): + raise RuntimeError( + f"non-finite state during {phase}: " + f"pos={np.round(pos, 3).tolist()}, vel={np.round(vel, 3).tolist()}" + ) + lower = np.asarray(_POSITION_LOWER) - 0.15 upper = np.asarray(_POSITION_UPPER) + 0.15 - if np.any(pos < lower) or np.any(pos > upper): - robot.stop() + outside = (pos < lower) | (pos > upper) + if np.any(outside): + offenders = ", ".join(f"joint{i + 1}={pos[i]:.3f}" for i in np.flatnonzero(outside)) raise RuntimeError( - f"start pose {np.round(pos, 3).tolist()} outside joint limits - " - "move the arm inside its range by hand, then retry; motors disabled" + f"start pose outside joint limits during {phase}: {offenders}; " + f"positions={np.round(pos, 3).tolist()}" ) - if np.any(np.abs(vel) > 0.5): - robot.stop() - raise RuntimeError("arm is moving during startup; motors disabled") - - if not self._zero_gravity: - # Engage hold gains at the *measured* pose: error ~0, no jerk. - robot.command_joint_state( - { - "pos": pos.copy(), - "vel": np.zeros(dof), - "kp": default_kp, - "kd": default_kd, - } + + too_fast = np.abs(vel) > max_velocity + if np.any(too_fast): + offenders = ", ".join( + f"joint{i + 1}={vel[i]:.3f} rad/s" for i in np.flatnonzero(too_fast) + ) + raise RuntimeError( + f"arm moving during {phase}: {offenders} " + f"(limit {max_velocity:.3f} rad/s); " + f"positions={np.round(pos, 3).tolist()}, " + f"velocities={np.round(vel, 3).tolist()}" ) + return pos, vel + + def _quiesce_and_stop_after_failed_start(self) -> None: + """Remove commanded force before disabling after activation failure.""" + robot = self._robot + if robot is None: + return + robot.gravity_comp_factor = 0.0 + try: + robot.estop() + except Exception: + pass + try: + robot.stop() + except Exception: + self._ensure_motors_disabled() def _all_motors_reported(self) -> bool: """True once every motor in the SDK chain has sent real feedback.""" diff --git a/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py index f7efa25922..b5c8a2dda9 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py @@ -16,6 +16,7 @@ from collections.abc import Iterator import importlib +from pathlib import Path import sys from types import ModuleType from typing import Any, ClassVar @@ -60,8 +61,13 @@ def __init__(self, **factory_kwargs: Any) -> None: self._bus = _FakeBus() self._default_kp = np.array([30.0, 30.0, 30.0, 20.0, 5.0, 5.0]) self._default_kd = np.array([1.0, 1.0, 1.0, 0.5, 0.5, 0.5]) - self._motor_chain = _FakeMotorChain() self.actions: list[Any] = [] + self.gravity_factor_history: list[float] = [] + self.gravity_comp_factor = float(factory_kwargs["gravity_comp_factor"]) + self.full_gravity_velocity_samples: list[np.ndarray] = [] + self._motor_chain = _FakeMotorChain() + self.move_during_gravity_ramp = False + self.stop_during_gravity_ramp = False self.state = { "pos": np.zeros(6), "vel": np.zeros(6), @@ -71,6 +77,21 @@ def __init__(self, **factory_kwargs: Any) -> None: "temp_rotor": np.full(6, 40.0), } + @property + def gravity_comp_factor(self) -> float: + return self._gravity_comp_factor + + @gravity_comp_factor.setter + def gravity_comp_factor(self, value: float) -> None: + self._gravity_comp_factor = value + self.gravity_factor_history.append(value) + if value <= 0 or not hasattr(self, "state"): + return + if self.move_during_gravity_ramp: + self.state["vel"][2] = 0.75 + if self.stop_during_gravity_ramp: + self._running = False + @property def is_running(self) -> bool: return self._running @@ -82,11 +103,15 @@ def is_estopped(self) -> bool: def start(self, initial_kp: Any = None, initial_kd: Any = None) -> None: self._running = True self._estopped = False - self.actions.append(("start", initial_kp, initial_kd)) + # Model the real SDK boundary that caused the hardware regression: + # feedforward present when the loop starts can move the arm even at kp=0. + if self.gravity_comp_factor > 0 and initial_kp is not None and np.allclose(initial_kp, 0): + self.state["vel"][1] = self.gravity_comp_factor + self.actions.append(("start", initial_kp, initial_kd, self.gravity_comp_factor)) self.actions.append("start") def command_joint_state(self, joint_state: dict[str, np.ndarray]) -> None: - self.actions.append(("command_joint_state", joint_state)) + self.actions.append(("command_joint_state", joint_state, self.gravity_comp_factor)) def stop(self) -> None: self._running = False @@ -101,7 +126,13 @@ def release(self) -> None: self.actions.append("release") def get_joint_state(self) -> dict[str, np.ndarray]: - return dict(self.state) + state = dict(self.state) + configured_gravity = float(self.factory_kwargs["gravity_comp_factor"]) + if self.full_gravity_velocity_samples and np.isclose( + self.gravity_comp_factor, configured_gravity + ): + state["vel"] = self.full_gravity_velocity_samples.pop(0).copy() + return state def command_joint_pos(self, pos: np.ndarray) -> None: self.actions.append(("command_joint_pos", pos.tolist())) @@ -122,7 +153,10 @@ def get_gripper_pos(self) -> float | None: @pytest.fixture -def a1z_adapter_module(monkeypatch: pytest.MonkeyPatch) -> Iterator[ModuleType]: +def a1z_adapter_module( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> Iterator[ModuleType]: _FakeArmRobot.instances.clear() a1z_pkg = ModuleType("a1z") @@ -134,7 +168,17 @@ def a1z_adapter_module(monkeypatch: pytest.MonkeyPatch) -> Iterator[ModuleType]: monkeypatch.setitem(sys.modules, "a1z.robots", a1z_robots) monkeypatch.setitem(sys.modules, "a1z.robots.get_robot", a1z_get_robot) sys.modules.pop("dimos.hardware.manipulators.galaxea_a1z.adapter", None) - yield importlib.import_module("dimos.hardware.manipulators.galaxea_a1z.adapter") + module = importlib.import_module("dimos.hardware.manipulators.galaxea_a1z.adapter") + monkeypatch.setattr(module.time, "sleep", lambda _seconds: None) + sys_class_net = tmp_path / "net" + interface = sys_class_net / "can0" + driver_path = tmp_path / "drivers" / "gs_usb" + (interface / "device").mkdir(parents=True) + driver_path.mkdir(parents=True, exist_ok=True) + (interface / "flags").write_text("0x1\n") + (interface / "device" / "driver").symlink_to(driver_path, target_is_directory=True) + monkeypatch.setattr(module, "_SYS_CLASS_NET", sys_class_net) + yield module sys.modules.pop("dimos.hardware.manipulators.galaxea_a1z.adapter", None) @@ -169,24 +213,140 @@ def test_activate_starts_control_loop_and_enables( assert adapter.read_enabled() -def test_safe_start_never_commands_force_toward_unverified_pose( +def test_safe_start_stages_measured_hold_before_gravity_feedforward( a1z_adapter_module: ModuleType, ) -> None: - adapter = _connected_adapter(a1z_adapter_module) + adapter = _connected_adapter(a1z_adapter_module, gravity_comp_factor=1.0) robot = _FakeArmRobot.instances[-1] robot.state["pos"] = np.array([0.1, 0.5, -0.5, 0.2, -0.1, 0.3]) assert adapter.activate() start_call = next(a for a in robot.actions if isinstance(a, tuple) and a[0] == "start") - assert np.allclose(start_call[1], np.zeros(6)) # kp=0: snap impossible + assert np.allclose(start_call[1], np.zeros(6)) + assert start_call[3] == 0.0 + + hold = [a for a in robot.actions if isinstance(a, tuple) and a[0] == "command_joint_state"] + assert len(hold) == 1 + assert np.allclose(hold[0][1]["pos"], robot.state["pos"]) + assert np.allclose(hold[0][1]["kp"], robot._default_kp) + assert hold[0][2] == 0.0 + zero_index = robot.gravity_factor_history.index(0.0) + gravity_ramp = robot.gravity_factor_history[zero_index:] + assert gravity_ramp == sorted(gravity_ramp) + assert robot.gravity_comp_factor == 1.0 + + +def test_safe_start_tolerates_one_noisy_settling_velocity_sample( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + stable = np.full(6, 0.05) + noisy = stable.copy() + noisy[1] = 0.119 + # The first sample is consumed by the final gravity-ramp validation. + robot.full_gravity_velocity_samples = [ + np.zeros(6), + *[stable for _ in range(4)], + noisy, + *[stable for _ in range(5)], + ] + + assert adapter.activate() + assert adapter.read_enabled() + - engage = next( - a for a in robot.actions if isinstance(a, tuple) and a[0] == "command_joint_state" +def test_safe_start_rejects_sustained_motion_during_settling( + a1z_adapter_module: ModuleType, + capsys: pytest.CaptureFixture[str], +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + moving = np.zeros(6) + moving[1] = 0.119 + # Include the final gravity-ramp sample, then exceed the full settling window. + robot.full_gravity_velocity_samples = [np.zeros(6), *[moving for _ in range(100)]] + + assert not adapter.activate() + + output = capsys.readouterr().out + assert "arm did not settle within 1.0 s" in output + assert "velocities=[0.0, 0.119, 0.0, 0.0, 0.0, 0.0]" in output + assert robot.gravity_comp_factor == 0.0 + assert robot.actions[-2:] == ["estop", "stop"] + + +def test_safe_start_preserves_zero_gain_teaching_mode( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter( + a1z_adapter_module, + gravity_comp_factor=0.7, + zero_gravity=True, ) - js = engage[1] - assert np.allclose(js["pos"], robot.state["pos"]) # hold target = measured pose - assert np.allclose(js["kp"], robot._default_kp) + robot = _FakeArmRobot.instances[-1] + + assert adapter.activate() + + start_call = next(a for a in robot.actions if isinstance(a, tuple) and a[0] == "start") + assert np.allclose(start_call[1], np.zeros(6)) + assert start_call[3] == 0.0 + assert not any( + isinstance(action, tuple) and action[0] == "command_joint_state" for action in robot.actions + ) + assert robot.gravity_comp_factor == 0.7 + + +def test_safe_start_reports_joint_motion_and_removes_force( + a1z_adapter_module: ModuleType, + capsys: pytest.CaptureFixture[str], +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + robot.state["pos"] = np.array([0.1, 0.5, -0.5, 0.2, -0.1, 0.3]) + robot.state["vel"][2] = 0.75 + + assert not adapter.activate() + + output = capsys.readouterr().out + assert "joint3=0.750 rad/s" in output + assert "positions=[0.1, 0.5, -0.5, 0.2, -0.1, 0.3]" in output + assert robot.gravity_comp_factor == 0.0 + assert robot.actions[-2:] == ["estop", "stop"] + + +def test_safe_start_aborts_motion_during_gravity_ramp( + a1z_adapter_module: ModuleType, + capsys: pytest.CaptureFixture[str], +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + robot.move_during_gravity_ramp = True + + assert not adapter.activate() + + output = capsys.readouterr().out + assert "arm moving during gravity ramp 1/50" in output + assert "joint3=0.750 rad/s" in output + assert robot.gravity_comp_factor == 0.0 + assert robot.actions[-2:] == ["estop", "stop"] + + +def test_safe_start_rejects_dead_sdk_control_loop( + a1z_adapter_module: ModuleType, + capsys: pytest.CaptureFixture[str], +) -> None: + adapter = _connected_adapter(a1z_adapter_module) + robot = _FakeArmRobot.instances[-1] + robot.stop_during_gravity_ramp = True + + assert not adapter.activate() + + output = capsys.readouterr().out + assert "SDK control loop stopped during gravity ramp 1/50" in output + assert robot.gravity_comp_factor == 0.0 + assert robot.actions[-2:] == ["estop", "stop"] def test_safe_start_false_uses_vendor_stock_startup( @@ -409,6 +569,7 @@ def test_connect_fails_gracefully_without_sdk( monkeypatch.setitem(sys.modules, "a1z", None) # force ImportError sys.modules.pop("dimos.hardware.manipulators.galaxea_a1z.adapter", None) module = importlib.import_module("dimos.hardware.manipulators.galaxea_a1z.adapter") + monkeypatch.setattr(module, "_socketcan_channel_error", lambda _channel: None) adapter = module.GalaxeaA1ZAdapter(address="can0") assert not adapter.connect() @@ -421,6 +582,8 @@ def test_gs_usb_transport_swaps_bus_during_factory_call( ) -> None: import can + monkeypatch.setattr(a1z_adapter_module.platform, "system", lambda: "Darwin") + class _FakeGsBus: def __init__(self, **kwargs: Any) -> None: self.kwargs = kwargs @@ -467,51 +630,86 @@ def test_auto_transport_is_gs_usb_on_macos( assert adapter._transport == "gs_usb" -def test_auto_transport_prefers_socketcan_when_channel_is_up( +def test_auto_transport_is_socketcan_on_linux_without_usb_detection( a1z_adapter_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(a1z_adapter_module.platform, "system", lambda: "Linux") - monkeypatch.setattr(a1z_adapter_module, "_socketcan_channel_is_up", lambda channel: True) - monkeypatch.setattr(a1z_adapter_module, "_gs_usb_adapter_claimable", lambda: True) adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0") assert adapter._transport == "socketcan" -def test_auto_transport_falls_back_to_gs_usb_when_channel_down( +def test_explicit_gs_usb_transport_is_rejected_on_linux( a1z_adapter_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(a1z_adapter_module.platform, "system", lambda: "Linux") - monkeypatch.setattr(a1z_adapter_module, "_socketcan_channel_is_up", lambda channel: False) - monkeypatch.setattr(a1z_adapter_module, "_gs_usb_adapter_claimable", lambda: True) - adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0") - assert adapter._transport == "gs_usb" + with pytest.raises(ValueError, match="macOS-only"): + a1z_adapter_module.GalaxeaA1ZAdapter(address="can0", transport="gs_usb") -def test_auto_transport_stays_socketcan_without_claimable_adapter( +def test_socketcan_connect_fails_closed_before_sdk_construction( a1z_adapter_module: ModuleType, monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], ) -> None: monkeypatch.setattr(a1z_adapter_module.platform, "system", lambda: "Linux") - monkeypatch.setattr(a1z_adapter_module, "_socketcan_channel_is_up", lambda channel: False) - monkeypatch.setattr(a1z_adapter_module, "_gs_usb_adapter_claimable", lambda: False) + monkeypatch.setattr( + a1z_adapter_module, + "_socketcan_channel_error", + lambda channel: f"SocketCAN interface {channel!r} belongs to kernel driver 'mttcan'", + ) adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0") - assert adapter._transport == "socketcan" + assert not adapter.connect() + assert not _FakeArmRobot.instances + assert "belongs to kernel driver 'mttcan'" in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("driver", "flags", "expected_error"), + [ + ("mttcan", "0x1\n", "belongs to kernel driver 'mttcan'"), + ("gs_usb", "0x0\n", "interface 'can7' is DOWN"), + ("gs_usb", "0x1\n", None), + ], +) +def test_socketcan_channel_validation_requires_up_gs_usb_interface( + a1z_adapter_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + driver: str, + flags: str, + expected_error: str | None, +) -> None: + sys_class_net = tmp_path / "net" + interface = sys_class_net / "can7" + driver_path = tmp_path / "drivers" / driver + (interface / "device").mkdir(parents=True) + driver_path.mkdir(parents=True, exist_ok=True) + (interface / "flags").write_text(flags) + (interface / "device" / "driver").symlink_to(driver_path, target_is_directory=True) + monkeypatch.setattr(a1z_adapter_module, "_SYS_CLASS_NET", sys_class_net) + + error = a1z_adapter_module._socketcan_channel_error("can7") + + if expected_error is None: + assert error is None + else: + assert error is not None + assert expected_error in error def test_explicit_transport_bypasses_auto_detection( a1z_adapter_module: ModuleType, monkeypatch: pytest.MonkeyPatch, ) -> None: - def _boom(*args: Any) -> bool: + def _boom(*args: Any) -> str: raise AssertionError("auto detection must not run for explicit transports") - monkeypatch.setattr(a1z_adapter_module, "_socketcan_channel_is_up", _boom) - monkeypatch.setattr(a1z_adapter_module, "_gs_usb_adapter_claimable", _boom) + monkeypatch.setattr(a1z_adapter_module, "_resolve_auto_transport", _boom) adapter = a1z_adapter_module.GalaxeaA1ZAdapter(address="can0", transport="socketcan") assert adapter._transport == "socketcan" diff --git a/dimos/hardware/test_adapter_registries.py b/dimos/hardware/test_adapter_registries.py index 13c38804b8..19ddc23a28 100644 --- a/dimos/hardware/test_adapter_registries.py +++ b/dimos/hardware/test_adapter_registries.py @@ -47,7 +47,15 @@ # Every name each registry must declare. Removing a name from a manifest is a # conscious change: update this set in the same PR. EXPECTED_NAMES = { - "manipulators": {"a750", "mock", "openarm", "piper", "sim_mujoco", "xarm"}, + "manipulators": { + "a750", + "galaxea_a1z", + "mock", + "openarm", + "piper", + "sim_mujoco", + "xarm", + }, "drive_trains": { "flowbase", "mock_twist_base", diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 20a8d167c0..191f9b72dc 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -82,6 +82,8 @@ logger = setup_logger() +_LIMIT_PROJECTION_LOG_PERIOD_S = 5.0 + # Composite type aliases for readability (using semantic IDs from planning.spec) RobotEntry: TypeAlias = tuple[WorldRobotID, RobotModelConfig, JointTrajectoryGenerator] """(world_robot_id, config, trajectory_generator)""" @@ -96,6 +98,10 @@ """Maps robot_name -> planned trajectory""" +class _MeasuredStateLimitError(ValueError): + """Raised when measured feedback is too far outside planning limits.""" + + class ManipulationState(Enum): """State machine for manipulation module.""" @@ -147,6 +153,7 @@ def __init__(self, **kwargs: Any) -> None: self._lock = threading.Lock() self._error_message = "" self._planning_epoch = 0 + self._last_limit_projection_log: dict[WorldRobotID, float] = {} # Planning components (initialized in start()) self._world_monitor: WorldMonitor | None = None @@ -509,6 +516,11 @@ def _solve_ik_for_pose( """Run the configured kinematics backend for a world-frame pose.""" assert self._world_monitor and self._kinematics + try: + planning_seed = self._project_measured_state_for_planning(robot_id, seed) + except _MeasuredStateLimitError as exc: + return IKResult(status=IKStatus.JOINT_LIMITS, message=str(exc)) + # Convert Pose to PoseStamped for the IK solver from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -522,10 +534,101 @@ def _solve_ik_for_pose( world=self._world_monitor.world, robot_id=robot_id, target_pose=target_pose, - seed=seed, + seed=planning_seed, check_collision=check_collision, ) + def _project_measured_state_for_planning( + self, robot_id: WorldRobotID, state: JointState + ) -> JointState: + """Project a minor measured-state overshoot onto the planning limits. + + Driver feedback remains untouched. Only the copy passed to IK or a + planner is projected, and only when every violation is within the + robot-specific measured-state tolerance. + """ + assert self._world_monitor + config = next( + ( + config + for candidate_id, config, _ in self._robots.values() + if candidate_id == robot_id + ), + None, + ) + if config is None: + raise _MeasuredStateLimitError(f"No robot configuration for '{robot_id}'") + + positions = np.asarray(state.position, dtype=np.float64) + lower_limits, upper_limits = self._world_monitor.get_joint_limits(robot_id) + model_lower = np.asarray(lower_limits, dtype=np.float64) + model_upper = np.asarray(upper_limits, dtype=np.float64) + if positions.shape != model_lower.shape or positions.shape != model_upper.shape: + raise _MeasuredStateLimitError( + "Measured state and planning joint limits have different lengths: " + f"state={positions.size}, lower={model_lower.size}, upper={model_upper.size}" + ) + if not np.all(np.isfinite(positions)): + raise _MeasuredStateLimitError("Measured planning state contains non-finite positions") + + joint_names = state.name if state.name else config.joint_names + if len(joint_names) != len(state.position): + raise _MeasuredStateLimitError( + "Measured planning state must name every position or omit all names" + ) + model_index_by_name = {name: i for i, name in enumerate(config.joint_names)} + model_indices: list[int] = [] + for name in joint_names: + model_name = config.get_urdf_joint_name(name) + if model_name not in model_index_by_name: + raise _MeasuredStateLimitError( + f"Measured planning state has unknown joint '{name}'" + ) + model_indices.append(model_index_by_name[model_name]) + if len(set(model_indices)) != len(model_indices): + raise _MeasuredStateLimitError("Measured planning state contains duplicate joints") + lower = model_lower[model_indices] + upper = model_upper[model_indices] + + overshoot = np.maximum(lower - positions, positions - upper) + outside = overshoot > 0.0 + if not np.any(outside): + return state + + tolerance = config.measured_state_limit_tolerance + beyond_tolerance = outside & (overshoot > tolerance) + if np.any(beyond_tolerance): + violations = ", ".join( + f"{joint_names[i]}={positions[i]:.6f} outside " + f"[{lower[i]:.6f}, {upper[i]:.6f}] by {overshoot[i]:.6f} rad" + for i in np.flatnonzero(beyond_tolerance) + ) + raise _MeasuredStateLimitError( + f"Measured planning state exceeds the {tolerance:.6f} rad limit tolerance: " + f"{violations}" + ) + + projected = JointState(state) + projected.position = np.clip(positions, lower, upper).tolist() + projections = ", ".join( + f"{joint_names[i]}={positions[i]:.6f}->{projected.position[i]:.6f}" + for i in np.flatnonzero(outside) + ) + now = time.monotonic() + with self._lock: + previous_log = self._last_limit_projection_log.get(robot_id, 0.0) + should_log = now - previous_log >= _LIMIT_PROJECTION_LOG_PERIOD_S + if should_log: + self._last_limit_projection_log[robot_id] = now + if should_log: + logger.warning( + "Projected measured state onto planning limits", + robot_id=str(robot_id), + projections=projections, + tolerance_rad=tolerance, + ) + return projected + @rpc def solve_ik( self, @@ -588,7 +691,8 @@ def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: ik = self._solve_ik_for_pose(robot_id, pose, current, check_collision=True) if not ik.is_success() or ik.joint_state is None: - return self._fail(f"IK failed: {ik.status.name}") + detail = f": {ik.message}" if ik.message else "" + return self._fail(f"IK failed: {ik.status.name}{detail}") logger.info(f"IK solved, error: {ik.position_error:.4f}m") return self._plan_path_only(robot_name, robot_id, ik.joint_state, planning_epoch) @@ -621,6 +725,10 @@ def _plan_path_only( start = self._world_monitor.get_current_joint_state(robot_id) if start is None: return self._fail("No joint state") + try: + start = self._project_measured_state_for_planning(robot_id, start) + except _MeasuredStateLimitError as exc: + return self._fail(f"Planning start is invalid: {exc}") # Trim goal to planner DOF (e.g. strip gripper joint from coordinator state) planner_dof = len(start.position) @@ -641,7 +749,8 @@ def _plan_path_only( logger.info("Discarding cancelled planning result") return False if not result.is_success(): - return self._fail(f"Planning failed: {result.status.name}") + detail = f": {result.message}" if result.message else "" + return self._fail(f"Planning failed: {result.status.name}{detail}") logger.info(f"Path: {len(result.path)} waypoints") self._planned_paths[robot_name] = result.path diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index 74dc3bd69b..eaf7532462 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -50,6 +50,9 @@ class RobotModelConfig(ModuleConfig): corresponds to URDF's "joint1". If empty, names are assumed to match. coordinator_task_name: Task name for executing trajectories via coordinator RPC. If set, trajectories can be executed via execute_trajectory() RPC. + measured_state_limit_tolerance: Maximum measured-state limit overshoot + that may be projected onto the model limits for planning. This applies + only to planning seeds and starts, never to goals or hardware commands. """ name: str @@ -76,6 +79,8 @@ class RobotModelConfig(ModuleConfig): tf_extra_links: list[str] = Field(default_factory=list) # Home/observe joint configuration for go_home skill home_joints: list[float] | None = None + # Real encoder feedback can rest slightly beyond a modeled soft limit. + measured_state_limit_tolerance: float = Field(default=1e-3, ge=0.0) # Pre-grasp offset distance in meters (along approach direction) pre_grasp_offset: float = 0.10 diff --git a/dimos/manipulation/planning/world/roboplan_world.py b/dimos/manipulation/planning/world/roboplan_world.py index d252d3fdc9..996a42535a 100644 --- a/dimos/manipulation/planning/world/roboplan_world.py +++ b/dimos/manipulation/planning/world/roboplan_world.py @@ -328,8 +328,48 @@ def plan_joint_path( message="RoboPlan-native planner requires its RoboPlanWorld instance", ) start_time = time.time() - q_start = self._joint_state_to_q(robot_id, start) - q_goal = self._joint_state_to_q(robot_id, goal) + try: + q_start = self._joint_state_to_q(robot_id, start) + except ValueError as exc: + return PlanningResult( + status=PlanningStatus.INVALID_START, + planning_time=time.time() - start_time, + message=f"Invalid start configuration: {exc}", + ) + try: + q_goal = self._joint_state_to_q(robot_id, goal) + except ValueError as exc: + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + planning_time=time.time() - start_time, + message=f"Invalid goal configuration: {exc}", + ) + + if violation := self._joint_limit_violation(robot_id, q_start): + return PlanningResult( + status=PlanningStatus.INVALID_START, + planning_time=time.time() - start_time, + message=f"Start configuration is outside joint limits: {violation}", + ) + if violation := self._joint_limit_violation(robot_id, q_goal): + return PlanningResult( + status=PlanningStatus.INVALID_GOAL, + planning_time=time.time() - start_time, + message=f"Goal configuration is outside joint limits: {violation}", + ) + if self._has_collisions(robot_id, q_start): + return PlanningResult( + status=PlanningStatus.COLLISION_AT_START, + planning_time=time.time() - start_time, + message="Start configuration is in collision", + ) + if self._has_collisions(robot_id, q_goal): + return PlanningResult( + status=PlanningStatus.COLLISION_AT_GOAL, + planning_time=time.time() - start_time, + message="Goal configuration is in collision", + ) + try: path_arrays = self._run_native_rrt(robot_id, q_start, q_goal, timeout) except ValueError as exc: @@ -340,6 +380,19 @@ def plan_joint_path( planning_time=time.time() - start_time, message=f"RoboPlan-native planning failed: {exc}", ) + except RuntimeError as exc: + message = str(exc) + if "Invalid start configuration" in message: + status = PlanningStatus.INVALID_START + elif "Invalid goal configuration" in message: + status = PlanningStatus.INVALID_GOAL + else: + raise + return PlanningResult( + status=status, + planning_time=time.time() - start_time, + message=f"RoboPlan-native planning failed: {message}", + ) if not path_arrays: return PlanningResult( status=PlanningStatus.NO_SOLUTION, @@ -526,6 +579,23 @@ def _joint_state_to_q( [name_to_pos[name] for name in robot.config.joint_names], dtype=np.float64 ) + def _joint_limit_violation(self, robot_id: WorldRobotID, q: NDArray[np.float64]) -> str | None: + robot = self._get_robot(robot_id) + nonfinite = ~np.isfinite(q) + if np.any(nonfinite): + return ", ".join( + f"{robot.config.joint_names[i]}={q[i]} is not finite" + for i in np.flatnonzero(nonfinite) + ) + outside = (q < robot.lower_limits) | (q > robot.upper_limits) + if not np.any(outside): + return None + return ", ".join( + f"{robot.config.joint_names[i]}={q[i]:.6f} outside " + f"[{robot.lower_limits[i]:.6f}, {robot.upper_limits[i]:.6f}]" + for i in np.flatnonzero(outside) + ) + def _require_finalized(self) -> None: if not self._finalized: raise RuntimeError("World must be finalized first") diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index bc12bfe994..cdaa15004e 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -31,8 +31,8 @@ from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor from dimos.manipulation.planning.spec.config import RobotModelConfig -from dimos.manipulation.planning.spec.enums import IKStatus -from dimos.manipulation.planning.spec.models import IKResult, PlanningSceneInfo +from dimos.manipulation.planning.spec.enums import IKStatus, PlanningStatus +from dimos.manipulation.planning.spec.models import IKResult, PlanningResult, PlanningSceneInfo from dimos.manipulation.planning.spec.protocols import VisualizationSpec from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -100,6 +100,7 @@ def __init__(self) -> None: self._lock = threading.Lock() self._error_message = "" self._planning_epoch = 0 + self._last_limit_projection_log = {} self._robots = {} self._planned_paths = {} self._planned_trajectories = {} @@ -308,6 +309,10 @@ def test_solve_ik_rpc_calls_configured_backend(self, robot_config): module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} module._world_monitor = MagicMock() module._world_monitor.world = MagicMock() + module._world_monitor.get_joint_limits.return_value = ( + [-1.0, -1.0, -1.0], + [1.0, 1.0, 1.0], + ) current = JointState(name=robot_config.joint_names, position=[0.0, 0.0, 0.0]) module._world_monitor.get_current_joint_state.return_value = current expected = IKResult( @@ -358,6 +363,10 @@ def test_solve_ik_rpc_uses_explicit_seed(self, robot_config): module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} module._world_monitor = MagicMock() module._world_monitor.world = MagicMock() + module._world_monitor.get_joint_limits.return_value = ( + [-1.0, -1.0, -1.0], + [1.0, 1.0, 1.0], + ) module._world_monitor.get_current_joint_state.return_value = JointState( name=robot_config.joint_names, position=[0.0, 0.0, 0.0] ) @@ -374,6 +383,146 @@ def test_solve_ik_rpc_uses_explicit_seed(self, robot_config): assert kwargs["seed"] is explicit_seed module._world_monitor.get_current_joint_state.assert_not_called() + def test_solve_ik_projects_minor_measured_limit_overshoot(self, robot_config): + module = _make_module() + robot_config.measured_state_limit_tolerance = 0.01 + module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} + module._world_monitor = MagicMock() + module._world_monitor.world = MagicMock() + module._world_monitor.get_joint_limits.return_value = ( + [-1.0, -1.0, -1.0], + [1.0, 1.0, 0.0], + ) + measured = JointState( + name=["joint3", "joint1", "joint2"], + position=[0.003, 0.2, 0.3], + ) + module._world_monitor.get_current_joint_state.return_value = measured + module._kinematics = MagicMock() + module._kinematics.solve.return_value = IKResult( + status=IKStatus.SUCCESS, + joint_state=JointState(name=robot_config.joint_names, position=[0.1, 0.1, -0.1]), + ) + + result = module.solve_ik(Pose(position=Vector3(), orientation=Quaternion())) + + assert result.status == IKStatus.SUCCESS + planning_seed = module._kinematics.solve.call_args.kwargs["seed"] + assert planning_seed.name == ["joint3", "joint1", "joint2"] + assert planning_seed.position == [0.0, 0.2, 0.3] + assert measured.position == [0.003, 0.2, 0.3] + + def test_viser_pose_evaluation_projects_minor_measured_limit_overshoot(self, robot_config): + module = _make_module() + robot_config.measured_state_limit_tolerance = 0.01 + module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} + module._world_monitor = MagicMock() + module._world_monitor.world = MagicMock() + module._world_monitor.get_joint_limits.return_value = ( + [-1.0, -1.0, -1.0], + [1.0, 1.0, 0.0], + ) + measured = JointState( + name=robot_config.joint_names, + position=[0.0, 0.0, 0.003], + ) + module._world_monitor.get_current_joint_state.return_value = measured + module._world_monitor.is_state_valid.return_value = True + module._kinematics = MagicMock() + solution = JointState( + name=robot_config.joint_names, + position=[0.1, 0.1, -0.1], + ) + module._kinematics.solve.return_value = IKResult( + status=IKStatus.SUCCESS, + joint_state=solution, + ) + + result = module.evaluate_pose_target( + Pose(position=Vector3(), orientation=Quaternion()), "test_arm" + ) + + assert result["success"] is True + assert result["joint_state"] == solution + planning_seed = module._kinematics.solve.call_args.kwargs["seed"] + assert planning_seed.position == [0.0, 0.0, 0.0] + assert measured.position == [0.0, 0.0, 0.003] + + def test_solve_ik_rejects_measured_limit_violation_beyond_tolerance(self, robot_config): + module = _make_module() + robot_config.measured_state_limit_tolerance = 0.01 + module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} + module._world_monitor = MagicMock() + module._world_monitor.world = MagicMock() + module._world_monitor.get_joint_limits.return_value = ( + [-1.0, -1.0, -1.0], + [1.0, 1.0, 0.0], + ) + module._world_monitor.get_current_joint_state.return_value = JointState( + name=robot_config.joint_names, + position=[0.0, 0.0, 0.02], + ) + module._kinematics = MagicMock() + + result = module.solve_ik(Pose(position=Vector3(), orientation=Quaternion())) + + assert result.status == IKStatus.JOINT_LIMITS + assert "joint3=0.020000" in result.message + assert "by 0.020000 rad" in result.message + module._kinematics.solve.assert_not_called() + + def test_plan_to_joints_projects_minor_measured_start_overshoot(self, robot_config): + module = _make_module() + robot_config.measured_state_limit_tolerance = 0.01 + module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} + module._world_monitor = MagicMock() + module._world_monitor.get_joint_limits.return_value = ( + [-1.0, -1.0, -1.0], + [1.0, 1.0, 0.0], + ) + measured = JointState( + name=robot_config.joint_names, + position=[0.0, 0.0, 0.003], + ) + module._world_monitor.get_current_joint_state.return_value = measured + module._planner = MagicMock() + module._planner.plan_joint_path.return_value = PlanningResult( + status=PlanningStatus.NO_SOLUTION, + message="stop after validating inputs", + ) + goal = JointState(name=robot_config.joint_names, position=[0.1, 0.1, -0.1]) + + assert not module.plan_to_joints(goal) + + planning_start = module._planner.plan_joint_path.call_args.kwargs["start"] + assert planning_start.position == [0.0, 0.0, 0.0] + assert measured.position == [0.0, 0.0, 0.003] + + def test_plan_to_joints_preserves_planner_failure_details(self, robot_config): + module = _make_module() + module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} + module._world_monitor = MagicMock() + module._world_monitor.get_joint_limits.return_value = ( + [-1.0, -1.0, -1.0], + [1.0, 1.0, 1.0], + ) + module._world_monitor.get_current_joint_state.return_value = JointState( + name=robot_config.joint_names, + position=[0.0, 0.0, 0.0], + ) + module._planner = MagicMock() + module._planner.plan_joint_path.return_value = PlanningResult( + status=PlanningStatus.INVALID_GOAL, + message="arm_joint3 is outside its upper limit", + ) + + goal = JointState(name=robot_config.joint_names, position=[0.1, 0.1, 0.1]) + assert not module.plan_to_joints(goal) + assert ( + module.get_error() + == "Planning failed: INVALID_GOAL: arm_joint3 is outside its upper limit" + ) + class TestJointNameTranslation: """Test trajectory joint name translation for coordinator.""" diff --git a/dimos/manipulation/test_roboplan_world.py b/dimos/manipulation/test_roboplan_world.py index 68ad241067..677cbf6012 100644 --- a/dimos/manipulation/test_roboplan_world.py +++ b/dimos/manipulation/test_roboplan_world.py @@ -480,6 +480,55 @@ def test_native_planner_names_path_from_robot_config_when_start_is_unnamed( assert [state.name for state in result.path] == [["joint1", "joint2"]] * 3 +def test_native_planner_returns_invalid_start_for_out_of_limit_configuration( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + + start = JointState(name=["joint1", "joint2"], position=[1.01, 0.0]) + goal = JointState(name=["joint1", "joint2"], position=[0.4, 0.2]) + result = world.plan_joint_path(world, robot_id, start, goal, timeout=1.0) + + assert result.status == PlanningStatus.INVALID_START + assert "joint1=1.010000 outside [-1.000000, 1.000000]" in result.message + + +def test_native_planner_returns_invalid_goal_for_out_of_limit_configuration( + fake_roboplan: None, robot_config: RobotModelConfig +) -> None: + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + + start = JointState(name=["joint1", "joint2"], position=[0.0, 0.0]) + goal = JointState(name=["joint1", "joint2"], position=[0.4, 2.01]) + result = world.plan_joint_path(world, robot_id, start, goal, timeout=1.0) + + assert result.status == PlanningStatus.INVALID_GOAL + assert "joint2=2.010000 outside [-2.000000, 2.000000]" in result.message + + +def test_native_planner_maps_known_backend_invalid_start_error( + fake_roboplan: None, robot_config: RobotModelConfig, monkeypatch: pytest.MonkeyPatch +) -> None: + class InvalidStartRRT(FakeRRT): + def plan( + self, q_start: FakeJointConfiguration, q_goal: FakeJointConfiguration + ) -> FakeJointPath: + raise RuntimeError("Invalid start configuration requested, cannot plan!") + + monkeypatch.setattr(sys.modules["roboplan.rrt"], "RRT", InvalidStartRRT) + world, robot_id = _make_world(fake_roboplan, robot_config) + world.finalize() + + start = JointState(name=["joint1", "joint2"], position=[0.0, 0.0]) + goal = JointState(name=["joint1", "joint2"], position=[0.4, 0.2]) + result = world.plan_joint_path(world, robot_id, start, goal, timeout=1.0) + + assert result.status == PlanningStatus.INVALID_START + assert "Invalid start configuration requested" in result.message + + def test_native_planner_rejects_empty_path( fake_roboplan: None, robot_config: RobotModelConfig, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/dimos/robot/manipulators/a1z/config.py b/dimos/robot/manipulators/a1z/config.py index 355c8ee28f..41cec6ef3d 100644 --- a/dimos/robot/manipulators/a1z/config.py +++ b/dimos/robot/manipulators/a1z/config.py @@ -28,6 +28,7 @@ from dimos.utils.data import LfsPath A1Z_DOF = 6 +A1Z_MEASURED_STATE_LIMIT_TOLERANCE = 0.01 A1Z_COLLISION_EXCLUSIONS: list[tuple[str, str]] = [ ("arm_link2", "arm_link5"), @@ -71,6 +72,7 @@ def make_a1z_model_config( name: str = "arm", *, has_gripper: bool = True, + enable_gripper_control: bool = True, joint_prefix: str | None = None, coordinator_task_name: str | None = None, home_joints: list[float] | None = None, @@ -92,6 +94,7 @@ def make_a1z_model_config( urdf_joint_prefix="arm_", ), coordinator_task_name=coordinator_task_name or f"traj_{name}", - gripper_hardware_id=name if has_gripper else None, + gripper_hardware_id=name if has_gripper and enable_gripper_control else None, home_joints=home_joints or [0.0] * A1Z_DOF, + measured_state_limit_tolerance=A1Z_MEASURED_STATE_LIMIT_TOLERANCE, ) diff --git a/dimos/robot/manipulators/a1z/test_config.py b/dimos/robot/manipulators/a1z/test_config.py new file mode 100644 index 0000000000..9c67fab342 --- /dev/null +++ b/dimos/robot/manipulators/a1z/test_config.py @@ -0,0 +1,36 @@ +# Copyright 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. + +"""Tests for the Galaxea A1Z planning model configuration.""" + +from dimos.robot.manipulators.a1z.config import ( + A1Z_G1Z_MODEL_PATH, + A1Z_MEASURED_STATE_LIMIT_TOLERANCE, + make_a1z_model_config, +) + + +def test_a1z_model_allows_bounded_home_encoder_overshoot() -> None: + config = make_a1z_model_config(has_gripper=False) + + assert A1Z_MEASURED_STATE_LIMIT_TOLERANCE == 0.01 + assert config.measured_state_limit_tolerance == A1Z_MEASURED_STATE_LIMIT_TOLERANCE + + +def test_a1z_model_can_include_gripper_without_enabling_gripper_control() -> None: + config = make_a1z_model_config(has_gripper=True, enable_gripper_control=False) + + assert config.model_path == A1Z_G1Z_MODEL_PATH + assert config.end_effector_link == "gripper_eef_link" + assert config.gripper_hardware_id is None diff --git a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py index 91cf24e7e2..4c7587be5e 100644 --- a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py +++ b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py @@ -22,6 +22,7 @@ from dimos.robot.manipulators.a1z.config import ( A1Z_DOF, A1Z_FK_MODEL, + A1Z_G1Z_MODEL_PATH, make_a1z_model_config, ) from dimos.robot.manipulators.common.blueprints import ( @@ -33,12 +34,10 @@ from dimos.robot.manipulators.galaxea_a1z.config import galaxea_a1z_hardware from dimos.teleop.keyboard.keyboard_teleop_module import KeyboardTeleopModule -# Arm-only stable configuration: the a1z SDK 'gripper' branch ships a G1Z -# gravity model that mismatches this unit's mounting (pushes the arm during -# zero-force startup; soft e-stop cannot catch the arm with comp disabled). -# Until the model/mount is calibrated, run the SDK *main* branch with -# gripper=False. Gripper code support remains in the adapter. -_a1z_hw = galaxea_a1z_hardware("arm", gripper=False) +# Gripper commands remain disabled, but the physical distal G1Z mass must be +# present in the SDK dynamics model or joints 2-4 sag under the missing load. +_A1Z_DYNAMICS_URDF = str(A1Z_G1Z_MODEL_PATH) +_a1z_hw = galaxea_a1z_hardware("arm", gripper=False, dynamics_urdf_path=_A1Z_DYNAMICS_URDF) coordinator_galaxea_a1z = autoconnect( ControlCoordinator.blueprint( @@ -54,12 +53,21 @@ ), ) -# Planner (ManipulationModule) on real hardware. Arm-only model (A1Z_Flange) -# to stay consistent with the gripper=False hardware configuration above. -_planner_hw = galaxea_a1z_hardware("arm", gripper=False) +# Planner and Viser use the physical G1Z model, including its fixed gripper +# geometry and TCP. Gripper commands stay disabled until the vendor SDK supports +# that hardware branch. +_planner_hw = galaxea_a1z_hardware("arm", gripper=False, dynamics_urdf_path=_A1Z_DYNAMICS_URDF) galaxea_a1z_planner_coordinator = autoconnect( - planner(robots=[make_a1z_model_config(name="arm", has_gripper=False)]), + planner( + robots=[ + make_a1z_model_config( + name="arm", + has_gripper=True, + enable_gripper_control=False, + ) + ] + ), coordinator( hardware=[_planner_hw], tasks=[trajectory_task(_planner_hw)], @@ -67,7 +75,7 @@ ) # Keyboard teleop on real hardware (eef twist task from the FK model). -_teleop_hw = galaxea_a1z_hardware("arm", gripper=False) +_teleop_hw = galaxea_a1z_hardware("arm", gripper=False, dynamics_urdf_path=_A1Z_DYNAMICS_URDF) keyboard_teleop_galaxea_a1z = autoconnect( KeyboardTeleopModule.blueprint(), @@ -76,7 +84,13 @@ tasks=[eef_twist_task(_teleop_hw, model_path=A1Z_FK_MODEL, ee_joint_id=A1Z_DOF)], ), ManipulationModule.blueprint( - robots=[make_a1z_model_config(name="arm", has_gripper=False)], + robots=[ + make_a1z_model_config( + name="arm", + has_gripper=True, + enable_gripper_control=False, + ) + ], visualization={"backend": "viser"}, ), ) diff --git a/dimos/robot/manipulators/galaxea_a1z/config.py b/dimos/robot/manipulators/galaxea_a1z/config.py index b041cb86f3..d18e9e7a93 100644 --- a/dimos/robot/manipulators/galaxea_a1z/config.py +++ b/dimos/robot/manipulators/galaxea_a1z/config.py @@ -50,6 +50,7 @@ def galaxea_a1z_hardware( *, gripper: bool = True, mock_without_address: bool = False, + dynamics_urdf_path: str | None = None, ) -> HardwareComponent: if global_config.simulation: # TODO: Add sim support when A1Z MuJoCo model is available @@ -62,4 +63,5 @@ def galaxea_a1z_hardware( adapter_type="galaxea_a1z", address=address, gripper=gripper, + adapter_kwargs={"urdf_path": dynamics_urdf_path} if dynamics_urdf_path else None, ) From ec2e041ebc043d132d250619dda73908023ca01b Mon Sep 17 00:00:00 2001 From: Pim Van den Bosch Date: Sun, 19 Jul 2026 15:28:02 +0800 Subject: [PATCH 08/51] fix(galaxea_a1z): harden hardware setup and remove teleop --- .../manipulators/galaxea_a1z/adapter.py | 52 ++-- .../manipulators/galaxea_a1z/test_adapter.py | 11 + dimos/robot/all_blueprints.py | 2 - .../manipulators/a1z/blueprints/teleop.py | 43 --- .../robot/manipulators/galaxea_a1z/README.md | 36 +++ .../galaxea_a1z/blueprints/basic.py | 42 +-- .../robot/manipulators/galaxea_a1z/config.py | 2 +- .../galaxea_a1z/scripts/setup_a1z_can.sh | 266 ++++++++++++++++++ dimos/robot/manipulators/test_blueprints.py | 2 - 9 files changed, 344 insertions(+), 112 deletions(-) delete mode 100644 dimos/robot/manipulators/a1z/blueprints/teleop.py create mode 100644 dimos/robot/manipulators/galaxea_a1z/README.md create mode 100755 dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z_can.sh diff --git a/dimos/hardware/manipulators/galaxea_a1z/adapter.py b/dimos/hardware/manipulators/galaxea_a1z/adapter.py index 0d4d2121af..fea329f469 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/adapter.py @@ -534,16 +534,34 @@ def write_cartesian_position( def read_gripper_position(self) -> float | None: """Read gripper opening (meters). None if no gripper attached. - Converts the SDK's normalized position (0.0=closed, 1.0=open). - Requires the adapter constructed with gripper=True and the SDK's - 'gripper' branch. + Prefers the SDK gripper's motor-feedback position, then falls back to + its commanded position for compatibility. Converts the normalized + value (0.0=closed, 1.0=open) to meters. Requires the adapter constructed + with gripper=True and the SDK's 'gripper' branch. """ if not self._robot or not self._gripper: return None + + fraction: float | None = None try: - fraction = self._robot.get_gripper_pos() + gripper_state = self._robot.get_joint_state().get("gripper_pos") + if gripper_state is not None: + fraction = float(np.asarray(gripper_state).reshape(-1)[0]) except Exception: - return None + pass + if fraction is None: + sdk_gripper = getattr(self._robot, "gripper", None) + feedback_reader = getattr(sdk_gripper, "get_feedback_norm", None) + if callable(feedback_reader): + try: + fraction = feedback_reader() + except Exception: + pass + if fraction is None: + try: + fraction = self._robot.get_gripper_pos() + except Exception: + return None if fraction is None: return None return float(fraction) * self._gripper_max_opening_m @@ -566,30 +584,6 @@ def read_force_torque(self) -> list[float] | None: # --- A1Z-specific extensions (beyond ManipulatorAdapter protocol) --- - def start_teach_recording(self, sample_hz: int = 50) -> None: - """Start recording joint positions for teach-and-play. - - Requires the adapter constructed with zero_gravity=True so the arm - can be hand-guided. - """ - self._require_robot().start_recording(sample_hz=sample_hz) - - def stop_teach_recording(self) -> list[tuple[float, np.ndarray]]: - """Stop recording and return the trajectory as (timestamp_s, pos_rad) tuples.""" - return self._require_robot().stop_recording() - - def play_trajectory(self, trajectory: list, speed_factor: float = 1.0) -> None: - """Replay a recorded trajectory (blocking).""" - self._require_robot().play_trajectory(trajectory, speed_factor=speed_factor) - - def save_recording(self, trajectory: list, path: str) -> None: - """Save a recorded trajectory to a JSON file.""" - self._require_robot().save_recording(trajectory, path) - - def load_recording(self, path: str) -> list: - """Load a recorded trajectory from a JSON file.""" - return self._require_robot().load_recording(path) - def set_gripper_free_drive(self, enabled: bool) -> bool: """Toggle gripper free-drive (zero-torque) mode for hand teaching. diff --git a/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py index b5c8a2dda9..b5c1e56481 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py @@ -529,6 +529,17 @@ def test_gripper_round_trips_meters_to_normalized( assert robot.gripper_fraction == pytest.approx(1.0) +def test_gripper_read_prefers_motor_feedback( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter(a1z_adapter_module, gripper=True, gripper_max_opening_m=0.1) + robot = _FakeArmRobot.instances[-1] + robot.gripper_fraction = 0.8 + robot.state["gripper_pos"] = np.array([0.35]) + + assert adapter.read_gripper_position() == pytest.approx(0.035) + + def test_gripper_disabled_signals_unsupported( a1z_adapter_module: ModuleType, ) -> None: diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index e9da749ea3..b70a7f7f6f 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -64,9 +64,7 @@ "drone-basic": "dimos.robot.drone.blueprints.basic.drone_basic:drone_basic", "dual-xarm6-planner": "dimos.robot.manipulators.xarm.blueprints.basic:dual_xarm6_planner", "galaxea-a1z-planner-coordinator": "dimos.robot.manipulators.galaxea_a1z.blueprints.basic:galaxea_a1z_planner_coordinator", - "keyboard-teleop-a1z": "dimos.robot.manipulators.a1z.blueprints.teleop:keyboard_teleop_a1z", "keyboard-teleop-a750": "dimos.robot.manipulators.a750.blueprints.teleop:keyboard_teleop_a750", - "keyboard-teleop-galaxea-a1z": "dimos.robot.manipulators.galaxea_a1z.blueprints.basic:keyboard_teleop_galaxea_a1z", "keyboard-teleop-openarm": "dimos.robot.manipulators.openarm.blueprints.teleop:keyboard_teleop_openarm", "keyboard-teleop-openarm-mock": "dimos.robot.manipulators.openarm.blueprints.teleop:keyboard_teleop_openarm_mock", "keyboard-teleop-piper": "dimos.robot.manipulators.piper.blueprints.teleop:keyboard_teleop_piper", diff --git a/dimos/robot/manipulators/a1z/blueprints/teleop.py b/dimos/robot/manipulators/a1z/blueprints/teleop.py deleted file mode 100644 index f869b637e2..0000000000 --- a/dimos/robot/manipulators/a1z/blueprints/teleop.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright 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. - -"""Galaxea A1Z teleop blueprints.""" - -from __future__ import annotations - -from dimos.control.coordinator import ControlCoordinator -from dimos.core.coordination.blueprints import autoconnect -from dimos.manipulation.manipulation_module import ManipulationModule -from dimos.robot.manipulators.a1z.config import ( - A1Z_DOF, - A1Z_FK_MODEL, - make_a1z_hardware, - make_a1z_model_config, -) -from dimos.robot.manipulators.common.blueprints import eef_twist_task -from dimos.teleop.keyboard.keyboard_teleop_module import KeyboardTeleopModule - -_a1z_keyboard_hw = make_a1z_hardware("arm") - -keyboard_teleop_a1z = autoconnect( - KeyboardTeleopModule.blueprint(), - ControlCoordinator.blueprint( - hardware=[_a1z_keyboard_hw], - tasks=[eef_twist_task(_a1z_keyboard_hw, model_path=A1Z_FK_MODEL, ee_joint_id=A1Z_DOF)], - ), - ManipulationModule.blueprint( - robots=[make_a1z_model_config()], - visualization={"backend": "viser"}, - ), -) diff --git a/dimos/robot/manipulators/galaxea_a1z/README.md b/dimos/robot/manipulators/galaxea_a1z/README.md new file mode 100644 index 0000000000..c277eea213 --- /dev/null +++ b/dimos/robot/manipulators/galaxea_a1z/README.md @@ -0,0 +1,36 @@ +# Galaxea A1Z + G1Z + +The A1Z integration uses native Linux SocketCAN, the vendor's 250 Hz MIT +position-control loop, the G1Z URDF for gravity compensation and visualization, +and the vendor G1Z gripper implementation. + +## Vendor SDK + +The G1Z requires the vendor SDK's `gripper` branch; vendor `main` does not +accept `with_gripper` and cannot actuate CAN motor 7. + +```bash +git clone --branch gripper https://github.com/userguide-galaxea/GALAXEA-A1Z.git +uv pip install -e ./GALAXEA-A1Z +``` + +The Jetson checkout currently used by DimOS is `/home/dimos/GALAXEA-A1Z` and +must remain on that branch. DimOS deliberately has no Linux userspace-CAN +fallback. After boot or reconnecting the HHS adapter, bind it to the kernel +driver, configure the stable `a1zcan` SocketCAN interface, and verify that the +driver can actually transmit: + +```bash +sudo ./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z_can.sh +``` + +Do not start DimOS unless the script prints `A1Z CAN setup passed`. Galaxea's +HHS USB-CANFD adapter is incompatible with `gs_usb` in some Linux kernels. An +affected kernel still creates a normal-looking, UP CAN interface but drops +every transmission. The setup script detects that misleading state and prints +the supported-kernel and exact-kernel patch options. Galaxea recommends kernel +6.8.0-124 or newer; Jetsons and other pinned-kernel hosts require a persistent +driver patch built for their exact kernel. + +The A1Z has no brakes. Support the arm and keep the workspace clear before +starting a hardware blueprint. Enabling the G1Z also initializes the gripper. diff --git a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py index 4c7587be5e..d3513c81f8 100644 --- a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py +++ b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py @@ -12,32 +12,28 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Basic Galaxea A1Z coordinator, planner, and teleop blueprints.""" +"""Basic Galaxea A1Z coordinator and planner blueprints.""" from __future__ import annotations from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.core.coordination.blueprints import autoconnect -from dimos.manipulation.manipulation_module import ManipulationModule from dimos.robot.manipulators.a1z.config import ( - A1Z_DOF, - A1Z_FK_MODEL, A1Z_G1Z_MODEL_PATH, make_a1z_model_config, ) from dimos.robot.manipulators.common.blueprints import ( coordinator, - eef_twist_task, planner, trajectory_task, ) from dimos.robot.manipulators.galaxea_a1z.config import galaxea_a1z_hardware -from dimos.teleop.keyboard.keyboard_teleop_module import KeyboardTeleopModule -# Gripper commands remain disabled, but the physical distal G1Z mass must be -# present in the SDK dynamics model or joints 2-4 sag under the missing load. +# The real arm has a G1Z gripper. Its mass must be present in the dynamics +# model, and the hardware component exposes its measured/commanded opening as +# arm/gripper alongside the six arm joints. _A1Z_DYNAMICS_URDF = str(A1Z_G1Z_MODEL_PATH) -_a1z_hw = galaxea_a1z_hardware("arm", gripper=False, dynamics_urdf_path=_A1Z_DYNAMICS_URDF) +_a1z_hw = galaxea_a1z_hardware("arm", gripper=True, dynamics_urdf_path=_A1Z_DYNAMICS_URDF) coordinator_galaxea_a1z = autoconnect( ControlCoordinator.blueprint( @@ -53,10 +49,8 @@ ), ) -# Planner and Viser use the physical G1Z model, including its fixed gripper -# geometry and TCP. Gripper commands stay disabled until the vendor SDK supports -# that hardware branch. -_planner_hw = galaxea_a1z_hardware("arm", gripper=False, dynamics_urdf_path=_A1Z_DYNAMICS_URDF) +# Planner and Viser use the same physical G1Z model and tool-center frame. +_planner_hw = galaxea_a1z_hardware("arm", gripper=True, dynamics_urdf_path=_A1Z_DYNAMICS_URDF) galaxea_a1z_planner_coordinator = autoconnect( planner( @@ -64,7 +58,6 @@ make_a1z_model_config( name="arm", has_gripper=True, - enable_gripper_control=False, ) ] ), @@ -73,24 +66,3 @@ tasks=[trajectory_task(_planner_hw)], ), ) - -# Keyboard teleop on real hardware (eef twist task from the FK model). -_teleop_hw = galaxea_a1z_hardware("arm", gripper=False, dynamics_urdf_path=_A1Z_DYNAMICS_URDF) - -keyboard_teleop_galaxea_a1z = autoconnect( - KeyboardTeleopModule.blueprint(), - ControlCoordinator.blueprint( - hardware=[_teleop_hw], - tasks=[eef_twist_task(_teleop_hw, model_path=A1Z_FK_MODEL, ee_joint_id=A1Z_DOF)], - ), - ManipulationModule.blueprint( - robots=[ - make_a1z_model_config( - name="arm", - has_gripper=True, - enable_gripper_control=False, - ) - ], - visualization={"backend": "viser"}, - ), -) diff --git a/dimos/robot/manipulators/galaxea_a1z/config.py b/dimos/robot/manipulators/galaxea_a1z/config.py index d18e9e7a93..1eb128bc9d 100644 --- a/dimos/robot/manipulators/galaxea_a1z/config.py +++ b/dimos/robot/manipulators/galaxea_a1z/config.py @@ -55,7 +55,7 @@ def galaxea_a1z_hardware( if global_config.simulation: # TODO: Add sim support when A1Z MuJoCo model is available return make_galaxea_a1z_hardware(hw_id, gripper=gripper) - address = global_config.can_port or "can0" + address = global_config.can_port or "a1zcan" if mock_without_address and not global_config.can_port: return make_galaxea_a1z_hardware(hw_id, gripper=gripper) return make_galaxea_a1z_hardware( diff --git a/dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z_can.sh b/dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z_can.sh new file mode 100755 index 0000000000..ebecb11e0d --- /dev/null +++ b/dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z_can.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# Bind the Galaxea HHS USB-CANFD adapter to Linux SocketCAN and give it a +# stable name. Run after boot or after connecting the adapter. +set -euo pipefail + +USB_VENDOR_ID="a8fa" +USB_PRODUCT_ID="8598" +CAN_INTERFACE="a1zcan" +CAN_BITRATE="1000000" +# An empty frame on the maximum extended CAN ID cannot match the A1Z's +# standard-ID motor commands, but it still exercises the USB transmit path. +CAN_PROBE_FRAME="1FFFFFFF#" +CAN_PROBE_ATTEMPTS="20" +CAN_PROBE_POLL_SECONDS="0.05" + +read_can_counter() { + local counter="$1" + cat "/sys/class/net/$CAN_INTERFACE/statistics/$counter" +} + +usb_bulk_out_endpoint() { + local endpoint + + for endpoint in "$usb_device":*/ep_*; do + [[ -r "$endpoint/direction" && -r "$endpoint/type" ]] || continue + [[ "$(<"$endpoint/direction")" == "out" ]] || continue + [[ "$(<"$endpoint/type")" == "Bulk" ]] || continue + printf '0x%s' "$(<"$endpoint/bEndpointAddress")" + return + done + printf 'unknown' +} + +print_driver_compatibility_error() { + local tx_packets_before="$1" + local tx_packets_after="$2" + local tx_dropped_before="$3" + local tx_dropped_after="$4" + local send_error="$5" + local host_os="unknown Linux distribution" + local driver_module="unknown" + local vendor_patch_url="https://galaxea-ai.feishu.cn/docx/XF2ed4pmhoervNxODlfc11Gvnbb" + local upstream_fix_url="https://github.com/torvalds/linux/commit/889b2ae9139a87b3390f7003cb1bb3d65bf90a26" + + if [[ -r /etc/os-release ]]; then + host_os="$(. /etc/os-release; printf '%s' "${PRETTY_NAME:-unknown Linux distribution}")" + fi + if [[ -e "/sys/class/net/$CAN_INTERFACE/device/driver/module" ]]; then + driver_module="$(readlink -f "/sys/class/net/$CAN_INTERFACE/device/driver/module")" + fi + + cat >&2 < $tx_packets_after + TX packets dropped: $tx_dropped_before -> $tx_dropped_after + Test-send error: ${send_error:-none reported} + +What this means +--------------- +Galaxea supplies this arm with an HHS USB-CANFD adapter whose Linux support is +poor. The adapter is not compatible with the 'gs_usb' driver shipped in some +Linux kernels. In the common failure, the adapter transmits on USB endpoint +0x01 while the old driver incorrectly hard-codes endpoint 0x02. + +This is especially confusing because the bad driver still creates a normal- +looking CAN interface. 'ip link' therefore says the interface is UP, while +every command sent to the robot is silently dropped. This is a known Galaxea / +HHS driver compatibility defect, not a DimOS control, URDF, or CAN-port error. + +How to fix it +------------- +Choose ONE of the following options, then reboot and run this setup script +again. Do not continue until this script prints "A1Z CAN setup passed". + +OPTION A — ordinary x86-64 Ubuntu computer (recommended) + + Upgrade to a distribution kernel that includes the corrected gs_usb driver. + Galaxea currently recommends Linux kernel 6.8.0-124 or newer. + + 1. Record the current kernel: + uname -r + 2. Install all normal OS/kernel updates using the distribution's updater. + 3. Reboot. + 4. Confirm the new kernel is running: + uname -r + 5. Run this script again: + sudo ./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z_can.sh + + If the reported kernel is still older than 6.8.0-124, ask the event organizer + for the supported Ubuntu kernel package instead of guessing at kernel + packages during the hackathon. + +OPTION B — NVIDIA Jetson, a pinned kernel, or a machine that cannot be upgraded + + Do NOT install a generic desktop Ubuntu kernel on a Jetson. The gs_usb module + must instead be patched for this machine's exact running kernel. Kernel + modules are kernel- and architecture-specific: never copy a random gs_usb.ko + from another computer. + + Galaxea's kernel patch guide: + $vendor_patch_url + + The upstream Linux endpoint-discovery fix: + $upstream_fix_url + + The patched module must be installed persistently under /lib/modules/\$(uname + -r), followed by 'sudo depmod -a' and a reboot. Merely running 'insmod' on a + file under /tmp is temporary and will stop working after the next reboot. + + If you are not comfortable building a kernel module, give this entire error + report to the event organizer. This is a host-driver installation task; it + cannot be repaired with a DimOS configuration override. + +After applying either option +---------------------------- +Run only this command first: + + sudo ./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z_can.sh + +When it prints "A1Z CAN setup passed", the stable interface is '$CAN_INTERFACE' +and DimOS can be started without --can-port. +================================================================================ +EOF +} + +verify_can_transmit() { + local tx_packets_before + local tx_packets_after + local tx_dropped_before + local tx_dropped_after + local send_error="" + local attempt + + if ! command -v cansend >/dev/null 2>&1; then + cat >&2 <<'EOF' +ERROR: The CAN health check requires the 'cansend' command. + +Install the standard Linux CAN utilities, then run this script again: + + Ubuntu / Debian: sudo apt install can-utils + Fedora: sudo dnf install can-utils + Arch Linux: sudo pacman -S can-utils + +The setup script intentionally stops here because seeing an UP interface is not +enough to prove that the Galaxea adapter can actually transmit. +EOF + exit 1 + fi + + tx_packets_before="$(read_can_counter tx_packets)" + tx_dropped_before="$(read_can_counter tx_dropped)" + if ! send_error="$(cansend "$CAN_INTERFACE" "$CAN_PROBE_FRAME" 2>&1)"; then + send_error="${send_error:-cansend exited unsuccessfully without an error message}" + fi + for ((attempt = 0; attempt < CAN_PROBE_ATTEMPTS; attempt++)); do + tx_packets_after="$(read_can_counter tx_packets)" + tx_dropped_after="$(read_can_counter tx_dropped)" + if ((tx_packets_after > tx_packets_before || tx_dropped_after > tx_dropped_before)); then + break + fi + sleep "$CAN_PROBE_POLL_SECONDS" + done + + if ((tx_dropped_after > tx_dropped_before)); then + print_driver_compatibility_error \ + "$tx_packets_before" "$tx_packets_after" \ + "$tx_dropped_before" "$tx_dropped_after" "$send_error" + exit 2 + fi + + if ((tx_packets_after <= tx_packets_before)); then + cat >&2 < $tx_packets_after + TX packets dropped: $tx_dropped_before -> $tx_dropped_after + Test-send error: ${send_error:-none reported} + +Do not start DimOS until this setup script passes. +EOF + exit 3 + fi +} + +if ((EUID != 0)); then + echo "Run this script with sudo." >&2 + exit 1 +fi + +modprobe gs_usb + +usb_device="" +for device in /sys/bus/usb/devices/*; do + [[ -r "$device/idVendor" && -r "$device/idProduct" ]] || continue + [[ "$(<"$device/idVendor")" == "$USB_VENDOR_ID" ]] || continue + [[ "$(<"$device/idProduct")" == "$USB_PRODUCT_ID" ]] || continue + usb_device="$device" + break +done +[[ -n "$usb_device" ]] || { echo "HHS USB-CANFD adapter not found." >&2; exit 1; } + +find_can_interface() { + for interface in "$usb_device":*/net/*; do + [[ -e "$interface" ]] || continue + basename "$interface" + return + done +} + +can_interface="$(find_can_interface)" +if [[ -z "$can_interface" ]]; then + printf '%s %s\n' "$USB_VENDOR_ID" "$USB_PRODUCT_ID" \ + > /sys/bus/usb/drivers/gs_usb/new_id + udevadm settle --timeout=3 + for _ in {1..30}; do + can_interface="$(find_can_interface)" + [[ -n "$can_interface" ]] && break + sleep 0.1 + done +fi +[[ -n "$can_interface" ]] || { echo "gs_usb did not create a CAN interface." >&2; exit 1; } + +ip link set "$can_interface" down +if [[ "$can_interface" != "$CAN_INTERFACE" ]]; then + [[ ! -e "/sys/class/net/$CAN_INTERFACE" ]] || { echo "$CAN_INTERFACE already exists." >&2; exit 1; } + ip link set "$can_interface" name "$CAN_INTERFACE" +fi +ip link set "$CAN_INTERFACE" type can bitrate "$CAN_BITRATE" +ip link set "$CAN_INTERFACE" up +verify_can_transmit + +echo "A1Z CAN setup passed: '$CAN_INTERFACE' transmitted successfully at $CAN_BITRATE bit/s." +ip -details link show "$CAN_INTERFACE" diff --git a/dimos/robot/manipulators/test_blueprints.py b/dimos/robot/manipulators/test_blueprints.py index 3e7848beb1..9394c2c221 100644 --- a/dimos/robot/manipulators/test_blueprints.py +++ b/dimos/robot/manipulators/test_blueprints.py @@ -21,7 +21,6 @@ from dimos.core.coordination.blueprints import Blueprint from dimos.manipulation.manipulation_module import ManipulationModule, ManipulationModuleConfig from dimos.manipulation.visualization.config import NoManipulationVisualizationConfig -from dimos.robot.manipulators.a1z.blueprints.teleop import keyboard_teleop_a1z from dimos.robot.manipulators.a750.blueprints.teleop import keyboard_teleop_a750 from dimos.robot.manipulators.common.blueprints import eef_twist_task, planner from dimos.robot.manipulators.common.topics import EEF_TWIST_TASK_NAME @@ -105,7 +104,6 @@ def test_eef_twist_task_helper_uses_hardware_joints_and_default_name() -> None: pytest.param(keyboard_teleop_openarm_mock, id="openarm-mock"), pytest.param(keyboard_teleop_openarm, id="openarm"), pytest.param(keyboard_teleop_a750, id="a750"), - pytest.param(keyboard_teleop_a1z, id="a1z"), ], ) def test_manipulator_keyboard_blueprint_uses_eef_twist_and_light_keyboard_kwargs( From aa94234a75be583548cce4488b749cfe192b97df Mon Sep 17 00:00:00 2001 From: Pim Van den Bosch Date: Sun, 19 Jul 2026 17:43:02 +0800 Subject: [PATCH 09/51] feat: add A1Z teach and replay workflow --- .../manipulators/galaxea_a1z/adapter.py | 18 + .../manipulators/galaxea_a1z/test_adapter.py | 31 +- dimos/learning/collection/episode_monitor.py | 19 +- .../collection/test_episode_monitor.py | 18 + .../dataprep/galaxea_a1z_camera_config.json | 37 ++ .../dataprep/galaxea_a1z_state_config.json | 33 ++ .../learning/dataprep/test_config_profiles.py | 41 +++ dimos/memory2/module.py | 8 +- dimos/robot/cli/dimos.py | 2 + .../robot/manipulators/galaxea_a1z/config.py | 6 +- .../manipulators/galaxea_a1z/teach_replay.py | 343 ++++++++++++++++++ .../galaxea_a1z/teach_replay_blueprints.py | 79 ++++ .../galaxea_a1z/teach_replay_cli.py | 234 ++++++++++++ .../galaxea_a1z/test_teach_replay.py | 139 +++++++ 14 files changed, 1001 insertions(+), 7 deletions(-) create mode 100644 dimos/learning/dataprep/galaxea_a1z_camera_config.json create mode 100644 dimos/learning/dataprep/galaxea_a1z_state_config.json create mode 100644 dimos/learning/dataprep/test_config_profiles.py create mode 100644 dimos/robot/manipulators/galaxea_a1z/teach_replay.py create mode 100644 dimos/robot/manipulators/galaxea_a1z/teach_replay_blueprints.py create mode 100644 dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py create mode 100644 dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py diff --git a/dimos/hardware/manipulators/galaxea_a1z/adapter.py b/dimos/hardware/manipulators/galaxea_a1z/adapter.py index fea329f469..f7fb4b148d 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/adapter.py @@ -183,6 +183,7 @@ def __init__( control_freq_hz: int = 250, urdf_path: str | None = None, gripper: bool = False, + gripper_free_drive: bool = False, gripper_max_torque: float = 2.0, gripper_max_opening_m: float = _GRIPPER_MAX_OPENING_M, transport: str = "auto", @@ -200,6 +201,7 @@ def __init__( self._control_freq_hz = control_freq_hz self._urdf_path = urdf_path self._gripper = gripper + self._gripper_free_drive = gripper_free_drive self._gripper_max_torque = gripper_max_torque self._gripper_max_opening_m = gripper_max_opening_m if transport == "auto": @@ -459,14 +461,30 @@ def write_enable(self, enable: bool) -> bool: if self._robot.is_running: if self._robot.is_estopped: self._robot.release() + elif self._zero_gravity: + # The vendor's zero-gravity startup deliberately allows + # motion while gravity compensation takes over. The + # position-hold safe start below requires the arm to + # settle, so it is only valid for position-controlled + # operation (planning/replay), not hand teaching. + self._robot.start() elif self._safe_start_enabled: self._safe_start() else: # Vendor-stock startup; can snap to zero if a motor's # first feedback is late (see _safe_start docstring). self._robot.start() + if self._gripper_free_drive and not self.set_gripper_free_drive(True): + self._robot.stop() + self._ensure_motors_disabled() + raise RuntimeError( + "gripper free-drive requested, but the installed A1Z SDK does not " + "support set_gripper_free_drive()" + ) return True else: + if self._gripper_free_drive: + self.set_gripper_free_drive(False) if self._robot.is_running: self._robot.stop() self._ensure_motors_disabled() diff --git a/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py index b5c1e56481..796a588784 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py @@ -151,6 +151,11 @@ def get_gripper_pos(self) -> float | None: return None return getattr(self, "gripper_fraction", 0.0) + def set_gripper_free_drive(self, enabled: bool) -> None: + if not self.factory_kwargs.get("with_gripper"): + raise RuntimeError("No gripper attached") + self.actions.append(("set_gripper_free_drive", enabled)) + @pytest.fixture def a1z_adapter_module( @@ -277,7 +282,7 @@ def test_safe_start_rejects_sustained_motion_during_settling( assert robot.actions[-2:] == ["estop", "stop"] -def test_safe_start_preserves_zero_gain_teaching_mode( +def test_zero_gravity_uses_vendor_teaching_startup( a1z_adapter_module: ModuleType, ) -> None: adapter = _connected_adapter( @@ -290,11 +295,13 @@ def test_safe_start_preserves_zero_gain_teaching_mode( assert adapter.activate() start_call = next(a for a in robot.actions if isinstance(a, tuple) and a[0] == "start") - assert np.allclose(start_call[1], np.zeros(6)) - assert start_call[3] == 0.0 + assert start_call[1] is None + assert start_call[2] is None + assert start_call[3] == 0.7 assert not any( isinstance(action, tuple) and action[0] == "command_joint_state" for action in robot.actions ) + assert 0.0 not in robot.gravity_factor_history assert robot.gravity_comp_factor == 0.7 @@ -529,6 +536,24 @@ def test_gripper_round_trips_meters_to_normalized( assert robot.gripper_fraction == pytest.approx(1.0) +def test_configured_gripper_free_drive_tracks_adapter_lifecycle( + a1z_adapter_module: ModuleType, +) -> None: + adapter = _connected_adapter( + a1z_adapter_module, + gripper=True, + gripper_free_drive=True, + zero_gravity=True, + ) + robot = _FakeArmRobot.instances[-1] + + assert adapter.activate() + assert ("set_gripper_free_drive", True) in robot.actions + + assert adapter.deactivate() + assert ("set_gripper_free_drive", False) in robot.actions + + def test_gripper_read_prefers_motor_feedback( a1z_adapter_module: ModuleType, ) -> None: diff --git a/dimos/learning/collection/episode_monitor.py b/dimos/learning/collection/episode_monitor.py index ab35e74284..debbc9f178 100644 --- a/dimos/learning/collection/episode_monitor.py +++ b/dimos/learning/collection/episode_monitor.py @@ -110,6 +110,21 @@ def reset_counters(self) -> EpisodeStatus: status = self._snapshot("init", time.time()) return self._emit(status) + @rpc + def start_episode(self) -> EpisodeStatus: + """Start a new episode and return the published status.""" + return self._transition("start", time.time()) + + @rpc + def save_episode(self) -> EpisodeStatus: + """Save the active episode and return the published status.""" + return self._transition("save", time.time()) + + @rpc + def discard_episode(self) -> EpisodeStatus: + """Discard the active episode and return the published status.""" + return self._transition("discard", time.time()) + # ── port handlers ──────────────────────────────────────────────────────── def _on_buttons(self, msg: Buttons) -> None: @@ -139,7 +154,7 @@ def _on_keyboard(self, msg: KeyPress) -> None: self._transition(event_name, msg.ts) break - def _transition(self, event: EpisodeCommand, ts: float) -> None: + def _transition(self, event: EpisodeCommand, ts: float) -> EpisodeStatus: """State-machine transition. Publishes EpisodeStatus on every change. ``toggle`` resolves to ``start`` when idle and ``save`` when recording, @@ -164,7 +179,7 @@ def _transition(self, event: EpisodeCommand, ts: float) -> None: self._state = "idle" # Snapshot under the mutation's lock so the event matches the state. status = self._snapshot(event, ts) - self._emit(status) + return self._emit(status) def _snapshot(self, last_event: EpisodeEvent, ts: float) -> EpisodeStatus: """Build a status from current state. Caller must hold `self._lock`.""" diff --git a/dimos/learning/collection/test_episode_monitor.py b/dimos/learning/collection/test_episode_monitor.py index fbf45943e1..f65b4060b2 100644 --- a/dimos/learning/collection/test_episode_monitor.py +++ b/dimos/learning/collection/test_episode_monitor.py @@ -165,3 +165,21 @@ def test_reset_counters(make_monitor: Callable[..., EpisodeMonitorModule]) -> No assert status.episodes_discarded == 0 assert status.state == "idle" assert status.last_event == "init" + + +def test_explicit_episode_rpcs_drive_same_state_machine( + make_monitor: Callable[..., EpisodeMonitorModule], +) -> None: + m = make_monitor() + + started = m.start_episode() + saved = m.save_episode() + m.start_episode() + discarded = m.discard_episode() + + assert started.state == "recording" + assert saved.state == "idle" + assert saved.episodes_saved == 1 + assert discarded.state == "idle" + assert discarded.episodes_discarded == 1 + assert [e.last_event for e in _events(m)] == ["start", "save", "start", "discard"] diff --git a/dimos/learning/dataprep/galaxea_a1z_camera_config.json b/dimos/learning/dataprep/galaxea_a1z_camera_config.json new file mode 100644 index 0000000000..9d0ace90a9 --- /dev/null +++ b/dimos/learning/dataprep/galaxea_a1z_camera_config.json @@ -0,0 +1,37 @@ +{ + "source": "", + "episodes": { + "extractor": "episode_status", + "status_stream": "status" + }, + "observation": { + "image": { + "stream": "color_image", + "field": "data" + }, + "joint_state": { + "stream": "coordinator_joint_state", + "field": "position" + } + }, + "action": { + "joint_target": { + "stream": "coordinator_joint_state", + "field": "position" + } + }, + "sync": { + "anchor": "image", + "rate_hz": 30.0, + "tolerance_ms": 50.0, + "action_shift": 1 + }, + "output": { + "format": "lerobot", + "path": "data/datasets/galaxea_a1z_camera", + "metadata": { + "robot": "galaxea_a1z", + "default_task_label": "hand_teach" + } + } +} diff --git a/dimos/learning/dataprep/galaxea_a1z_state_config.json b/dimos/learning/dataprep/galaxea_a1z_state_config.json new file mode 100644 index 0000000000..5e2ac5c501 --- /dev/null +++ b/dimos/learning/dataprep/galaxea_a1z_state_config.json @@ -0,0 +1,33 @@ +{ + "source": "", + "episodes": { + "extractor": "episode_status", + "status_stream": "status" + }, + "observation": { + "joint_state": { + "stream": "coordinator_joint_state", + "field": "position" + } + }, + "action": { + "joint_target": { + "stream": "coordinator_joint_state", + "field": "position" + } + }, + "sync": { + "anchor": "joint_state", + "rate_hz": 50.0, + "tolerance_ms": 20.0, + "action_shift": 1 + }, + "output": { + "format": "lerobot", + "path": "data/datasets/galaxea_a1z", + "metadata": { + "robot": "galaxea_a1z", + "default_task_label": "hand_teach" + } + } +} diff --git a/dimos/learning/dataprep/test_config_profiles.py b/dimos/learning/dataprep/test_config_profiles.py new file mode 100644 index 0000000000..7e25ea10ae --- /dev/null +++ b/dimos/learning/dataprep/test_config_profiles.py @@ -0,0 +1,41 @@ +# 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 pathlib import Path + +import pytest + +from dimos.learning.dataprep.core import DataPrepConfig + + +@pytest.mark.parametrize( + ("filename", "anchor", "observation_keys"), + [ + ("galaxea_a1z_state_config.json", "joint_state", {"joint_state"}), + ("galaxea_a1z_camera_config.json", "image", {"image", "joint_state"}), + ], +) +def test_a1z_dataprep_profiles_are_valid( + filename: str, + anchor: str, + observation_keys: set[str], +) -> None: + path = Path(__file__).with_name(filename) + + config = DataPrepConfig.model_validate_json(path.read_text()) + + assert config.sync.anchor == anchor + assert set(config.observation) == observation_keys + assert set(config.action) == {"joint_target"} + assert config.output.metadata["robot"] == "galaxea_a1z" diff --git a/dimos/memory2/module.py b/dimos/memory2/module.py index e16c1527e7..c5b995174c 100644 --- a/dimos/memory2/module.py +++ b/dimos/memory2/module.py @@ -389,7 +389,13 @@ async def on_msg(msg: Any) -> None: ts, getattr(msg, "ts", None), ) - stream.append(msg, ts=ts, pose=pose) + try: + stream.append(msg, ts=ts, pose=pose) + except sqlite3.ProgrammingError: + # A callback already queued on the module loop can finish after + # shutdown has closed the store. The TF recorder has the same + # teardown guard below. + pass self.process_observable(input_topic.pure_observable(), on_msg) diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 6f78c62c9b..18b21a97de 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -42,6 +42,7 @@ from dimos.mapping.utils.cli.rename import main as _map_rename_main from dimos.mapping.utils.cli.replay import main as _map_replay_main from dimos.mapping.utils.cli.replay_marker import main as _map_replay_marker_main +from dimos.robot.manipulators.galaxea_a1z.teach_replay_cli import app as a1z_app from dimos.robot.unitree.go2.cli.go2tool import app as go2tool_app from dimos.utils.logging_config import setup_logger from dimos.visualization.rerun.constants import RerunOpenOption @@ -153,6 +154,7 @@ def callback(**kwargs) -> None: # type: ignore[no-untyped-def] main.callback()(create_dynamic_callback()) # type: ignore[no-untyped-call] main.add_typer(go2tool_app, name="go2tool") +main.add_typer(a1z_app, name="a1z") def arg_help( diff --git a/dimos/robot/manipulators/galaxea_a1z/config.py b/dimos/robot/manipulators/galaxea_a1z/config.py index 1eb128bc9d..ea3b344df6 100644 --- a/dimos/robot/manipulators/galaxea_a1z/config.py +++ b/dimos/robot/manipulators/galaxea_a1z/config.py @@ -51,6 +51,7 @@ def galaxea_a1z_hardware( gripper: bool = True, mock_without_address: bool = False, dynamics_urdf_path: str | None = None, + adapter_kwargs: dict[str, object] | None = None, ) -> HardwareComponent: if global_config.simulation: # TODO: Add sim support when A1Z MuJoCo model is available @@ -58,10 +59,13 @@ def galaxea_a1z_hardware( address = global_config.can_port or "a1zcan" if mock_without_address and not global_config.can_port: return make_galaxea_a1z_hardware(hw_id, gripper=gripper) + resolved_adapter_kwargs = dict(adapter_kwargs or {}) + if dynamics_urdf_path is not None: + resolved_adapter_kwargs["urdf_path"] = dynamics_urdf_path return make_galaxea_a1z_hardware( hw_id, adapter_type="galaxea_a1z", address=address, gripper=gripper, - adapter_kwargs={"urdf_path": dynamics_urdf_path} if dynamics_urdf_path else None, + adapter_kwargs=resolved_adapter_kwargs, ) diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay.py new file mode 100644 index 0000000000..a5a707dec3 --- /dev/null +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay.py @@ -0,0 +1,343 @@ +# 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. + +"""Compile saved Memory2 A1Z episodes into safe coordinator trajectories.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +from numpy.typing import NDArray + +from dimos.learning.dataprep.core import Episode, EpisodeExtractor, extract_episodes +from dimos.memory2.store.sqlite import SqliteStore +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.msgs.trajectory_msgs.JointTrajectory import JointTrajectory +from dimos.msgs.trajectory_msgs.TrajectoryPoint import TrajectoryPoint + +A1Z_JOINT_NAMES = ( + "arm/joint1", + "arm/joint2", + "arm/joint3", + "arm/joint4", + "arm/joint5", + "arm/joint6", + "arm/gripper", +) + +# The first six bounds are the vendor SDK's commandable soft limits. The +# gripper position is represented in meters throughout DimOS. +_POSITION_LOWER = np.array([-2.094, 0.0, -3.142, -1.484, -1.484, -2.007, 0.0]) +_POSITION_UPPER = np.array([2.094, 3.142, 0.0, 1.484, 1.484, 2.007, 0.1]) + +# Conservative teach-replay caps. Faster demonstrations are automatically +# time-scaled rather than clipped or rejected. +_REPLAY_VELOCITY_MAX = np.array([1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 0.10]) +_REPLAY_ACCELERATION_MAX = np.array([5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 0.50]) +_APPROACH_VELOCITY_MAX = np.array([0.4, 0.4, 0.4, 0.4, 0.4, 0.4, 0.04]) + + +@dataclass(frozen=True) +class RecordedEpisode: + """Measured samples and metadata loaded from one saved Memory2 episode.""" + + episode: Episode + episode_index: int + timestamps: NDArray[np.float64] + positions: NDArray[np.float64] + + +@dataclass(frozen=True) +class PreparedEpisode: + """Smoothed, uniformly sampled positions ready for trajectory execution.""" + + recorded: RecordedEpisode + timestamps: NDArray[np.float64] + positions: NDArray[np.float64] + velocities: NDArray[np.float64] + requested_speed: float + effective_speed: float + + @property + def duration(self) -> float: + return float(self.timestamps[-1]) + + +def load_recorded_episode(db_path: Path, episode_index: int = -1) -> RecordedEpisode: + """Load one successfully saved episode and reorder every sample by joint name.""" + store = SqliteStore(path=db_path, must_exist=True) + try: + episodes = [ + episode + for episode in extract_episodes(store, EpisodeExtractor(status_stream="status")) + if episode.success + ] + if not episodes: + raise ValueError(f"No saved episodes found in {db_path}") + + resolved_index = episode_index if episode_index >= 0 else len(episodes) + episode_index + if resolved_index < 0 or resolved_index >= len(episodes): + raise IndexError( + f"Episode index {episode_index} is out of range; {db_path} contains " + f"{len(episodes)} saved episode(s), indexed 0..{len(episodes) - 1}" + ) + episode = episodes[resolved_index] + + observations = store.stream("coordinator_joint_state", JointState).time_range( + episode.start_ts, episode.end_ts + ) + timestamps: list[float] = [] + positions: list[list[float]] = [] + for observation in observations: + msg = observation.data + if len(msg.name) != len(msg.position): + raise ValueError( + "Recorded JointState has different name/position lengths at " + f"t={observation.ts:.6f}: {len(msg.name)} names, " + f"{len(msg.position)} positions" + ) + by_name = dict(zip(msg.name, msg.position, strict=True)) + missing = [name for name in A1Z_JOINT_NAMES if name not in by_name] + if missing: + raise ValueError( + f"Recorded JointState at t={observation.ts:.6f} is missing {missing}" + ) + timestamps.append(observation.ts) + positions.append([float(by_name[name]) for name in A1Z_JOINT_NAMES]) + finally: + store.stop() + + if len(timestamps) < 3: + raise ValueError( + f"Episode {resolved_index} contains only {len(timestamps)} joint-state sample(s); " + "record at least 0.1 seconds" + ) + + ts = np.asarray(timestamps, dtype=np.float64) + q = np.asarray(positions, dtype=np.float64) + ts -= ts[0] + _validate_recorded_samples(ts, q) + return RecordedEpisode( + episode=episode, + episode_index=resolved_index, + timestamps=ts, + positions=q, + ) + + +def prepare_episode( + recorded: RecordedEpisode, + *, + speed: float = 1.0, + sample_rate_hz: float = 100.0, + smoothing_window_s: float = 0.08, +) -> PreparedEpisode: + """Smooth, resample, and automatically time-scale a recorded episode.""" + if speed <= 0: + raise ValueError(f"speed must be positive, got {speed}") + if sample_rate_hz <= 0: + raise ValueError(f"sample_rate_hz must be positive, got {sample_rate_hz}") + if smoothing_window_s < 0: + raise ValueError(f"smoothing_window_s cannot be negative, got {smoothing_window_s}") + + source_ts = recorded.timestamps + _validate_recorded_samples(source_ts, recorded.positions) + smoothed = _moving_average(recorded.positions, source_ts, smoothing_window_s) + source_uniform_ts = _uniform_times(float(source_ts[-1]), sample_rate_hz) + source_uniform_q = _interpolate_positions(source_ts, smoothed, source_uniform_ts) + + source_velocity = np.gradient(source_uniform_q, source_uniform_ts, axis=0) + source_acceleration = np.gradient(source_velocity, source_uniform_ts, axis=0) + safe_speed = _safe_playback_factor(source_velocity, source_acceleration) + effective_speed = min(speed, safe_speed) + if not np.isfinite(effective_speed) or effective_speed <= 0: + raise ValueError("Could not derive a safe playback speed from the recorded episode") + + playback_duration = float(source_uniform_ts[-1] / effective_speed) + playback_ts = _uniform_times(playback_duration, sample_rate_hz) + source_query = np.minimum(playback_ts * effective_speed, source_uniform_ts[-1]) + playback_q = _interpolate_positions(source_uniform_ts, source_uniform_q, source_query) + playback_velocity = np.gradient(playback_q, playback_ts, axis=0) + playback_velocity[0] = 0.0 + playback_velocity[-1] = 0.0 + + _validate_positions(playback_q, context="Prepared trajectory") + return PreparedEpisode( + recorded=recorded, + timestamps=playback_ts, + positions=playback_q, + velocities=playback_velocity, + requested_speed=speed, + effective_speed=effective_speed, + ) + + +def build_execution_trajectory( + current_positions: dict[str, float], + prepared: PreparedEpisode, + *, + sample_rate_hz: float = 100.0, + settle_s: float = 0.35, + final_hold_s: float = 0.35, +) -> JointTrajectory: + """Prepend a minimum-jerk approach and append a final hold.""" + missing = [name for name in A1Z_JOINT_NAMES if name not in current_positions] + if missing: + raise ValueError(f"Current robot state is missing {missing}") + current = np.asarray([current_positions[name] for name in A1Z_JOINT_NAMES], dtype=float) + _validate_positions(current[np.newaxis, :], context="Current robot state") + + target = prepared.positions[0] + delta = np.abs(target - current) + # Minimum jerk has a peak normalized velocity of 1.875 / duration. + approach_duration = max(1.0, float(np.max(1.875 * delta / _APPROACH_VELOCITY_MAX))) + approach_ts = _uniform_times(approach_duration, sample_rate_hz) + u = approach_ts / approach_duration + blend = 10.0 * u**3 - 15.0 * u**4 + 6.0 * u**5 + blend_velocity = (30.0 * u**2 - 60.0 * u**3 + 30.0 * u**4) / approach_duration + approach_q = current + blend[:, np.newaxis] * (target - current) + approach_velocity = blend_velocity[:, np.newaxis] * (target - current) + + points = [ + TrajectoryPoint( + time_from_start=float(ts), + positions=q.tolist(), + velocities=dq.tolist(), + ) + for ts, q, dq in zip(approach_ts, approach_q, approach_velocity, strict=True) + ] + + replay_offset = approach_duration + settle_s + points.extend( + TrajectoryPoint( + time_from_start=float(replay_offset + ts), + positions=q.tolist(), + velocities=dq.tolist(), + ) + for ts, q, dq in zip( + prepared.timestamps, + prepared.positions, + prepared.velocities, + strict=True, + ) + ) + points.append( + TrajectoryPoint( + time_from_start=float(replay_offset + prepared.duration + final_hold_s), + positions=prepared.positions[-1].tolist(), + velocities=[0.0] * len(A1Z_JOINT_NAMES), + ) + ) + return JointTrajectory(points=points, joint_names=list(A1Z_JOINT_NAMES)) + + +def _validate_recorded_samples( + timestamps: NDArray[np.float64], + positions: NDArray[np.float64], +) -> None: + if timestamps.ndim != 1 or positions.shape != (len(timestamps), len(A1Z_JOINT_NAMES)): + raise ValueError( + f"Unexpected episode shape: timestamps={timestamps.shape}, positions={positions.shape}" + ) + if not np.all(np.isfinite(timestamps)) or not np.all(np.isfinite(positions)): + raise ValueError("Recorded episode contains NaN or infinite values") + deltas = np.diff(timestamps) + if np.any(deltas <= 0): + index = int(np.flatnonzero(deltas <= 0)[0]) + raise ValueError( + "Recorded joint-state timestamps are not strictly increasing at samples " + f"{index} and {index + 1}" + ) + if timestamps[-1] < 0.1: + raise ValueError(f"Recorded episode is only {timestamps[-1]:.3f}s; record at least 0.1s") + _validate_positions(positions, context="Recorded episode") + + +def _validate_positions(positions: NDArray[np.float64], *, context: str) -> None: + invalid = np.argwhere( + (positions < _POSITION_LOWER[np.newaxis, :]) | (positions > _POSITION_UPPER[np.newaxis, :]) + ) + if invalid.size == 0: + return + sample_index, joint_index = (int(value) for value in invalid[0]) + value = positions[sample_index, joint_index] + raise ValueError( + f"{context} leaves the commandable range at sample {sample_index}: " + f"{A1Z_JOINT_NAMES[joint_index]}={value:.4f}, allowed " + f"[{_POSITION_LOWER[joint_index]:.4f}, {_POSITION_UPPER[joint_index]:.4f}]. " + "No values were clipped; re-teach the episode inside the vendor command limits." + ) + + +def _moving_average( + positions: NDArray[np.float64], + timestamps: NDArray[np.float64], + window_s: float, +) -> NDArray[np.float64]: + if window_s == 0: + return positions.copy() + median_period = float(np.median(np.diff(timestamps))) + window = max(1, round(window_s / median_period)) + if window % 2 == 0: + window += 1 + if window == 1: + return positions.copy() + + radius = window // 2 + kernel = np.ones(window, dtype=np.float64) / window + padded = np.pad(positions, ((radius, radius), (0, 0)), mode="edge") + return np.column_stack( + [np.convolve(padded[:, joint], kernel, mode="valid") for joint in range(positions.shape[1])] + ) + + +def _uniform_times(duration: float, rate_hz: float) -> NDArray[np.float64]: + count = max(2, int(np.ceil(duration * rate_hz)) + 1) + return np.linspace(0.0, duration, count, dtype=np.float64) + + +def _interpolate_positions( + source_ts: NDArray[np.float64], + source_q: NDArray[np.float64], + target_ts: NDArray[np.float64], +) -> NDArray[np.float64]: + return np.column_stack( + [np.interp(target_ts, source_ts, source_q[:, joint]) for joint in range(source_q.shape[1])] + ) + + +def _safe_playback_factor( + velocity: NDArray[np.float64], + acceleration: NDArray[np.float64], +) -> float: + max_velocity = np.max(np.abs(velocity), axis=0) + max_acceleration = np.max(np.abs(acceleration), axis=0) + velocity_factor = np.divide( + _REPLAY_VELOCITY_MAX, + max_velocity, + out=np.full_like(max_velocity, np.inf), + where=max_velocity > 1e-9, + ) + acceleration_factor = np.sqrt( + np.divide( + _REPLAY_ACCELERATION_MAX, + max_acceleration, + out=np.full_like(max_acceleration, np.inf), + where=max_acceleration > 1e-9, + ) + ) + # Leave a small numerical margin for the second interpolation pass. + return float(0.98 * min(np.min(velocity_factor), np.min(acceleration_factor))) diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay_blueprints.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay_blueprints.py new file mode 100644 index 0000000000..0342585dc3 --- /dev/null +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay_blueprints.py @@ -0,0 +1,79 @@ +# 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. + +"""A1Z hand-teaching and measured-trajectory replay blueprint factories.""" + +from __future__ import annotations + +from pathlib import Path + +from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.core.coordination.blueprints import Blueprint, autoconnect +from dimos.learning.collection.episode_monitor import EpisodeMonitorModule +from dimos.learning.collection.recorder import CollectionRecorder +from dimos.memory2.module import OnExisting +from dimos.robot.manipulators.a1z.config import A1Z_G1Z_MODEL_PATH +from dimos.robot.manipulators.galaxea_a1z.config import galaxea_a1z_hardware + +A1Z_REPLAY_TASK_NAME = "teach_replay_arm" + + +def make_a1z_teach_blueprint( + db_path: Path, + *, + task_label: str | None = None, +) -> Blueprint: + """Record measured arm/gripper state while both are hand-drivable.""" + hardware = galaxea_a1z_hardware( + "arm", + gripper=True, + dynamics_urdf_path=str(A1Z_G1Z_MODEL_PATH), + adapter_kwargs={ + "zero_gravity": True, + "gripper_free_drive": True, + }, + ) + return autoconnect( + ControlCoordinator.blueprint(hardware=[hardware], tasks=[]), + EpisodeMonitorModule.blueprint(default_task_label=task_label), + CollectionRecorder.blueprint( + db_path=db_path, + on_existing=OnExisting.ERROR, + root_frame="coordinator", + default_frame_id="coordinator", + record_tf=False, + ), + ) + + +def make_a1z_replay_blueprint() -> Blueprint: + """Run a validated seven-joint arm/gripper trajectory through the coordinator.""" + hardware = galaxea_a1z_hardware( + "arm", + gripper=True, + dynamics_urdf_path=str(A1Z_G1Z_MODEL_PATH), + ) + return autoconnect( + ControlCoordinator.blueprint( + hardware=[hardware], + tasks=[ + TaskConfig( + name=A1Z_REPLAY_TASK_NAME, + type="trajectory", + joint_names=hardware.all_joints, + priority=10, + ) + ], + ) + ) diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py new file mode 100644 index 0000000000..54f3548688 --- /dev/null +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py @@ -0,0 +1,234 @@ +# 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. + +"""Foreground A1Z hand-teach and replay commands.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +import time +from typing import Any + +import typer + +from dimos.constants import STATE_DIR + +app = typer.Typer(help="Record and replay Galaxea A1Z hand-taught episodes") + + +def _default_recording_path() -> Path: + return STATE_DIR / "recordings" / f"a1z_teach_{datetime.now():%Y%m%d_%H%M%S}.db" + + +def _press_enter(message: str) -> None: + typer.prompt(message, default="", show_default=False) + + +@app.command() +def teach( + output: Path | None = typer.Argument( + None, + help="Memory2 .db output (default: timestamped file under the DimOS state directory)", + ), + task: str | None = typer.Option(None, "--task", help="Task label stored with each episode"), +) -> None: + """Hand-teach episodes into one Memory2 recording.""" + from dimos.core.coordination.module_coordinator import ModuleCoordinator + from dimos.learning.collection.episode_monitor import EpisodeMonitorModule + from dimos.robot.manipulators.galaxea_a1z.teach_replay_blueprints import ( + make_a1z_teach_blueprint, + ) + + db_path = (output or _default_recording_path()).expanduser().resolve() + if db_path.exists(): + typer.echo(f"error: refusing to overwrite existing recording: {db_path}", err=True) + raise typer.Exit(2) + + typer.echo("A1Z hand-teach mode") + typer.echo(f"Recording: {db_path}") + typer.echo("The arm and gripper will become hand-drivable after startup.") + typer.echo("Keep the arm supported: it has no brakes and can fall when motors disable.\n") + + coordinator: ModuleCoordinator | None = None + recording = False + try: + coordinator = ModuleCoordinator.build( + make_a1z_teach_blueprint(db_path, task_label=task), + {}, + ) + monitor: Any = coordinator.get_instance(EpisodeMonitorModule) + typer.echo("Ready. Move only after starting an episode.") + + while True: + if not recording: + command = ( + typer.prompt( + "Press ENTER to start an episode, or q to finish", + default="", + show_default=False, + ) + .strip() + .lower() + ) + if command == "q": + break + if command: + typer.echo("Use ENTER to start or q to finish.") + continue + monitor.start_episode() + recording = True + typer.echo("RECORDING — move the arm and gripper by hand.") + continue + + command = ( + typer.prompt( + "Press ENTER to save, d to discard, or q to discard and finish", + default="", + show_default=False, + ) + .strip() + .lower() + ) + if command == "d": + monitor.discard_episode() + recording = False + typer.echo("Episode discarded.") + elif command == "q": + monitor.discard_episode() + recording = False + typer.echo("Active episode discarded.") + break + elif not command: + status = monitor.save_episode() + recording = False + typer.echo(f"Episode saved ({status.episodes_saved} total).") + else: + typer.echo("Use ENTER to save, d to discard, or q to finish.") + except KeyboardInterrupt: + if coordinator is not None and recording: + monitor = coordinator.get_instance(EpisodeMonitorModule) + monitor.discard_episode() + typer.echo("\nActive episode discarded.") + except Exception as exc: + typer.echo(f"A1Z teach failed: {exc}", err=True) + raise typer.Exit(1) + finally: + if coordinator is not None: + typer.echo("\nSupport the arm before the recording is flushed and motors disable.") + try: + _press_enter("Press ENTER when the arm is supported") + except (KeyboardInterrupt, EOFError): + pass + coordinator.stop() + + typer.echo(f"Saved Memory2 recording: {db_path}") + + +@app.command() +def replay( + source: Path = typer.Argument(..., help="Memory2 recording .db"), + episode: int = typer.Option(-1, "--episode", "-e", help="Saved episode index; -1 is latest"), + speed: float = typer.Option(1.0, "--speed", min=0.01, help="Requested playback speed"), +) -> None: + """Validate and replay one saved A1Z episode through ControlCoordinator.""" + from dimos.control.coordinator import ControlCoordinator + from dimos.core.coordination.module_coordinator import ModuleCoordinator + from dimos.msgs.trajectory_msgs.TrajectoryStatus import TrajectoryState + from dimos.robot.manipulators.galaxea_a1z.teach_replay import ( + build_execution_trajectory, + load_recorded_episode, + prepare_episode, + ) + from dimos.robot.manipulators.galaxea_a1z.teach_replay_blueprints import ( + A1Z_REPLAY_TASK_NAME, + make_a1z_replay_blueprint, + ) + + source = source.expanduser().resolve() + try: + recorded = load_recorded_episode(source, episode) + prepared = prepare_episode(recorded, speed=speed) + except Exception as exc: + typer.echo(f"A1Z replay preflight failed: {exc}", err=True) + raise typer.Exit(1) + + typer.echo(f"Recording: {source}") + typer.echo( + f"Episode: {recorded.episode_index} ({len(recorded.timestamps)} measured samples, " + f"{recorded.timestamps[-1]:.2f}s)" + ) + if prepared.effective_speed < prepared.requested_speed * 0.999: + typer.echo( + f"Safety time-scaling: requested {prepared.requested_speed:.2f}x, " + f"using {prepared.effective_speed:.2f}x" + ) + else: + typer.echo(f"Playback speed: {prepared.effective_speed:.2f}x") + typer.echo("Raw recorded values passed command-limit validation; nothing was clipped.") + typer.echo("Support the arm during startup. It has no brakes.\n") + + coordinator: ModuleCoordinator | None = None + started = False + try: + coordinator = ModuleCoordinator.build(make_a1z_replay_blueprint(), {}) + control: Any = coordinator.get_instance(ControlCoordinator) + current_positions = control.get_joint_positions() + trajectory = build_execution_trajectory(current_positions, prepared) + + typer.echo( + f"The robot will approach the recorded start pose, then replay for " + f"{prepared.duration:.2f}s. Total controlled motion: {trajectory.duration:.2f}s." + ) + if not typer.confirm("Execute this motion now?", default=False): + typer.echo("Replay cancelled before motion.") + return + + accepted = control.task_invoke( + A1Z_REPLAY_TASK_NAME, + "execute", + {"trajectory": trajectory}, + ) + if not accepted: + raise RuntimeError("ControlCoordinator rejected the replay trajectory") + started = True + + deadline = time.monotonic() + trajectory.duration + 5.0 + while time.monotonic() < deadline: + state = TrajectoryState(control.task_invoke(A1Z_REPLAY_TASK_NAME, "get_state", {})) + if state == TrajectoryState.COMPLETED: + typer.echo("Replay complete. The arm is holding the final pose.") + break + if state in (TrajectoryState.ABORTED, TrajectoryState.FAULT): + raise RuntimeError(f"Replay ended in state {state.name}") + time.sleep(0.05) + else: + control.task_invoke(A1Z_REPLAY_TASK_NAME, "cancel", {}) + raise TimeoutError("Replay did not complete before its safety timeout") + except KeyboardInterrupt: + typer.echo("\nReplay interrupted.", err=True) + if coordinator is not None and started: + control = coordinator.get_instance(ControlCoordinator) + control.task_invoke(A1Z_REPLAY_TASK_NAME, "cancel", {}) + except Exception as exc: + typer.echo(f"A1Z replay failed: {exc}", err=True) + raise typer.Exit(1) + finally: + if coordinator is not None: + typer.echo("Support the arm before disabling its motors.") + try: + _press_enter("Press ENTER when the arm is supported") + except (KeyboardInterrupt, EOFError): + pass + coordinator.stop() diff --git a/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py b/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py new file mode 100644 index 0000000000..b5597b84d9 --- /dev/null +++ b/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py @@ -0,0 +1,139 @@ +# 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 + +import numpy as np +import pytest + +from dimos.learning.collection.episode_monitor import EpisodeStatus +from dimos.learning.dataprep.core import Episode +from dimos.memory2.store.sqlite import SqliteStore +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.robot.manipulators.galaxea_a1z.teach_replay import ( + A1Z_JOINT_NAMES, + RecordedEpisode, + build_execution_trajectory, + load_recorded_episode, + prepare_episode, +) + + +def _positions(count: int = 5) -> np.ndarray: + base = np.array([0.0, 0.5, -0.5, 0.0, 0.0, 0.0, 0.05]) + return np.repeat(base[np.newaxis, :], count, axis=0) + + +def _recorded(positions: np.ndarray, period: float = 0.1) -> RecordedEpisode: + timestamps = np.arange(len(positions), dtype=float) * period + return RecordedEpisode( + episode=Episode(id="ep_000000", start_ts=10.0, end_ts=10.0 + timestamps[-1]), + episode_index=0, + timestamps=timestamps, + positions=positions, + ) + + +def test_loads_saved_memory2_episode_and_orders_joints(tmp_path: Path) -> None: + path = tmp_path / "teach.db" + store = SqliteStore(path=path) + status = store.stream("status", EpisodeStatus) + joints = store.stream("coordinator_joint_state", JointState) + status.append( + EpisodeStatus( + ts=10.0, + state="recording", + episodes_saved=0, + episodes_discarded=0, + last_event="start", + ), + ts=10.0, + ) + reversed_names = list(reversed(A1Z_JOINT_NAMES)) + base = _positions(1)[0] + for index, ts in enumerate((10.05, 10.15, 10.25)): + sample = base.copy() + sample[0] += index / 10 + values = dict(zip(A1Z_JOINT_NAMES, sample, strict=True)) + joints.append( + JointState( + ts=ts, + name=reversed_names, + position=[values[name] for name in reversed_names], + ), + ts=ts, + ) + status.append( + EpisodeStatus( + ts=10.3, + state="idle", + episodes_saved=1, + episodes_discarded=0, + last_event="save", + ), + ts=10.3, + ) + store.stop() + + loaded = load_recorded_episode(path) + + assert loaded.episode_index == 0 + np.testing.assert_allclose(loaded.timestamps, [0.0, 0.1, 0.2]) + np.testing.assert_allclose(loaded.positions[0], base) + + +def test_prepare_rejects_recorded_positions_instead_of_clipping() -> None: + positions = _positions() + positions[2, 0] = 2.2 + + with pytest.raises(ValueError, match=r"arm/joint1=2\.2000.*No values were clipped"): + prepare_episode(_recorded(positions)) + + +def test_prepare_smooths_resamples_and_time_scales_fast_motion() -> None: + positions = _positions() + positions[:, 0] = np.linspace(0.0, 1.0, len(positions)) + + prepared = prepare_episode( + _recorded(positions, period=0.025), + speed=1.0, + sample_rate_hz=100.0, + smoothing_window_s=0.05, + ) + + assert prepared.effective_speed < 1.0 + assert prepared.duration > prepared.recorded.timestamps[-1] + assert len(prepared.timestamps) > len(positions) + assert np.all(np.diff(prepared.timestamps) > 0) + assert np.max(np.abs(prepared.velocities[:, 0])) <= 1.5 + + +def test_execution_trajectory_approaches_then_replays_all_seven_joints() -> None: + positions = _positions() + positions[:, 0] = np.linspace(0.2, 0.4, len(positions)) + prepared = prepare_episode(_recorded(positions), smoothing_window_s=0.0) + current = dict(zip(A1Z_JOINT_NAMES, [0.0, 0.4, -0.4, 0.1, 0.0, 0.0, 0.03], strict=True)) + + trajectory = build_execution_trajectory(current, prepared) + + assert trajectory.joint_names == list(A1Z_JOINT_NAMES) + assert trajectory.points[0].positions == pytest.approx(list(current.values())) + assert trajectory.points[-1].positions == pytest.approx(prepared.positions[-1]) + assert trajectory.points[-1].velocities == pytest.approx([0.0] * 7) + assert all( + previous.time_from_start < current_point.time_from_start + for previous, current_point in zip(trajectory.points, trajectory.points[1:], strict=False) + ) From a0d052d25b620dcad20510136ad57328a85c51ae Mon Sep 17 00:00:00 2001 From: Pim Van den Bosch Date: Sun, 19 Jul 2026 18:19:47 +0800 Subject: [PATCH 10/51] refactor: narrow A1Z integration scope --- .../dataprep/galaxea_a1z_camera_config.json | 37 ----- .../learning/dataprep/test_config_profiles.py | 21 +-- dimos/manipulation/manipulation_module.py | 115 +------------ dimos/manipulation/planning/spec/config.py | 5 - .../planning/world/roboplan_world.py | 74 +-------- dimos/manipulation/test_manipulation_unit.py | 153 +----------------- dimos/manipulation/test_roboplan_world.py | 49 ------ dimos/robot/all_blueprints.py | 1 - dimos/robot/manipulators/a1z/config.py | 5 +- dimos/robot/manipulators/a1z/test_config.py | 36 ----- .../robot/manipulators/galaxea_a1z/README.md | 4 +- .../galaxea_a1z/blueprints/basic.py | 85 ++++++---- .../galaxea_a1z/teach_replay_blueprints.py | 79 --------- .../galaxea_a1z/teach_replay_cli.py | 10 +- 14 files changed, 77 insertions(+), 597 deletions(-) delete mode 100644 dimos/learning/dataprep/galaxea_a1z_camera_config.json delete mode 100644 dimos/robot/manipulators/a1z/test_config.py delete mode 100644 dimos/robot/manipulators/galaxea_a1z/teach_replay_blueprints.py diff --git a/dimos/learning/dataprep/galaxea_a1z_camera_config.json b/dimos/learning/dataprep/galaxea_a1z_camera_config.json deleted file mode 100644 index 9d0ace90a9..0000000000 --- a/dimos/learning/dataprep/galaxea_a1z_camera_config.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "source": "", - "episodes": { - "extractor": "episode_status", - "status_stream": "status" - }, - "observation": { - "image": { - "stream": "color_image", - "field": "data" - }, - "joint_state": { - "stream": "coordinator_joint_state", - "field": "position" - } - }, - "action": { - "joint_target": { - "stream": "coordinator_joint_state", - "field": "position" - } - }, - "sync": { - "anchor": "image", - "rate_hz": 30.0, - "tolerance_ms": 50.0, - "action_shift": 1 - }, - "output": { - "format": "lerobot", - "path": "data/datasets/galaxea_a1z_camera", - "metadata": { - "robot": "galaxea_a1z", - "default_task_label": "hand_teach" - } - } -} diff --git a/dimos/learning/dataprep/test_config_profiles.py b/dimos/learning/dataprep/test_config_profiles.py index 7e25ea10ae..4c18f66cc1 100644 --- a/dimos/learning/dataprep/test_config_profiles.py +++ b/dimos/learning/dataprep/test_config_profiles.py @@ -14,28 +14,15 @@ from pathlib import Path -import pytest - from dimos.learning.dataprep.core import DataPrepConfig -@pytest.mark.parametrize( - ("filename", "anchor", "observation_keys"), - [ - ("galaxea_a1z_state_config.json", "joint_state", {"joint_state"}), - ("galaxea_a1z_camera_config.json", "image", {"image", "joint_state"}), - ], -) -def test_a1z_dataprep_profiles_are_valid( - filename: str, - anchor: str, - observation_keys: set[str], -) -> None: - path = Path(__file__).with_name(filename) +def test_a1z_dataprep_profile_is_valid() -> None: + path = Path(__file__).with_name("galaxea_a1z_state_config.json") config = DataPrepConfig.model_validate_json(path.read_text()) - assert config.sync.anchor == anchor - assert set(config.observation) == observation_keys + assert config.sync.anchor == "joint_state" + assert set(config.observation) == {"joint_state"} assert set(config.action) == {"joint_target"} assert config.output.metadata["robot"] == "galaxea_a1z" diff --git a/dimos/manipulation/manipulation_module.py b/dimos/manipulation/manipulation_module.py index 191f9b72dc..20a8d167c0 100644 --- a/dimos/manipulation/manipulation_module.py +++ b/dimos/manipulation/manipulation_module.py @@ -82,8 +82,6 @@ logger = setup_logger() -_LIMIT_PROJECTION_LOG_PERIOD_S = 5.0 - # Composite type aliases for readability (using semantic IDs from planning.spec) RobotEntry: TypeAlias = tuple[WorldRobotID, RobotModelConfig, JointTrajectoryGenerator] """(world_robot_id, config, trajectory_generator)""" @@ -98,10 +96,6 @@ """Maps robot_name -> planned trajectory""" -class _MeasuredStateLimitError(ValueError): - """Raised when measured feedback is too far outside planning limits.""" - - class ManipulationState(Enum): """State machine for manipulation module.""" @@ -153,7 +147,6 @@ def __init__(self, **kwargs: Any) -> None: self._lock = threading.Lock() self._error_message = "" self._planning_epoch = 0 - self._last_limit_projection_log: dict[WorldRobotID, float] = {} # Planning components (initialized in start()) self._world_monitor: WorldMonitor | None = None @@ -516,11 +509,6 @@ def _solve_ik_for_pose( """Run the configured kinematics backend for a world-frame pose.""" assert self._world_monitor and self._kinematics - try: - planning_seed = self._project_measured_state_for_planning(robot_id, seed) - except _MeasuredStateLimitError as exc: - return IKResult(status=IKStatus.JOINT_LIMITS, message=str(exc)) - # Convert Pose to PoseStamped for the IK solver from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -534,101 +522,10 @@ def _solve_ik_for_pose( world=self._world_monitor.world, robot_id=robot_id, target_pose=target_pose, - seed=planning_seed, + seed=seed, check_collision=check_collision, ) - def _project_measured_state_for_planning( - self, robot_id: WorldRobotID, state: JointState - ) -> JointState: - """Project a minor measured-state overshoot onto the planning limits. - - Driver feedback remains untouched. Only the copy passed to IK or a - planner is projected, and only when every violation is within the - robot-specific measured-state tolerance. - """ - assert self._world_monitor - config = next( - ( - config - for candidate_id, config, _ in self._robots.values() - if candidate_id == robot_id - ), - None, - ) - if config is None: - raise _MeasuredStateLimitError(f"No robot configuration for '{robot_id}'") - - positions = np.asarray(state.position, dtype=np.float64) - lower_limits, upper_limits = self._world_monitor.get_joint_limits(robot_id) - model_lower = np.asarray(lower_limits, dtype=np.float64) - model_upper = np.asarray(upper_limits, dtype=np.float64) - if positions.shape != model_lower.shape or positions.shape != model_upper.shape: - raise _MeasuredStateLimitError( - "Measured state and planning joint limits have different lengths: " - f"state={positions.size}, lower={model_lower.size}, upper={model_upper.size}" - ) - if not np.all(np.isfinite(positions)): - raise _MeasuredStateLimitError("Measured planning state contains non-finite positions") - - joint_names = state.name if state.name else config.joint_names - if len(joint_names) != len(state.position): - raise _MeasuredStateLimitError( - "Measured planning state must name every position or omit all names" - ) - model_index_by_name = {name: i for i, name in enumerate(config.joint_names)} - model_indices: list[int] = [] - for name in joint_names: - model_name = config.get_urdf_joint_name(name) - if model_name not in model_index_by_name: - raise _MeasuredStateLimitError( - f"Measured planning state has unknown joint '{name}'" - ) - model_indices.append(model_index_by_name[model_name]) - if len(set(model_indices)) != len(model_indices): - raise _MeasuredStateLimitError("Measured planning state contains duplicate joints") - lower = model_lower[model_indices] - upper = model_upper[model_indices] - - overshoot = np.maximum(lower - positions, positions - upper) - outside = overshoot > 0.0 - if not np.any(outside): - return state - - tolerance = config.measured_state_limit_tolerance - beyond_tolerance = outside & (overshoot > tolerance) - if np.any(beyond_tolerance): - violations = ", ".join( - f"{joint_names[i]}={positions[i]:.6f} outside " - f"[{lower[i]:.6f}, {upper[i]:.6f}] by {overshoot[i]:.6f} rad" - for i in np.flatnonzero(beyond_tolerance) - ) - raise _MeasuredStateLimitError( - f"Measured planning state exceeds the {tolerance:.6f} rad limit tolerance: " - f"{violations}" - ) - - projected = JointState(state) - projected.position = np.clip(positions, lower, upper).tolist() - projections = ", ".join( - f"{joint_names[i]}={positions[i]:.6f}->{projected.position[i]:.6f}" - for i in np.flatnonzero(outside) - ) - now = time.monotonic() - with self._lock: - previous_log = self._last_limit_projection_log.get(robot_id, 0.0) - should_log = now - previous_log >= _LIMIT_PROJECTION_LOG_PERIOD_S - if should_log: - self._last_limit_projection_log[robot_id] = now - if should_log: - logger.warning( - "Projected measured state onto planning limits", - robot_id=str(robot_id), - projections=projections, - tolerance_rad=tolerance, - ) - return projected - @rpc def solve_ik( self, @@ -691,8 +588,7 @@ def plan_to_pose(self, pose: Pose, robot_name: RobotName | None = None) -> bool: ik = self._solve_ik_for_pose(robot_id, pose, current, check_collision=True) if not ik.is_success() or ik.joint_state is None: - detail = f": {ik.message}" if ik.message else "" - return self._fail(f"IK failed: {ik.status.name}{detail}") + return self._fail(f"IK failed: {ik.status.name}") logger.info(f"IK solved, error: {ik.position_error:.4f}m") return self._plan_path_only(robot_name, robot_id, ik.joint_state, planning_epoch) @@ -725,10 +621,6 @@ def _plan_path_only( start = self._world_monitor.get_current_joint_state(robot_id) if start is None: return self._fail("No joint state") - try: - start = self._project_measured_state_for_planning(robot_id, start) - except _MeasuredStateLimitError as exc: - return self._fail(f"Planning start is invalid: {exc}") # Trim goal to planner DOF (e.g. strip gripper joint from coordinator state) planner_dof = len(start.position) @@ -749,8 +641,7 @@ def _plan_path_only( logger.info("Discarding cancelled planning result") return False if not result.is_success(): - detail = f": {result.message}" if result.message else "" - return self._fail(f"Planning failed: {result.status.name}{detail}") + return self._fail(f"Planning failed: {result.status.name}") logger.info(f"Path: {len(result.path)} waypoints") self._planned_paths[robot_name] = result.path diff --git a/dimos/manipulation/planning/spec/config.py b/dimos/manipulation/planning/spec/config.py index eaf7532462..74dc3bd69b 100644 --- a/dimos/manipulation/planning/spec/config.py +++ b/dimos/manipulation/planning/spec/config.py @@ -50,9 +50,6 @@ class RobotModelConfig(ModuleConfig): corresponds to URDF's "joint1". If empty, names are assumed to match. coordinator_task_name: Task name for executing trajectories via coordinator RPC. If set, trajectories can be executed via execute_trajectory() RPC. - measured_state_limit_tolerance: Maximum measured-state limit overshoot - that may be projected onto the model limits for planning. This applies - only to planning seeds and starts, never to goals or hardware commands. """ name: str @@ -79,8 +76,6 @@ class RobotModelConfig(ModuleConfig): tf_extra_links: list[str] = Field(default_factory=list) # Home/observe joint configuration for go_home skill home_joints: list[float] | None = None - # Real encoder feedback can rest slightly beyond a modeled soft limit. - measured_state_limit_tolerance: float = Field(default=1e-3, ge=0.0) # Pre-grasp offset distance in meters (along approach direction) pre_grasp_offset: float = 0.10 diff --git a/dimos/manipulation/planning/world/roboplan_world.py b/dimos/manipulation/planning/world/roboplan_world.py index 996a42535a..d252d3fdc9 100644 --- a/dimos/manipulation/planning/world/roboplan_world.py +++ b/dimos/manipulation/planning/world/roboplan_world.py @@ -328,48 +328,8 @@ def plan_joint_path( message="RoboPlan-native planner requires its RoboPlanWorld instance", ) start_time = time.time() - try: - q_start = self._joint_state_to_q(robot_id, start) - except ValueError as exc: - return PlanningResult( - status=PlanningStatus.INVALID_START, - planning_time=time.time() - start_time, - message=f"Invalid start configuration: {exc}", - ) - try: - q_goal = self._joint_state_to_q(robot_id, goal) - except ValueError as exc: - return PlanningResult( - status=PlanningStatus.INVALID_GOAL, - planning_time=time.time() - start_time, - message=f"Invalid goal configuration: {exc}", - ) - - if violation := self._joint_limit_violation(robot_id, q_start): - return PlanningResult( - status=PlanningStatus.INVALID_START, - planning_time=time.time() - start_time, - message=f"Start configuration is outside joint limits: {violation}", - ) - if violation := self._joint_limit_violation(robot_id, q_goal): - return PlanningResult( - status=PlanningStatus.INVALID_GOAL, - planning_time=time.time() - start_time, - message=f"Goal configuration is outside joint limits: {violation}", - ) - if self._has_collisions(robot_id, q_start): - return PlanningResult( - status=PlanningStatus.COLLISION_AT_START, - planning_time=time.time() - start_time, - message="Start configuration is in collision", - ) - if self._has_collisions(robot_id, q_goal): - return PlanningResult( - status=PlanningStatus.COLLISION_AT_GOAL, - planning_time=time.time() - start_time, - message="Goal configuration is in collision", - ) - + q_start = self._joint_state_to_q(robot_id, start) + q_goal = self._joint_state_to_q(robot_id, goal) try: path_arrays = self._run_native_rrt(robot_id, q_start, q_goal, timeout) except ValueError as exc: @@ -380,19 +340,6 @@ def plan_joint_path( planning_time=time.time() - start_time, message=f"RoboPlan-native planning failed: {exc}", ) - except RuntimeError as exc: - message = str(exc) - if "Invalid start configuration" in message: - status = PlanningStatus.INVALID_START - elif "Invalid goal configuration" in message: - status = PlanningStatus.INVALID_GOAL - else: - raise - return PlanningResult( - status=status, - planning_time=time.time() - start_time, - message=f"RoboPlan-native planning failed: {message}", - ) if not path_arrays: return PlanningResult( status=PlanningStatus.NO_SOLUTION, @@ -579,23 +526,6 @@ def _joint_state_to_q( [name_to_pos[name] for name in robot.config.joint_names], dtype=np.float64 ) - def _joint_limit_violation(self, robot_id: WorldRobotID, q: NDArray[np.float64]) -> str | None: - robot = self._get_robot(robot_id) - nonfinite = ~np.isfinite(q) - if np.any(nonfinite): - return ", ".join( - f"{robot.config.joint_names[i]}={q[i]} is not finite" - for i in np.flatnonzero(nonfinite) - ) - outside = (q < robot.lower_limits) | (q > robot.upper_limits) - if not np.any(outside): - return None - return ", ".join( - f"{robot.config.joint_names[i]}={q[i]:.6f} outside " - f"[{robot.lower_limits[i]:.6f}, {robot.upper_limits[i]:.6f}]" - for i in np.flatnonzero(outside) - ) - def _require_finalized(self) -> None: if not self._finalized: raise RuntimeError("World must be finalized first") diff --git a/dimos/manipulation/test_manipulation_unit.py b/dimos/manipulation/test_manipulation_unit.py index cdaa15004e..bc12bfe994 100644 --- a/dimos/manipulation/test_manipulation_unit.py +++ b/dimos/manipulation/test_manipulation_unit.py @@ -31,8 +31,8 @@ from dimos.manipulation.planning.kinematics.config import PinkKinematicsConfig from dimos.manipulation.planning.monitor.world_monitor import WorldMonitor from dimos.manipulation.planning.spec.config import RobotModelConfig -from dimos.manipulation.planning.spec.enums import IKStatus, PlanningStatus -from dimos.manipulation.planning.spec.models import IKResult, PlanningResult, PlanningSceneInfo +from dimos.manipulation.planning.spec.enums import IKStatus +from dimos.manipulation.planning.spec.models import IKResult, PlanningSceneInfo from dimos.manipulation.planning.spec.protocols import VisualizationSpec from dimos.msgs.geometry_msgs.Pose import Pose from dimos.msgs.geometry_msgs.PoseStamped import PoseStamped @@ -100,7 +100,6 @@ def __init__(self) -> None: self._lock = threading.Lock() self._error_message = "" self._planning_epoch = 0 - self._last_limit_projection_log = {} self._robots = {} self._planned_paths = {} self._planned_trajectories = {} @@ -309,10 +308,6 @@ def test_solve_ik_rpc_calls_configured_backend(self, robot_config): module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} module._world_monitor = MagicMock() module._world_monitor.world = MagicMock() - module._world_monitor.get_joint_limits.return_value = ( - [-1.0, -1.0, -1.0], - [1.0, 1.0, 1.0], - ) current = JointState(name=robot_config.joint_names, position=[0.0, 0.0, 0.0]) module._world_monitor.get_current_joint_state.return_value = current expected = IKResult( @@ -363,10 +358,6 @@ def test_solve_ik_rpc_uses_explicit_seed(self, robot_config): module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} module._world_monitor = MagicMock() module._world_monitor.world = MagicMock() - module._world_monitor.get_joint_limits.return_value = ( - [-1.0, -1.0, -1.0], - [1.0, 1.0, 1.0], - ) module._world_monitor.get_current_joint_state.return_value = JointState( name=robot_config.joint_names, position=[0.0, 0.0, 0.0] ) @@ -383,146 +374,6 @@ def test_solve_ik_rpc_uses_explicit_seed(self, robot_config): assert kwargs["seed"] is explicit_seed module._world_monitor.get_current_joint_state.assert_not_called() - def test_solve_ik_projects_minor_measured_limit_overshoot(self, robot_config): - module = _make_module() - robot_config.measured_state_limit_tolerance = 0.01 - module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} - module._world_monitor = MagicMock() - module._world_monitor.world = MagicMock() - module._world_monitor.get_joint_limits.return_value = ( - [-1.0, -1.0, -1.0], - [1.0, 1.0, 0.0], - ) - measured = JointState( - name=["joint3", "joint1", "joint2"], - position=[0.003, 0.2, 0.3], - ) - module._world_monitor.get_current_joint_state.return_value = measured - module._kinematics = MagicMock() - module._kinematics.solve.return_value = IKResult( - status=IKStatus.SUCCESS, - joint_state=JointState(name=robot_config.joint_names, position=[0.1, 0.1, -0.1]), - ) - - result = module.solve_ik(Pose(position=Vector3(), orientation=Quaternion())) - - assert result.status == IKStatus.SUCCESS - planning_seed = module._kinematics.solve.call_args.kwargs["seed"] - assert planning_seed.name == ["joint3", "joint1", "joint2"] - assert planning_seed.position == [0.0, 0.2, 0.3] - assert measured.position == [0.003, 0.2, 0.3] - - def test_viser_pose_evaluation_projects_minor_measured_limit_overshoot(self, robot_config): - module = _make_module() - robot_config.measured_state_limit_tolerance = 0.01 - module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} - module._world_monitor = MagicMock() - module._world_monitor.world = MagicMock() - module._world_monitor.get_joint_limits.return_value = ( - [-1.0, -1.0, -1.0], - [1.0, 1.0, 0.0], - ) - measured = JointState( - name=robot_config.joint_names, - position=[0.0, 0.0, 0.003], - ) - module._world_monitor.get_current_joint_state.return_value = measured - module._world_monitor.is_state_valid.return_value = True - module._kinematics = MagicMock() - solution = JointState( - name=robot_config.joint_names, - position=[0.1, 0.1, -0.1], - ) - module._kinematics.solve.return_value = IKResult( - status=IKStatus.SUCCESS, - joint_state=solution, - ) - - result = module.evaluate_pose_target( - Pose(position=Vector3(), orientation=Quaternion()), "test_arm" - ) - - assert result["success"] is True - assert result["joint_state"] == solution - planning_seed = module._kinematics.solve.call_args.kwargs["seed"] - assert planning_seed.position == [0.0, 0.0, 0.0] - assert measured.position == [0.0, 0.0, 0.003] - - def test_solve_ik_rejects_measured_limit_violation_beyond_tolerance(self, robot_config): - module = _make_module() - robot_config.measured_state_limit_tolerance = 0.01 - module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} - module._world_monitor = MagicMock() - module._world_monitor.world = MagicMock() - module._world_monitor.get_joint_limits.return_value = ( - [-1.0, -1.0, -1.0], - [1.0, 1.0, 0.0], - ) - module._world_monitor.get_current_joint_state.return_value = JointState( - name=robot_config.joint_names, - position=[0.0, 0.0, 0.02], - ) - module._kinematics = MagicMock() - - result = module.solve_ik(Pose(position=Vector3(), orientation=Quaternion())) - - assert result.status == IKStatus.JOINT_LIMITS - assert "joint3=0.020000" in result.message - assert "by 0.020000 rad" in result.message - module._kinematics.solve.assert_not_called() - - def test_plan_to_joints_projects_minor_measured_start_overshoot(self, robot_config): - module = _make_module() - robot_config.measured_state_limit_tolerance = 0.01 - module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} - module._world_monitor = MagicMock() - module._world_monitor.get_joint_limits.return_value = ( - [-1.0, -1.0, -1.0], - [1.0, 1.0, 0.0], - ) - measured = JointState( - name=robot_config.joint_names, - position=[0.0, 0.0, 0.003], - ) - module._world_monitor.get_current_joint_state.return_value = measured - module._planner = MagicMock() - module._planner.plan_joint_path.return_value = PlanningResult( - status=PlanningStatus.NO_SOLUTION, - message="stop after validating inputs", - ) - goal = JointState(name=robot_config.joint_names, position=[0.1, 0.1, -0.1]) - - assert not module.plan_to_joints(goal) - - planning_start = module._planner.plan_joint_path.call_args.kwargs["start"] - assert planning_start.position == [0.0, 0.0, 0.0] - assert measured.position == [0.0, 0.0, 0.003] - - def test_plan_to_joints_preserves_planner_failure_details(self, robot_config): - module = _make_module() - module._robots = {"test_arm": ("robot_id", robot_config, MagicMock())} - module._world_monitor = MagicMock() - module._world_monitor.get_joint_limits.return_value = ( - [-1.0, -1.0, -1.0], - [1.0, 1.0, 1.0], - ) - module._world_monitor.get_current_joint_state.return_value = JointState( - name=robot_config.joint_names, - position=[0.0, 0.0, 0.0], - ) - module._planner = MagicMock() - module._planner.plan_joint_path.return_value = PlanningResult( - status=PlanningStatus.INVALID_GOAL, - message="arm_joint3 is outside its upper limit", - ) - - goal = JointState(name=robot_config.joint_names, position=[0.1, 0.1, 0.1]) - assert not module.plan_to_joints(goal) - assert ( - module.get_error() - == "Planning failed: INVALID_GOAL: arm_joint3 is outside its upper limit" - ) - class TestJointNameTranslation: """Test trajectory joint name translation for coordinator.""" diff --git a/dimos/manipulation/test_roboplan_world.py b/dimos/manipulation/test_roboplan_world.py index 677cbf6012..68ad241067 100644 --- a/dimos/manipulation/test_roboplan_world.py +++ b/dimos/manipulation/test_roboplan_world.py @@ -480,55 +480,6 @@ def test_native_planner_names_path_from_robot_config_when_start_is_unnamed( assert [state.name for state in result.path] == [["joint1", "joint2"]] * 3 -def test_native_planner_returns_invalid_start_for_out_of_limit_configuration( - fake_roboplan: None, robot_config: RobotModelConfig -) -> None: - world, robot_id = _make_world(fake_roboplan, robot_config) - world.finalize() - - start = JointState(name=["joint1", "joint2"], position=[1.01, 0.0]) - goal = JointState(name=["joint1", "joint2"], position=[0.4, 0.2]) - result = world.plan_joint_path(world, robot_id, start, goal, timeout=1.0) - - assert result.status == PlanningStatus.INVALID_START - assert "joint1=1.010000 outside [-1.000000, 1.000000]" in result.message - - -def test_native_planner_returns_invalid_goal_for_out_of_limit_configuration( - fake_roboplan: None, robot_config: RobotModelConfig -) -> None: - world, robot_id = _make_world(fake_roboplan, robot_config) - world.finalize() - - start = JointState(name=["joint1", "joint2"], position=[0.0, 0.0]) - goal = JointState(name=["joint1", "joint2"], position=[0.4, 2.01]) - result = world.plan_joint_path(world, robot_id, start, goal, timeout=1.0) - - assert result.status == PlanningStatus.INVALID_GOAL - assert "joint2=2.010000 outside [-2.000000, 2.000000]" in result.message - - -def test_native_planner_maps_known_backend_invalid_start_error( - fake_roboplan: None, robot_config: RobotModelConfig, monkeypatch: pytest.MonkeyPatch -) -> None: - class InvalidStartRRT(FakeRRT): - def plan( - self, q_start: FakeJointConfiguration, q_goal: FakeJointConfiguration - ) -> FakeJointPath: - raise RuntimeError("Invalid start configuration requested, cannot plan!") - - monkeypatch.setattr(sys.modules["roboplan.rrt"], "RRT", InvalidStartRRT) - world, robot_id = _make_world(fake_roboplan, robot_config) - world.finalize() - - start = JointState(name=["joint1", "joint2"], position=[0.0, 0.0]) - goal = JointState(name=["joint1", "joint2"], position=[0.4, 0.2]) - result = world.plan_joint_path(world, robot_id, start, goal, timeout=1.0) - - assert result.status == PlanningStatus.INVALID_START - assert "Invalid start configuration requested" in result.message - - def test_native_planner_rejects_empty_path( fake_roboplan: None, robot_config: RobotModelConfig, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index b70a7f7f6f..1f6c72dba2 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -63,7 +63,6 @@ "drone-agentic": "dimos.robot.drone.blueprints.agentic.drone_agentic:drone_agentic", "drone-basic": "dimos.robot.drone.blueprints.basic.drone_basic:drone_basic", "dual-xarm6-planner": "dimos.robot.manipulators.xarm.blueprints.basic:dual_xarm6_planner", - "galaxea-a1z-planner-coordinator": "dimos.robot.manipulators.galaxea_a1z.blueprints.basic:galaxea_a1z_planner_coordinator", "keyboard-teleop-a750": "dimos.robot.manipulators.a750.blueprints.teleop:keyboard_teleop_a750", "keyboard-teleop-openarm": "dimos.robot.manipulators.openarm.blueprints.teleop:keyboard_teleop_openarm", "keyboard-teleop-openarm-mock": "dimos.robot.manipulators.openarm.blueprints.teleop:keyboard_teleop_openarm_mock", diff --git a/dimos/robot/manipulators/a1z/config.py b/dimos/robot/manipulators/a1z/config.py index 41cec6ef3d..355c8ee28f 100644 --- a/dimos/robot/manipulators/a1z/config.py +++ b/dimos/robot/manipulators/a1z/config.py @@ -28,7 +28,6 @@ from dimos.utils.data import LfsPath A1Z_DOF = 6 -A1Z_MEASURED_STATE_LIMIT_TOLERANCE = 0.01 A1Z_COLLISION_EXCLUSIONS: list[tuple[str, str]] = [ ("arm_link2", "arm_link5"), @@ -72,7 +71,6 @@ def make_a1z_model_config( name: str = "arm", *, has_gripper: bool = True, - enable_gripper_control: bool = True, joint_prefix: str | None = None, coordinator_task_name: str | None = None, home_joints: list[float] | None = None, @@ -94,7 +92,6 @@ def make_a1z_model_config( urdf_joint_prefix="arm_", ), coordinator_task_name=coordinator_task_name or f"traj_{name}", - gripper_hardware_id=name if has_gripper and enable_gripper_control else None, + gripper_hardware_id=name if has_gripper else None, home_joints=home_joints or [0.0] * A1Z_DOF, - measured_state_limit_tolerance=A1Z_MEASURED_STATE_LIMIT_TOLERANCE, ) diff --git a/dimos/robot/manipulators/a1z/test_config.py b/dimos/robot/manipulators/a1z/test_config.py deleted file mode 100644 index 9c67fab342..0000000000 --- a/dimos/robot/manipulators/a1z/test_config.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 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. - -"""Tests for the Galaxea A1Z planning model configuration.""" - -from dimos.robot.manipulators.a1z.config import ( - A1Z_G1Z_MODEL_PATH, - A1Z_MEASURED_STATE_LIMIT_TOLERANCE, - make_a1z_model_config, -) - - -def test_a1z_model_allows_bounded_home_encoder_overshoot() -> None: - config = make_a1z_model_config(has_gripper=False) - - assert A1Z_MEASURED_STATE_LIMIT_TOLERANCE == 0.01 - assert config.measured_state_limit_tolerance == A1Z_MEASURED_STATE_LIMIT_TOLERANCE - - -def test_a1z_model_can_include_gripper_without_enabling_gripper_control() -> None: - config = make_a1z_model_config(has_gripper=True, enable_gripper_control=False) - - assert config.model_path == A1Z_G1Z_MODEL_PATH - assert config.end_effector_link == "gripper_eef_link" - assert config.gripper_hardware_id is None diff --git a/dimos/robot/manipulators/galaxea_a1z/README.md b/dimos/robot/manipulators/galaxea_a1z/README.md index c277eea213..c4d286faa7 100644 --- a/dimos/robot/manipulators/galaxea_a1z/README.md +++ b/dimos/robot/manipulators/galaxea_a1z/README.md @@ -1,8 +1,8 @@ # Galaxea A1Z + G1Z The A1Z integration uses native Linux SocketCAN, the vendor's 250 Hz MIT -position-control loop, the G1Z URDF for gravity compensation and visualization, -and the vendor G1Z gripper implementation. +position-control loop, the G1Z URDF for gravity compensation, and the vendor +G1Z gripper implementation. ## Vendor SDK diff --git a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py index d3513c81f8..925cf70d4c 100644 --- a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py +++ b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py @@ -12,23 +12,22 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Basic Galaxea A1Z coordinator and planner blueprints.""" +"""Basic Galaxea A1Z coordinator blueprint.""" from __future__ import annotations +from pathlib import Path + from dimos.control.coordinator import ControlCoordinator, TaskConfig -from dimos.core.coordination.blueprints import autoconnect -from dimos.robot.manipulators.a1z.config import ( - A1Z_G1Z_MODEL_PATH, - make_a1z_model_config, -) -from dimos.robot.manipulators.common.blueprints import ( - coordinator, - planner, - trajectory_task, -) +from dimos.core.coordination.blueprints import Blueprint, autoconnect +from dimos.learning.collection.episode_monitor import EpisodeMonitorModule +from dimos.learning.collection.recorder import CollectionRecorder +from dimos.memory2.module import OnExisting +from dimos.robot.manipulators.a1z.config import A1Z_G1Z_MODEL_PATH from dimos.robot.manipulators.galaxea_a1z.config import galaxea_a1z_hardware +A1Z_REPLAY_TASK_NAME = "teach_replay_arm" + # The real arm has a G1Z gripper. Its mass must be present in the dynamics # model, and the hardware component exposes its measured/commanded opening as # arm/gripper alongside the six arm joints. @@ -49,20 +48,52 @@ ), ) -# Planner and Viser use the same physical G1Z model and tool-center frame. -_planner_hw = galaxea_a1z_hardware("arm", gripper=True, dynamics_urdf_path=_A1Z_DYNAMICS_URDF) -galaxea_a1z_planner_coordinator = autoconnect( - planner( - robots=[ - make_a1z_model_config( - name="arm", - has_gripper=True, - ) - ] - ), - coordinator( - hardware=[_planner_hw], - tasks=[trajectory_task(_planner_hw)], - ), -) +def make_a1z_teach_blueprint( + db_path: Path, + *, + task_label: str | None = None, +) -> Blueprint: + """Record measured arm/gripper state while both are hand-drivable.""" + hardware = galaxea_a1z_hardware( + "arm", + gripper=True, + dynamics_urdf_path=_A1Z_DYNAMICS_URDF, + adapter_kwargs={ + "zero_gravity": True, + "gripper_free_drive": True, + }, + ) + return autoconnect( + ControlCoordinator.blueprint(hardware=[hardware], tasks=[]), + EpisodeMonitorModule.blueprint(default_task_label=task_label), + CollectionRecorder.blueprint( + db_path=db_path, + on_existing=OnExisting.ERROR, + root_frame="coordinator", + default_frame_id="coordinator", + record_tf=False, + ), + ) + + +def make_a1z_replay_blueprint() -> Blueprint: + """Run a validated seven-joint arm/gripper trajectory through the coordinator.""" + hardware = galaxea_a1z_hardware( + "arm", + gripper=True, + dynamics_urdf_path=_A1Z_DYNAMICS_URDF, + ) + return autoconnect( + ControlCoordinator.blueprint( + hardware=[hardware], + tasks=[ + TaskConfig( + name=A1Z_REPLAY_TASK_NAME, + type="trajectory", + joint_names=hardware.all_joints, + priority=10, + ) + ], + ) + ) diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay_blueprints.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay_blueprints.py deleted file mode 100644 index 0342585dc3..0000000000 --- a/dimos/robot/manipulators/galaxea_a1z/teach_replay_blueprints.py +++ /dev/null @@ -1,79 +0,0 @@ -# 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. - -"""A1Z hand-teaching and measured-trajectory replay blueprint factories.""" - -from __future__ import annotations - -from pathlib import Path - -from dimos.control.coordinator import ControlCoordinator, TaskConfig -from dimos.core.coordination.blueprints import Blueprint, autoconnect -from dimos.learning.collection.episode_monitor import EpisodeMonitorModule -from dimos.learning.collection.recorder import CollectionRecorder -from dimos.memory2.module import OnExisting -from dimos.robot.manipulators.a1z.config import A1Z_G1Z_MODEL_PATH -from dimos.robot.manipulators.galaxea_a1z.config import galaxea_a1z_hardware - -A1Z_REPLAY_TASK_NAME = "teach_replay_arm" - - -def make_a1z_teach_blueprint( - db_path: Path, - *, - task_label: str | None = None, -) -> Blueprint: - """Record measured arm/gripper state while both are hand-drivable.""" - hardware = galaxea_a1z_hardware( - "arm", - gripper=True, - dynamics_urdf_path=str(A1Z_G1Z_MODEL_PATH), - adapter_kwargs={ - "zero_gravity": True, - "gripper_free_drive": True, - }, - ) - return autoconnect( - ControlCoordinator.blueprint(hardware=[hardware], tasks=[]), - EpisodeMonitorModule.blueprint(default_task_label=task_label), - CollectionRecorder.blueprint( - db_path=db_path, - on_existing=OnExisting.ERROR, - root_frame="coordinator", - default_frame_id="coordinator", - record_tf=False, - ), - ) - - -def make_a1z_replay_blueprint() -> Blueprint: - """Run a validated seven-joint arm/gripper trajectory through the coordinator.""" - hardware = galaxea_a1z_hardware( - "arm", - gripper=True, - dynamics_urdf_path=str(A1Z_G1Z_MODEL_PATH), - ) - return autoconnect( - ControlCoordinator.blueprint( - hardware=[hardware], - tasks=[ - TaskConfig( - name=A1Z_REPLAY_TASK_NAME, - type="trajectory", - joint_names=hardware.all_joints, - priority=10, - ) - ], - ) - ) diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py index 54f3548688..bd50c73964 100644 --- a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py @@ -47,7 +47,7 @@ def teach( """Hand-teach episodes into one Memory2 recording.""" from dimos.core.coordination.module_coordinator import ModuleCoordinator from dimos.learning.collection.episode_monitor import EpisodeMonitorModule - from dimos.robot.manipulators.galaxea_a1z.teach_replay_blueprints import ( + from dimos.robot.manipulators.galaxea_a1z.blueprints.basic import ( make_a1z_teach_blueprint, ) @@ -146,15 +146,15 @@ def replay( from dimos.control.coordinator import ControlCoordinator from dimos.core.coordination.module_coordinator import ModuleCoordinator from dimos.msgs.trajectory_msgs.TrajectoryStatus import TrajectoryState + from dimos.robot.manipulators.galaxea_a1z.blueprints.basic import ( + A1Z_REPLAY_TASK_NAME, + make_a1z_replay_blueprint, + ) from dimos.robot.manipulators.galaxea_a1z.teach_replay import ( build_execution_trajectory, load_recorded_episode, prepare_episode, ) - from dimos.robot.manipulators.galaxea_a1z.teach_replay_blueprints import ( - A1Z_REPLAY_TASK_NAME, - make_a1z_replay_blueprint, - ) source = source.expanduser().resolve() try: From 08f8cddc9239f15eaabdf3d37f7d302bfe4b390e Mon Sep 17 00:00:00 2001 From: Pim Van den Bosch Date: Sun, 19 Jul 2026 18:26:21 +0800 Subject: [PATCH 11/51] fix: preserve existing A1Z teleop --- dimos/robot/all_blueprints.py | 1 + .../manipulators/a1z/blueprints/teleop.py | 43 +++++++++++++++++++ dimos/robot/manipulators/test_blueprints.py | 2 + 3 files changed, 46 insertions(+) create mode 100644 dimos/robot/manipulators/a1z/blueprints/teleop.py diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 1f6c72dba2..8f82ab50d7 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -63,6 +63,7 @@ "drone-agentic": "dimos.robot.drone.blueprints.agentic.drone_agentic:drone_agentic", "drone-basic": "dimos.robot.drone.blueprints.basic.drone_basic:drone_basic", "dual-xarm6-planner": "dimos.robot.manipulators.xarm.blueprints.basic:dual_xarm6_planner", + "keyboard-teleop-a1z": "dimos.robot.manipulators.a1z.blueprints.teleop:keyboard_teleop_a1z", "keyboard-teleop-a750": "dimos.robot.manipulators.a750.blueprints.teleop:keyboard_teleop_a750", "keyboard-teleop-openarm": "dimos.robot.manipulators.openarm.blueprints.teleop:keyboard_teleop_openarm", "keyboard-teleop-openarm-mock": "dimos.robot.manipulators.openarm.blueprints.teleop:keyboard_teleop_openarm_mock", diff --git a/dimos/robot/manipulators/a1z/blueprints/teleop.py b/dimos/robot/manipulators/a1z/blueprints/teleop.py new file mode 100644 index 0000000000..f869b637e2 --- /dev/null +++ b/dimos/robot/manipulators/a1z/blueprints/teleop.py @@ -0,0 +1,43 @@ +# Copyright 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. + +"""Galaxea A1Z teleop blueprints.""" + +from __future__ import annotations + +from dimos.control.coordinator import ControlCoordinator +from dimos.core.coordination.blueprints import autoconnect +from dimos.manipulation.manipulation_module import ManipulationModule +from dimos.robot.manipulators.a1z.config import ( + A1Z_DOF, + A1Z_FK_MODEL, + make_a1z_hardware, + make_a1z_model_config, +) +from dimos.robot.manipulators.common.blueprints import eef_twist_task +from dimos.teleop.keyboard.keyboard_teleop_module import KeyboardTeleopModule + +_a1z_keyboard_hw = make_a1z_hardware("arm") + +keyboard_teleop_a1z = autoconnect( + KeyboardTeleopModule.blueprint(), + ControlCoordinator.blueprint( + hardware=[_a1z_keyboard_hw], + tasks=[eef_twist_task(_a1z_keyboard_hw, model_path=A1Z_FK_MODEL, ee_joint_id=A1Z_DOF)], + ), + ManipulationModule.blueprint( + robots=[make_a1z_model_config()], + visualization={"backend": "viser"}, + ), +) diff --git a/dimos/robot/manipulators/test_blueprints.py b/dimos/robot/manipulators/test_blueprints.py index 9394c2c221..3e7848beb1 100644 --- a/dimos/robot/manipulators/test_blueprints.py +++ b/dimos/robot/manipulators/test_blueprints.py @@ -21,6 +21,7 @@ from dimos.core.coordination.blueprints import Blueprint from dimos.manipulation.manipulation_module import ManipulationModule, ManipulationModuleConfig from dimos.manipulation.visualization.config import NoManipulationVisualizationConfig +from dimos.robot.manipulators.a1z.blueprints.teleop import keyboard_teleop_a1z from dimos.robot.manipulators.a750.blueprints.teleop import keyboard_teleop_a750 from dimos.robot.manipulators.common.blueprints import eef_twist_task, planner from dimos.robot.manipulators.common.topics import EEF_TWIST_TASK_NAME @@ -104,6 +105,7 @@ def test_eef_twist_task_helper_uses_hardware_joints_and_default_name() -> None: pytest.param(keyboard_teleop_openarm_mock, id="openarm-mock"), pytest.param(keyboard_teleop_openarm, id="openarm"), pytest.param(keyboard_teleop_a750, id="a750"), + pytest.param(keyboard_teleop_a1z, id="a1z"), ], ) def test_manipulator_keyboard_blueprint_uses_eef_twist_and_light_keyboard_kwargs( From a72b8439cc625c531237e183d7dff8023a61c830 Mon Sep 17 00:00:00 2001 From: Jetson Wu Date: Sun, 19 Jul 2026 18:42:33 +0800 Subject: [PATCH 12/51] refactor: tighten A1Z PR scope --- dimos/memory2/module.py | 8 +------- dimos/robot/manipulators/galaxea_a1z/README.md | 10 +++++----- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/dimos/memory2/module.py b/dimos/memory2/module.py index c5b995174c..e16c1527e7 100644 --- a/dimos/memory2/module.py +++ b/dimos/memory2/module.py @@ -389,13 +389,7 @@ async def on_msg(msg: Any) -> None: ts, getattr(msg, "ts", None), ) - try: - stream.append(msg, ts=ts, pose=pose) - except sqlite3.ProgrammingError: - # A callback already queued on the module loop can finish after - # shutdown has closed the store. The TF recorder has the same - # teardown guard below. - pass + stream.append(msg, ts=ts, pose=pose) self.process_observable(input_topic.pure_observable(), on_msg) diff --git a/dimos/robot/manipulators/galaxea_a1z/README.md b/dimos/robot/manipulators/galaxea_a1z/README.md index c4d286faa7..48af02013a 100644 --- a/dimos/robot/manipulators/galaxea_a1z/README.md +++ b/dimos/robot/manipulators/galaxea_a1z/README.md @@ -14,11 +14,11 @@ git clone --branch gripper https://github.com/userguide-galaxea/GALAXEA-A1Z.git uv pip install -e ./GALAXEA-A1Z ``` -The Jetson checkout currently used by DimOS is `/home/dimos/GALAXEA-A1Z` and -must remain on that branch. DimOS deliberately has no Linux userspace-CAN -fallback. After boot or reconnecting the HHS adapter, bind it to the kernel -driver, configure the stable `a1zcan` SocketCAN interface, and verify that the -driver can actually transmit: +Keep the installed SDK checkout on its `gripper` branch; switching that checkout +to vendor `main` will break G1Z initialization. DimOS deliberately has no Linux +userspace-CAN fallback. After boot or reconnecting the HHS adapter, bind it to +the kernel driver, configure the stable `a1zcan` SocketCAN interface, and verify +that the driver can actually transmit: ```bash sudo ./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z_can.sh From 041e6c519c1a1abf17eb9bcd6009760b6046a8e1 Mon Sep 17 00:00:00 2001 From: Jetson Wu Date: Sun, 19 Jul 2026 20:47:39 +0800 Subject: [PATCH 13/51] feat: record A1Z camera observations --- .../dataprep/galaxea_a1z_state_config.json | 10 +++-- .../learning/dataprep/test_config_profiles.py | 5 ++- .../robot/manipulators/galaxea_a1z/README.md | 37 +++++++++++++++++++ .../galaxea_a1z/blueprints/basic.py | 27 +++++++++++++- .../galaxea_a1z/teach_replay_cli.py | 13 ++++++- .../galaxea_a1z/test_teach_replay.py | 30 +++++++++++++++ 6 files changed, 115 insertions(+), 7 deletions(-) diff --git a/dimos/learning/dataprep/galaxea_a1z_state_config.json b/dimos/learning/dataprep/galaxea_a1z_state_config.json index 5e2ac5c501..74a6ce80f7 100644 --- a/dimos/learning/dataprep/galaxea_a1z_state_config.json +++ b/dimos/learning/dataprep/galaxea_a1z_state_config.json @@ -5,6 +5,10 @@ "status_stream": "status" }, "observation": { + "image": { + "stream": "color_image", + "field": "data" + }, "joint_state": { "stream": "coordinator_joint_state", "field": "position" @@ -17,9 +21,9 @@ } }, "sync": { - "anchor": "joint_state", - "rate_hz": 50.0, - "tolerance_ms": 20.0, + "anchor": "image", + "rate_hz": 15.0, + "tolerance_ms": 80.0, "action_shift": 1 }, "output": { diff --git a/dimos/learning/dataprep/test_config_profiles.py b/dimos/learning/dataprep/test_config_profiles.py index 4c18f66cc1..b8846e9b57 100644 --- a/dimos/learning/dataprep/test_config_profiles.py +++ b/dimos/learning/dataprep/test_config_profiles.py @@ -22,7 +22,8 @@ def test_a1z_dataprep_profile_is_valid() -> None: config = DataPrepConfig.model_validate_json(path.read_text()) - assert config.sync.anchor == "joint_state" - assert set(config.observation) == {"joint_state"} + assert config.sync.anchor == "image" + assert config.sync.rate_hz == 15.0 + assert set(config.observation) == {"image", "joint_state"} assert set(config.action) == {"joint_target"} assert config.output.metadata["robot"] == "galaxea_a1z" diff --git a/dimos/robot/manipulators/galaxea_a1z/README.md b/dimos/robot/manipulators/galaxea_a1z/README.md index 48af02013a..814520c289 100644 --- a/dimos/robot/manipulators/galaxea_a1z/README.md +++ b/dimos/robot/manipulators/galaxea_a1z/README.md @@ -34,3 +34,40 @@ driver patch built for their exact kernel. The A1Z has no brakes. Support the arm and keep the workspace clear before starting a hardware blueprint. Enabling the G1Z also initializes the gripper. + +## Camera, teach, replay, and LeRobot export + +The teach command uses a standard Linux UVC camera through DimOS's generic +`Webcam` and `CameraModule`. The default camera is `/dev/video0`; select another +video device with `--camera-index N`. Each saved episode contains 640x480 RGB +images at 15 Hz plus the measured six arm joints and gripper position. + +After the CAN setup check passes, record one or more episodes: + +```bash +uv run dimos a1z teach --task "pick up the object" +``` + +The command prints the Memory2 `.db` path. Replay a saved episode by passing +that path (the latest saved episode is selected by default): + +```bash +uv run dimos a1z replay ~/.local/state/dimos/recordings/a1z_teach_.db +``` + +Convert the same recording into a LeRobot v3 dataset with synchronized video, +seven-element observation state, and seven-element action: + +```bash +uv run dimos dataprep build \ + --source ~/.local/state/dimos/recordings/a1z_teach_.db \ + --output ./a1z_lerobot_dataset \ + --format lerobot \ + --config dimos/learning/dataprep/galaxea_a1z_state_config.json + +uv run dimos dataprep inspect ./a1z_lerobot_dataset +``` + +The LeRobot output stores images as +`observation.images.image`, the measured arm and gripper state as +`observation.state`, and the next measured state as `action`. diff --git a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py index 925cf70d4c..a3fb3ae8cc 100644 --- a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py +++ b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py @@ -16,17 +16,24 @@ from __future__ import annotations +from functools import partial from pathlib import Path from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.core.coordination.blueprints import Blueprint, autoconnect +from dimos.hardware.sensors.camera.module import CameraModule +from dimos.hardware.sensors.camera.webcam import Webcam from dimos.learning.collection.episode_monitor import EpisodeMonitorModule from dimos.learning.collection.recorder import CollectionRecorder from dimos.memory2.module import OnExisting +from dimos.msgs.geometry_msgs.Transform import Transform from dimos.robot.manipulators.a1z.config import A1Z_G1Z_MODEL_PATH from dimos.robot.manipulators.galaxea_a1z.config import galaxea_a1z_hardware A1Z_REPLAY_TASK_NAME = "teach_replay_arm" +A1Z_TEACH_CAMERA_WIDTH = 640 +A1Z_TEACH_CAMERA_HEIGHT = 480 +A1Z_TEACH_CAMERA_FPS = 15.0 # The real arm has a G1Z gripper. Its mass must be present in the dynamics # model, and the hardware component exposes its measured/commanded opening as @@ -53,8 +60,9 @@ def make_a1z_teach_blueprint( db_path: Path, *, task_label: str | None = None, + camera_index: int = 0, ) -> Blueprint: - """Record measured arm/gripper state while both are hand-drivable.""" + """Record camera and measured arm/gripper state while hand-drivable.""" hardware = galaxea_a1z_hardware( "arm", gripper=True, @@ -72,8 +80,25 @@ def make_a1z_teach_blueprint( on_existing=OnExisting.ERROR, root_frame="coordinator", default_frame_id="coordinator", + tf_tolerance=1.5, record_tf=False, ), + CameraModule.blueprint( + hardware=partial( + Webcam, + camera_index=camera_index, + width=A1Z_TEACH_CAMERA_WIDTH, + height=A1Z_TEACH_CAMERA_HEIGHT, + fps=A1Z_TEACH_CAMERA_FPS, + ), + # Placeholder until the hackathon camera mount is calibrated. + # Learned image policies do not consume this transform, but the + # recorder and Rerun still require a connected frame tree. + transform=Transform( + frame_id="coordinator", + child_frame_id="camera_link", + ), + ), ) diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py index bd50c73964..ee1878736a 100644 --- a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py @@ -43,6 +43,12 @@ def teach( help="Memory2 .db output (default: timestamped file under the DimOS state directory)", ), task: str | None = typer.Option(None, "--task", help="Task label stored with each episode"), + camera_index: int = typer.Option( + 0, + "--camera-index", + min=0, + help="Linux camera index N for /dev/videoN", + ), ) -> None: """Hand-teach episodes into one Memory2 recording.""" from dimos.core.coordination.module_coordinator import ModuleCoordinator @@ -58,6 +64,7 @@ def teach( typer.echo("A1Z hand-teach mode") typer.echo(f"Recording: {db_path}") + typer.echo(f"Camera: /dev/video{camera_index} (640x480 at 15 FPS)") typer.echo("The arm and gripper will become hand-drivable after startup.") typer.echo("Keep the arm supported: it has no brakes and can fall when motors disable.\n") @@ -65,7 +72,11 @@ def teach( recording = False try: coordinator = ModuleCoordinator.build( - make_a1z_teach_blueprint(db_path, task_label=task), + make_a1z_teach_blueprint( + db_path, + task_label=task, + camera_index=camera_index, + ), {}, ) monitor: Any = coordinator.get_instance(EpisodeMonitorModule) diff --git a/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py b/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py index b5597b84d9..5e8aefcf9e 100644 --- a/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py +++ b/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py @@ -19,10 +19,19 @@ import numpy as np import pytest +from dimos.hardware.sensors.camera.module import CameraModule +from dimos.hardware.sensors.camera.webcam import Webcam from dimos.learning.collection.episode_monitor import EpisodeStatus +from dimos.learning.collection.recorder import CollectionRecorder from dimos.learning.dataprep.core import Episode from dimos.memory2.store.sqlite import SqliteStore from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.robot.manipulators.galaxea_a1z.blueprints.basic import ( + A1Z_TEACH_CAMERA_FPS, + A1Z_TEACH_CAMERA_HEIGHT, + A1Z_TEACH_CAMERA_WIDTH, + make_a1z_teach_blueprint, +) from dimos.robot.manipulators.galaxea_a1z.teach_replay import ( A1Z_JOINT_NAMES, RecordedEpisode, @@ -47,6 +56,27 @@ def _recorded(positions: np.ndarray, period: float = 0.1) -> RecordedEpisode: ) +def test_teach_blueprint_records_from_configured_webcam(tmp_path: Path) -> None: + blueprint = make_a1z_teach_blueprint(tmp_path / "teach.db", camera_index=3) + camera_atom = next(atom for atom in blueprint.blueprints if atom.module is CameraModule) + recorder_atom = next(atom for atom in blueprint.blueprints if atom.module is CollectionRecorder) + + camera = camera_atom.kwargs["hardware"]() + + assert isinstance(camera, Webcam) + assert camera.config.camera_index == 3 + assert (camera.config.width, camera.config.height, camera.config.fps) == ( + A1Z_TEACH_CAMERA_WIDTH, + A1Z_TEACH_CAMERA_HEIGHT, + A1Z_TEACH_CAMERA_FPS, + ) + assert camera.config.frame_id_prefix is None + transform = camera_atom.kwargs["transform"] + assert (transform.frame_id, transform.child_frame_id) == ("coordinator", "camera_link") + assert recorder_atom.kwargs["tf_tolerance"] == 1.5 + assert blueprint.blueprints.index(camera_atom) > blueprint.blueprints.index(recorder_atom) + + def test_loads_saved_memory2_episode_and_orders_joints(tmp_path: Path) -> None: path = tmp_path / "teach.db" store = SqliteStore(path=path) From fe873cf872ad6db65e92c68e01cddc2d2fb2b873 Mon Sep 17 00:00:00 2001 From: Kentaro Wu Date: Sun, 19 Jul 2026 21:34:33 +0800 Subject: [PATCH 14/51] feat: keyboard gripper control in A1Z teach mode Keep the gripper powered during hand-teaching and toggle it open/closed with a single g keypress, so the operator's hand stays out of the wrist camera footage. The legacy pinch-by-hand behavior remains available via --gripper-free-drive. Prompts now read single keypresses (cbreak) with a line-based fallback for non-tty stdin. Co-Authored-By: Claude Fable 5 --- .../galaxea_a1z/blueprints/basic.py | 11 +- .../galaxea_a1z/teach_replay_cli.py | 105 ++++++++++++++---- 2 files changed, 93 insertions(+), 23 deletions(-) diff --git a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py index a3fb3ae8cc..da7ef7a670 100644 --- a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py +++ b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py @@ -61,15 +61,22 @@ def make_a1z_teach_blueprint( *, task_label: str | None = None, camera_index: int = 0, + gripper_free_drive: bool = False, ) -> Blueprint: - """Record camera and measured arm/gripper state while hand-drivable.""" + """Record camera and measured arm/gripper state while hand-drivable. + + The arm is always hand-drivable (zero gravity). The gripper defaults to + powered position control so it can be opened/closed from the keyboard, + keeping the operator's hand out of the wrist camera; pass + gripper_free_drive=True for the legacy pinch-by-hand behavior. + """ hardware = galaxea_a1z_hardware( "arm", gripper=True, dynamics_urdf_path=_A1Z_DYNAMICS_URDF, adapter_kwargs={ "zero_gravity": True, - "gripper_free_drive": True, + "gripper_free_drive": gripper_free_drive, }, ) return autoconnect( diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py index ee1878736a..afec4e6067 100644 --- a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py @@ -27,6 +27,12 @@ app = typer.Typer(help="Record and replay Galaxea A1Z hand-taught episodes") +_TEACH_HARDWARE_ID = "arm" +# Matches the adapter's default G1Z max opening; the adapter clamps to the +# configured range, so a full-open command stays correct if that changes. +_GRIPPER_OPEN_M = 0.1 +_GRIPPER_CLOSED_M = 0.0 + def _default_recording_path() -> Path: return STATE_DIR / "recordings" / f"a1z_teach_{datetime.now():%Y%m%d_%H%M%S}.db" @@ -36,6 +42,38 @@ def _press_enter(message: str) -> None: typer.prompt(message, default="", show_default=False) +def _read_key(message: str) -> str: + """Read one keypress without waiting for ENTER. + + Returns the lowercased character; ENTER is normalized to "". Falls back + to line input when stdin is not an interactive terminal. + """ + import sys + + typer.echo(message) + if not sys.stdin.isatty(): + line = sys.stdin.readline() + if not line: + raise EOFError + return line.strip().lower()[:1] + + import termios + import tty + + fd = sys.stdin.fileno() + saved = termios.tcgetattr(fd) + try: + tty.setcbreak(fd) + key = sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, saved) + if key == "\x03": # Ctrl-C arrives as a literal byte in cbreak mode + raise KeyboardInterrupt + if key in ("\r", "\n"): + return "" + return key.lower() + + @app.command() def teach( output: Path | None = typer.Argument( @@ -49,8 +87,15 @@ def teach( min=0, help="Linux camera index N for /dev/videoN", ), + gripper_free_drive: bool = typer.Option( + False, + "--gripper-free-drive", + help="Zero-torque gripper you pinch by hand (legacy); default keeps the " + "gripper powered and toggled with g so your hand stays out of the camera", + ), ) -> None: """Hand-teach episodes into one Memory2 recording.""" + from dimos.control.coordinator import ControlCoordinator from dimos.core.coordination.module_coordinator import ModuleCoordinator from dimos.learning.collection.episode_monitor import EpisodeMonitorModule from dimos.robot.manipulators.galaxea_a1z.blueprints.basic import ( @@ -65,54 +110,72 @@ def teach( typer.echo("A1Z hand-teach mode") typer.echo(f"Recording: {db_path}") typer.echo(f"Camera: /dev/video{camera_index} (640x480 at 15 FPS)") - typer.echo("The arm and gripper will become hand-drivable after startup.") + typer.echo("The arm will become hand-drivable after startup.") + if gripper_free_drive: + typer.echo("Gripper: free drive (open and close it by hand).") + else: + typer.echo("Gripper: powered; type g then ENTER to toggle open/closed.") typer.echo("Keep the arm supported: it has no brakes and can fall when motors disable.\n") coordinator: ModuleCoordinator | None = None recording = False + gripper_open: bool | None = None + try: coordinator = ModuleCoordinator.build( make_a1z_teach_blueprint( db_path, task_label=task, camera_index=camera_index, + gripper_free_drive=gripper_free_drive, ), {}, ) monitor: Any = coordinator.get_instance(EpisodeMonitorModule) + control: Any = coordinator.get_instance(ControlCoordinator) + if not gripper_free_drive: + measured = control.get_gripper_position(_TEACH_HARDWARE_ID) + gripper_open = measured is not None and measured > _GRIPPER_OPEN_M / 2 typer.echo("Ready. Move only after starting an episode.") + def _toggle_gripper() -> None: + nonlocal gripper_open + if gripper_free_drive: + typer.echo("Gripper is in free drive; open and close it by hand.") + return + target_open = not gripper_open + target = _GRIPPER_OPEN_M if target_open else _GRIPPER_CLOSED_M + if control.set_gripper_position(_TEACH_HARDWARE_ID, target): + gripper_open = target_open + typer.echo(f"Gripper {'opening' if target_open else 'closing'}.") + else: + typer.echo("Gripper command rejected; check hardware state.", err=True) + while True: if not recording: - command = ( - typer.prompt( - "Press ENTER to start an episode, or q to finish", - default="", - show_default=False, - ) - .strip() - .lower() + command = _read_key( + "Press ENTER to start an episode, g to toggle the gripper, or q to finish" ) if command == "q": break + if command == "g": + _toggle_gripper() + continue if command: - typer.echo("Use ENTER to start or q to finish.") + typer.echo("Use ENTER to start, g for the gripper, or q to finish.") continue monitor.start_episode() recording = True - typer.echo("RECORDING — move the arm and gripper by hand.") + typer.echo("RECORDING — move the arm by hand; g toggles the gripper.") continue - command = ( - typer.prompt( - "Press ENTER to save, d to discard, or q to discard and finish", - default="", - show_default=False, - ) - .strip() - .lower() + command = _read_key( + "Press ENTER to save, g to toggle the gripper, d to discard, " + "or q to discard and finish" ) - if command == "d": + if command == "g": + _toggle_gripper() + elif command == "d": monitor.discard_episode() recording = False typer.echo("Episode discarded.") @@ -126,7 +189,7 @@ def teach( recording = False typer.echo(f"Episode saved ({status.episodes_saved} total).") else: - typer.echo("Use ENTER to save, d to discard, or q to finish.") + typer.echo("Use ENTER to save, g for the gripper, d to discard, or q to finish.") except KeyboardInterrupt: if coordinator is not None and recording: monitor = coordinator.get_instance(EpisodeMonitorModule) From a13b1a149af30cffed0ca036bbe8b25ab3095400 Mon Sep 17 00:00:00 2001 From: Jetson Wu Date: Sun, 19 Jul 2026 21:40:12 +0800 Subject: [PATCH 15/51] feat: automate A1Z host setup --- .../manipulators/galaxea_a1z/adapter.py | 6 +- .../robot/manipulators/galaxea_a1z/README.md | 26 ++- .../galaxea_a1z/scripts/setup_a1z.sh | 163 ++++++++++++++++++ 3 files changed, 186 insertions(+), 9 deletions(-) create mode 100755 dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh diff --git a/dimos/hardware/manipulators/galaxea_a1z/adapter.py b/dimos/hardware/manipulators/galaxea_a1z/adapter.py index f7fb4b148d..334626817a 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/adapter.py @@ -234,7 +234,8 @@ def connect(self) -> bool: from a1z.robots.get_robot import get_a1z_robot except ImportError: print( - "ERROR: a1z SDK not installed. Install from github.com/userguide-galaxea/GALAXEA-A1Z" + "ERROR: a1z SDK not installed. Run " + "./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh" ) return False @@ -262,7 +263,8 @@ def connect(self) -> bool: except TypeError as e: print( "ERROR: installed a1z SDK does not support the gripper - " - f"install the SDK's 'gripper' branch: {e}" + "run ./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh: " + f"{e}" ) self._robot = None return False diff --git a/dimos/robot/manipulators/galaxea_a1z/README.md b/dimos/robot/manipulators/galaxea_a1z/README.md index 814520c289..2a9f15ae28 100644 --- a/dimos/robot/manipulators/galaxea_a1z/README.md +++ b/dimos/robot/manipulators/galaxea_a1z/README.md @@ -9,16 +9,28 @@ G1Z gripper implementation. The G1Z requires the vendor SDK's `gripper` branch; vendor `main` does not accept `with_gripper` and cannot actuate CAN motor 7. +Run the one-command host setup as your normal user: + +```bash +./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh +``` + +The wrapper verifies G1Z support and, when needed, installs a known-working +commit from the vendor's `gripper` branch into the DimOS virtual environment. +It requests `sudo` only when invoking the Linux SocketCAN setup. Use +`--sdk-only` to install or verify the Python SDK without touching CAN. + +For a manual SDK-only installation, use the same pinned source: + ```bash -git clone --branch gripper https://github.com/userguide-galaxea/GALAXEA-A1Z.git -uv pip install -e ./GALAXEA-A1Z +uv pip install \ + "a1z @ git+https://github.com/userguide-galaxea/GALAXEA-A1Z.git@e931ecd0e25ad35df251097ba42921b3d2fa7224" ``` -Keep the installed SDK checkout on its `gripper` branch; switching that checkout -to vendor `main` will break G1Z initialization. DimOS deliberately has no Linux -userspace-CAN fallback. After boot or reconnecting the HHS adapter, bind it to -the kernel driver, configure the stable `a1zcan` SocketCAN interface, and verify -that the driver can actually transmit: +DimOS deliberately has no Linux userspace-CAN fallback. After boot or +reconnecting the HHS adapter, the one-command setup can be rerun, or the CAN +portion can be invoked directly to bind the adapter to the kernel driver, +configure the stable `a1zcan` SocketCAN interface, and verify transmission: ```bash sudo ./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z_can.sh diff --git a/dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh b/dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh new file mode 100755 index 0000000000..df520cbdd8 --- /dev/null +++ b/dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# Install/verify the pinned Galaxea A1Z SDK as the normal user, then run the +# privileged Linux SocketCAN setup. This is the one-command A1Z host setup. +set -euo pipefail + +SDK_REPOSITORY="https://github.com/userguide-galaxea/GALAXEA-A1Z.git" +# Known-working revision from the vendor's gripper branch. Pinning the commit +# prevents a moving vendor branch from silently changing hackathon machines. +SDK_REVISION="e931ecd0e25ad35df251097ba42921b3d2fa7224" +SDK_REQUIREMENT="a1z @ git+${SDK_REPOSITORY}@${SDK_REVISION}" + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPOSITORY_ROOT="$(cd -- "$SCRIPT_DIR/../../../../.." && pwd)" +CAN_SETUP_SCRIPT="$SCRIPT_DIR/setup_a1z_can.sh" + +usage() { + cat <&2 + exit 2 + ;; +esac + +if ((EUID == 0)); then + cat >&2 <&2 <&2 <&2 <&2 </dev/null 2>&1; then + echo "ERROR: sudo is required for Linux SocketCAN setup." >&2 + exit 1 + fi + exec sudo "$CAN_SETUP_SCRIPT" + ;; + Darwin) + echo "macOS uses the A1Z userspace USB-CAN transport; SocketCAN setup is not required." + ;; + *) + echo "ERROR: A1Z host setup supports Linux and macOS only." >&2 + exit 1 + ;; +esac From 039cc9bdc6a42c456ab00c0833a063eb29921ab5 Mon Sep 17 00:00:00 2001 From: Kentaro Wu Date: Sun, 19 Jul 2026 22:18:47 +0800 Subject: [PATCH 16/51] fix: drain A1Z gs_usb RX on a reader thread The HHS adapter is a Full-Speed USB device read one frame per libusb round-trip. Draining it synchronously from the SDK's 250 Hz control loop (~1 ms budget per cycle) cannot sustain the ~3500 frames/s the bus carries (7 motors' feedback plus TX echoes), so the device FIFO overflowed and feedback froze for 0.3-2.4 s at a time - poisoning teach recordings with stale-then-jump positions and tripping the SDK's stale-feedback watchdog during replay. A dedicated reader thread now drains USB continuously (libusb releases the GIL while blocked), filters TX echoes, and queues real frames; recv() becomes an instant pop that always meets the SDK's drain budget. Validated on hardware: teach recordings are freeze-free and replay runs without stale warnings. --- .../manipulators/galaxea_a1z/gs_usb_bus.py | 98 ++++++++++++++----- 1 file changed, 76 insertions(+), 22 deletions(-) diff --git a/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py b/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py index b51686f982..cb7ac248e5 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py +++ b/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py @@ -25,6 +25,15 @@ the gs_usb library assumes 0x02) - macOS has no kernel driver to detach; the detach call is skipped - TX echo frames are filtered out of recv() +- RX runs on a dedicated reader thread (see _rx_loop): the adapter is a + Full-Speed USB device read one frame per libusb round-trip, and the SDK's + 250 Hz loop budgets only ~1 ms/cycle for draining. Synchronous reads from + that budget cannot keep up with the ~3500 frames/s the bus carries (motor + feedback plus TX echoes), so the device FIFO overflows and feedback + freezes for hundreds of ms (observed on hardware: frozen joints in teach + recordings, "CAN feedback stale" during replay). The reader thread drains + USB continuously - libusb releases the GIL while blocked - and recv() + becomes a queue pop that always meets the SDK's budget. Requires: pip install pyusb gs_usb (plus libusb, e.g. brew install libusb). Lives in galaxea_a1z/ because it is the only user today; promote to a shared @@ -33,6 +42,9 @@ from __future__ import annotations +import contextlib +import queue +import threading import time from typing import Any @@ -45,6 +57,13 @@ _GS_USB_NONE_ECHO_ID = 0xFFFFFFFF _GS_CAN_MODE_LISTEN_ONLY = 1 << 0 +# Reader-thread queue depth. At the arm's ~1750 feedback frames/s this holds +# multiple seconds of backlog; the consumer (SDK drain) normally keeps the +# queue near-empty, and on overflow the oldest frames are dropped so recv() +# keeps returning fresh state instead of replaying stale history. +_RX_QUEUE_MAX_FRAMES = 8192 +_RX_READ_TIMEOUT_MS = 20 + class GsUsbMacBus(can.BusABC): """CAN bus over a gs_usb adapter through libusb (macOS-friendly).""" @@ -96,6 +115,14 @@ def __init__( self._gs.start(_GS_CAN_MODE_LISTEN_ONLY if listen_only else 0) self._flush_rx() + self._rx_queue: queue.Queue[can.Message] = queue.Queue(maxsize=_RX_QUEUE_MAX_FRAMES) + self._rx_dropped = 0 + self._rx_stop = threading.Event() + self._rx_thread = threading.Thread( + target=self._rx_loop, name="gs_usb_rx", daemon=True + ) + self._rx_thread.start() + self.channel_info = f"gs_usb {vendor_id:04x}:{product_id:04x} @ {bitrate}" super().__init__(channel=channel) @@ -127,30 +154,30 @@ def send(self, msg: can.Message, timeout: float | None = None) -> None: hw_ts = bool(self._gs.device_flags & self._hw_timestamp_flag) self._gs.gs_usb.write(self._out_endpoint, frame.pack(hw_ts)) - def _recv_internal(self, timeout: float | None) -> tuple[can.Message | None, bool]: + def _rx_loop(self) -> None: + """Continuously drain the device into the RX queue. + + Runs until shutdown. libusb releases the GIL for the duration of each + blocking read, so this thread keeps the device FIFO empty even while + the SDK control thread and the rest of the process compete for the + interpreter. TX echoes are discarded here so they never consume the + consumer's drain budget. + """ from gs_usb.gs_usb_frame import GsUsbFrame - # python-can treats timeout<=0 as a poll. gs_usb reads block for at - # least 1 ms, so a poll costs up to 1 ms when the queue is empty - # (returns immediately when a frame is pending). The SDK's feedback - # drain relies on recv(timeout=0.0) returning pending frames. - if timeout is not None and timeout <= 0: - timeout = 0.001 - deadline = None if timeout is None else time.perf_counter() + timeout frame = GsUsbFrame() - while True: - if deadline is None: - wait_ms = 1000 - else: - remaining = deadline - time.perf_counter() - if remaining <= 0: - return None, False - wait_ms = max(1, int(remaining * 1000)) - - if not self._gs.read(frame, wait_ms): - if deadline is None: - continue - return None, False + while not self._rx_stop.is_set(): + try: + got = self._gs.read(frame, _RX_READ_TIMEOUT_MS) + except Exception: + if self._rx_stop.is_set(): + return + # Transient libusb error (e.g. device re-enumerating); back + # off briefly instead of spinning. + time.sleep(0.01) + continue + if not got: + continue if frame.echo_id != _GS_USB_NONE_ECHO_ID: continue # our own TX echo, not bus traffic @@ -160,9 +187,36 @@ def _recv_internal(self, timeout: float | None) -> tuple[can.Message | None, boo data=bytes(frame.data[: frame.can_dlc]), dlc=frame.can_dlc, ) - return msg, False + try: + self._rx_queue.put_nowait(msg) + except queue.Full: + # Consumer stalled: drop the oldest frame so the queue holds + # the freshest state. Single producer, so this cannot race + # another put. + with contextlib.suppress(queue.Empty): + self._rx_queue.get_nowait() + self._rx_dropped += 1 + with contextlib.suppress(queue.Full): + self._rx_queue.put_nowait(msg) + + def _recv_internal(self, timeout: float | None) -> tuple[can.Message | None, bool]: + # The SDK's feedback drain calls recv(timeout=0.0) in a tight loop + # with a ~1 ms budget; a true non-blocking pop keeps every call well + # inside that budget. + try: + if timeout is not None and timeout <= 0: + return self._rx_queue.get_nowait(), False + return self._rx_queue.get(timeout=timeout), False + except queue.Empty: + return None, False def shutdown(self) -> None: + self._rx_stop.set() + rx_thread = getattr(self, "_rx_thread", None) + if rx_thread is not None and rx_thread.is_alive(): + rx_thread.join(timeout=1.0) + if self._rx_dropped: + print(f"GsUsbMacBus: dropped {self._rx_dropped} RX frames on queue overflow") try: self._gs.stop() except Exception: From 67c9af6b810403e37739530da2bea9d07fe0d08e Mon Sep 17 00:00:00 2001 From: Kentaro Wu Date: Sun, 19 Jul 2026 22:19:04 +0800 Subject: [PATCH 17/51] fix: replay A1Z teach episodes at natural speed Two changes so a hand-taught demonstration replays at 1.0x instead of being silently time-scaled to a crawl: - Smooth on the uniform resampled grid instead of the raw samples. The recorder's sample spacing is irregular (10-100 ms under load); differentiating linear interpolation across those gaps manufactured acceleration spikes an order of magnitude above the real motion (74 rad/s^2 measured vs ~14 real), which throttled the safety time-scaler and put velocity ripple into the streamed commands. Two moving-average passes give a continuous velocity profile. - Size the replay caps to measured reality: natural hand teaching peaks ~3.5 rad/s (old cap 1.5 rescaled every demo), staying under the SDK's 4.0 rad/s streaming cap and 7-20 rad/s watchdogs. The gripper is commanded through the SDK's own controller (measured ~0.28 m/s, ~9 m/s^2), so its caps now cover the SDK's own motion. Validated on hardware: a 33.5 s natural-speed episode preflights at 1.00x and replays smoothly at 0.5x and 1.0x. --- .../manipulators/galaxea_a1z/teach_replay.py | 60 +++++++++++++------ .../galaxea_a1z/test_teach_replay.py | 3 +- 2 files changed, 43 insertions(+), 20 deletions(-) diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay.py index a5a707dec3..c7ee8519b0 100644 --- a/dimos/robot/manipulators/galaxea_a1z/teach_replay.py +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay.py @@ -43,10 +43,17 @@ _POSITION_LOWER = np.array([-2.094, 0.0, -3.142, -1.484, -1.484, -2.007, 0.0]) _POSITION_UPPER = np.array([2.094, 3.142, 0.0, 1.484, 1.484, 2.007, 0.1]) -# Conservative teach-replay caps. Faster demonstrations are automatically -# time-scaled rather than clipped or rejected. -_REPLAY_VELOCITY_MAX = np.array([1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 0.10]) -_REPLAY_ACCELERATION_MAX = np.array([5.0, 5.0, 5.0, 5.0, 5.0, 5.0, 0.50]) +# Teach-replay caps. Faster demonstrations are automatically time-scaled +# rather than clipped or rejected. Arm values are sized so natural hand +# teaching (peaks ~3.5 rad/s measured on hardware) replays at 1.0x while +# staying under the SDK's 4.0 rad/s streaming cap and its 7-20 rad/s +# per-joint velocity watchdogs. The gripper is not hand-driven: the teach +# CLI commands it through the SDK's own gripper controller, whose measured +# toggle motion reaches ~0.28 m/s and ~9 m/s^2 - the caps cover that with +# margin, since re-streaming what the SDK itself commanded is safe by +# construction (its torque limit bounds the physical motion either way). +_REPLAY_VELOCITY_MAX = np.array([3.5, 3.5, 3.5, 3.5, 3.5, 3.5, 0.4]) +_REPLAY_ACCELERATION_MAX = np.array([25.0, 25.0, 25.0, 25.0, 25.0, 25.0, 15.0]) _APPROACH_VELOCITY_MAX = np.array([0.4, 0.4, 0.4, 0.4, 0.4, 0.4, 0.04]) @@ -155,9 +162,19 @@ def prepare_episode( source_ts = recorded.timestamps _validate_recorded_samples(source_ts, recorded.positions) - smoothed = _moving_average(recorded.positions, source_ts, smoothing_window_s) source_uniform_ts = _uniform_times(float(source_ts[-1]), sample_rate_hz) - source_uniform_q = _interpolate_positions(source_ts, smoothed, source_uniform_ts) + source_uniform_q = _interpolate_positions(source_ts, recorded.positions, source_uniform_ts) + # Smooth on the uniform grid, not the raw samples: the recorder's sample + # spacing is irregular (10-100 ms under load), and differentiating + # linear interpolation across those gaps manufactures acceleration + # spikes an order of magnitude above the real motion, which would both + # trip the safety time-scaler and put velocity ripple into the replayed + # command stream. Two moving-average passes (triangular kernel) give a + # continuous velocity profile, unlike a single pass whose derivative + # still has corners. + source_uniform_q = _smooth_uniform( + source_uniform_q, window=round(smoothing_window_s * sample_rate_hz), passes=2 + ) source_velocity = np.gradient(source_uniform_q, source_uniform_ts, axis=0) source_acceleration = np.gradient(source_velocity, source_uniform_ts, axis=0) @@ -282,26 +299,31 @@ def _validate_positions(positions: NDArray[np.float64], *, context: str) -> None ) -def _moving_average( +def _smooth_uniform( positions: NDArray[np.float64], - timestamps: NDArray[np.float64], - window_s: float, + *, + window: int, + passes: int = 1, ) -> NDArray[np.float64]: - if window_s == 0: - return positions.copy() - median_period = float(np.median(np.diff(timestamps))) - window = max(1, round(window_s / median_period)) + """Zero-phase moving average over uniformly sampled positions.""" + window = min(window, len(positions)) if window % 2 == 0: - window += 1 - if window == 1: + window -= 1 + if window <= 1: return positions.copy() radius = window // 2 kernel = np.ones(window, dtype=np.float64) / window - padded = np.pad(positions, ((radius, radius), (0, 0)), mode="edge") - return np.column_stack( - [np.convolve(padded[:, joint], kernel, mode="valid") for joint in range(positions.shape[1])] - ) + smoothed = positions + for _ in range(passes): + padded = np.pad(smoothed, ((radius, radius), (0, 0)), mode="edge") + smoothed = np.column_stack( + [ + np.convolve(padded[:, joint], kernel, mode="valid") + for joint in range(positions.shape[1]) + ] + ) + return smoothed def _uniform_times(duration: float, rate_hz: float) -> NDArray[np.float64]: diff --git a/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py b/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py index 5e8aefcf9e..acdd8c0307 100644 --- a/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py +++ b/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py @@ -33,6 +33,7 @@ make_a1z_teach_blueprint, ) from dimos.robot.manipulators.galaxea_a1z.teach_replay import ( + _REPLAY_VELOCITY_MAX, A1Z_JOINT_NAMES, RecordedEpisode, build_execution_trajectory, @@ -148,7 +149,7 @@ def test_prepare_smooths_resamples_and_time_scales_fast_motion() -> None: assert prepared.duration > prepared.recorded.timestamps[-1] assert len(prepared.timestamps) > len(positions) assert np.all(np.diff(prepared.timestamps) > 0) - assert np.max(np.abs(prepared.velocities[:, 0])) <= 1.5 + assert np.max(np.abs(prepared.velocities[:, 0])) <= _REPLAY_VELOCITY_MAX[0] def test_execution_trajectory_approaches_then_replays_all_seven_joints() -> None: From f48249f00c17e7f7b1c34cc7a836662722fd9eff Mon Sep 17 00:00:00 2001 From: Kentaro Wu Date: Sun, 19 Jul 2026 22:25:29 +0800 Subject: [PATCH 18/51] feat: polish A1Z teach mode UX and denoise recording session Single-purpose keys with a persistent status line: SPACE starts and saves episodes, g toggles the gripper, d discards, q quits with a save/discard confirmation when an episode is in progress. Denoise the session by fixing root causes: publish the camera transform immediately at start (first-second frames also gain poses), rate-limit the recorder's poseless-message warning, drop in-flight recording callbacks during shutdown to avoid the closed-database error, and treat disabling an already-disconnected A1Z adapter as success. Co-Authored-By: Claude Fable 5 --- .../manipulators/galaxea_a1z/adapter.py | 4 +- dimos/hardware/sensors/camera/module.py | 4 + dimos/memory2/module.py | 34 +++++- .../galaxea_a1z/teach_replay_cli.py | 105 +++++++++++------- 4 files changed, 102 insertions(+), 45 deletions(-) diff --git a/dimos/hardware/manipulators/galaxea_a1z/adapter.py b/dimos/hardware/manipulators/galaxea_a1z/adapter.py index 334626817a..d722607fa0 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/adapter.py @@ -456,7 +456,9 @@ def write_enable(self, enable: bool) -> bool: SAFETY: disabling powers off motors and the arm falls freely. """ if not self._robot: - return False + # Disabling with no robot is a no-op success (already torn down); + # enabling still requires a connection. + return not enable try: if enable: diff --git a/dimos/hardware/sensors/camera/module.py b/dimos/hardware/sensors/camera/module.py index 0fe0d8f030..e65b1daefd 100644 --- a/dimos/hardware/sensors/camera/module.py +++ b/dimos/hardware/sensors/camera/module.py @@ -81,6 +81,10 @@ def on_image(image: Image) -> None: stream.subscribe(on_image), ) + # Publish the transform immediately, not only on the 1 Hz timer; + # otherwise every image in the first second is recorded before the + # camera frame exists in tf and gets stored without a pose. + self.publish_metadata() self.register_disposable( rx.interval(1.0).subscribe(lambda _: self.publish_metadata()), ) diff --git a/dimos/memory2/module.py b/dimos/memory2/module.py index e16c1527e7..c773d58bcf 100644 --- a/dimos/memory2/module.py +++ b/dimos/memory2/module.py @@ -318,6 +318,8 @@ async def _lidar_pose(self, msg): config: RecorderConfig _pose_setters: dict[str, Any] = {} + _closing: bool = False + _poseless_counts: dict[str, int] = {} @rpc def start(self) -> None: @@ -380,19 +382,39 @@ def _port_to_stream(self, name: str, input_topic: In[Any], stream: Stream[Any]) """ async def on_msg(msg: Any) -> None: + if self._closing: + return ts = self._resolve_ts(name, msg) pose = await self._resolve_pose(name, msg, ts) if not pose: - logger.warning( - "[%s] No pose for time %s (msg ts: %s), storing without pose", - name, - ts, - getattr(msg, "ts", None), - ) + # Warn on the first poseless message per stream, then every + # 100th - a missing tf otherwise floods the console at frame + # rate (e.g. during the pre-first-tf startup window). + count = self._poseless_counts.get(name, 0) + 1 + self._poseless_counts[name] = count + if count == 1 or count % 100 == 0: + logger.warning( + "[%s] No pose for time %s (msg ts: %s), storing without pose " + "(%d poseless message(s) so far; repeats logged every 100th)", + name, + ts, + getattr(msg, "ts", None), + count, + ) + if self._closing: + return stream.append(msg, ts=ts, pose=pose) self.process_observable(input_topic.pure_observable(), on_msg) + @rpc + def stop(self) -> None: + # Drop in-flight recording callbacks before the store closes; without + # this, a message dispatched just before shutdown races the SQLite + # close and surfaces as "Cannot operate on a closed database". + self._closing = True + super().stop() + def _prepare_streams(self) -> None: """On APPEND, drop the streams this recorder is about to (re)write — the remapped In-port streams plus ``tf`` — so a re-run replaces them instead diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py index afec4e6067..009bc0351d 100644 --- a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py @@ -114,12 +114,30 @@ def teach( if gripper_free_drive: typer.echo("Gripper: free drive (open and close it by hand).") else: - typer.echo("Gripper: powered; type g then ENTER to toggle open/closed.") + typer.echo("Gripper: powered; press g to toggle open/closed.") typer.echo("Keep the arm supported: it has no brakes and can fall when motors disable.\n") coordinator: ModuleCoordinator | None = None recording = False gripper_open: bool | None = None + saved_count = 0 + episode_started_at = 0.0 + + def _status() -> str: + if gripper_free_drive: + gripper = "free-drive" + elif gripper_open is None: + gripper = "?" + else: + gripper = "open" if gripper_open else "closed" + if recording: + elapsed = time.monotonic() - episode_started_at + state = f"RECORDING {int(elapsed // 60)}:{int(elapsed % 60):02d}" + keys = "SPACE save · g gripper · d discard · q quit" + else: + state = "IDLE" + keys = "SPACE record · g gripper · q quit" + return f"[{state} | saved: {saved_count} | gripper: {gripper}] {keys}" try: coordinator = ModuleCoordinator.build( @@ -147,49 +165,60 @@ def _toggle_gripper() -> None: target = _GRIPPER_OPEN_M if target_open else _GRIPPER_CLOSED_M if control.set_gripper_position(_TEACH_HARDWARE_ID, target): gripper_open = target_open - typer.echo(f"Gripper {'opening' if target_open else 'closing'}.") + typer.echo(f">> gripper {'opening' if target_open else 'closing'}") else: - typer.echo("Gripper command rejected; check hardware state.", err=True) + typer.echo(">> gripper command rejected; check hardware state", err=True) while True: - if not recording: - command = _read_key( - "Press ENTER to start an episode, g to toggle the gripper, or q to finish" - ) - if command == "q": - break - if command == "g": - _toggle_gripper() - continue - if command: - typer.echo("Use ENTER to start, g for the gripper, or q to finish.") - continue - monitor.start_episode() - recording = True - typer.echo("RECORDING — move the arm by hand; g toggles the gripper.") - continue + command = _read_key(_status()) - command = _read_key( - "Press ENTER to save, g to toggle the gripper, d to discard, " - "or q to discard and finish" - ) if command == "g": _toggle_gripper() - elif command == "d": - monitor.discard_episode() - recording = False - typer.echo("Episode discarded.") - elif command == "q": - monitor.discard_episode() - recording = False - typer.echo("Active episode discarded.") - break - elif not command: - status = monitor.save_episode() - recording = False - typer.echo(f"Episode saved ({status.episodes_saved} total).") - else: - typer.echo("Use ENTER to save, g for the gripper, d to discard, or q to finish.") + continue + + if command == " " or command == "": + # SPACE is the documented key; bare ENTER does the same thing + # so either habit works. + if not recording: + monitor.start_episode() + recording = True + episode_started_at = time.monotonic() + typer.echo(">> episode started - move the arm by hand") + else: + status = monitor.save_episode() + recording = False + saved_count = status.episodes_saved + typer.echo(f">> episode saved ({saved_count} total)") + continue + + if command == "d": + if recording: + monitor.discard_episode() + recording = False + typer.echo(">> episode discarded") + else: + typer.echo(">> nothing to discard (not recording)") + continue + + if command == "q": + if not recording: + break + choice = _read_key("Episode in progress - s to save it, d to discard it, or any other key to keep recording") + if choice == "s": + status = monitor.save_episode() + recording = False + saved_count = status.episodes_saved + typer.echo(f">> episode saved ({saved_count} total)") + break + if choice == "d": + monitor.discard_episode() + recording = False + typer.echo(">> episode discarded") + break + typer.echo(">> still recording") + continue + + typer.echo(f">> unrecognized key {command!r}") except KeyboardInterrupt: if coordinator is not None and recording: monitor = coordinator.get_instance(EpisodeMonitorModule) From 759431e24cbc8e8b31c32d186875c3de1bf84fb9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:26:55 +0000 Subject: [PATCH 19/51] [autofix.ci] apply automated fixes --- dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py | 4 +--- dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py | 4 +++- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py b/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py index cb7ac248e5..47bf5945b1 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py +++ b/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py @@ -118,9 +118,7 @@ def __init__( self._rx_queue: queue.Queue[can.Message] = queue.Queue(maxsize=_RX_QUEUE_MAX_FRAMES) self._rx_dropped = 0 self._rx_stop = threading.Event() - self._rx_thread = threading.Thread( - target=self._rx_loop, name="gs_usb_rx", daemon=True - ) + self._rx_thread = threading.Thread(target=self._rx_loop, name="gs_usb_rx", daemon=True) self._rx_thread.start() self.channel_info = f"gs_usb {vendor_id:04x}:{product_id:04x} @ {bitrate}" diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py index 009bc0351d..109c1063e0 100644 --- a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py @@ -203,7 +203,9 @@ def _toggle_gripper() -> None: if command == "q": if not recording: break - choice = _read_key("Episode in progress - s to save it, d to discard it, or any other key to keep recording") + choice = _read_key( + "Episode in progress - s to save it, d to discard it, or any other key to keep recording" + ) if choice == "s": status = monitor.save_episode() recording = False From 43593f0e170a2defbd3ce02eab20ef6c29bb95e8 Mon Sep 17 00:00:00 2001 From: Jetson Wu Date: Sun, 19 Jul 2026 23:26:36 +0800 Subject: [PATCH 20/51] feat: run LeRobot policies on A1Z --- dimos/learning/README.md | 36 +- dimos/learning/lerobot_policy.py | 402 ++++++ dimos/learning/test_lerobot_policy.py | 175 +++ .../robot/manipulators/galaxea_a1z/README.md | 33 + .../galaxea_a1z/blueprints/basic.py | 77 +- .../galaxea_a1z/teach_replay_cli.py | 101 ++ .../galaxea_a1z/test_teach_replay.py | 27 + pyproject.toml | 13 + uv.lock | 1087 +++++++++++++---- 9 files changed, 1669 insertions(+), 282 deletions(-) create mode 100644 dimos/learning/lerobot_policy.py create mode 100644 dimos/learning/test_lerobot_policy.py diff --git a/dimos/learning/README.md b/dimos/learning/README.md index be40ff2d9b..ac263416e5 100644 --- a/dimos/learning/README.md +++ b/dimos/learning/README.md @@ -4,7 +4,7 @@ End-to-end: teleoperate an arm, record episodes to a session DB, then convert that DB into a LeRobot or HDF5 dataset for imitation learning. ``` -teleop (Quest) ─▶ CollectionRecorder ─▶ session__.db ─▶ dimos dataprep ─▶ dataset +teleop / hand-teach ─▶ CollectionRecorder ─▶ session.db ─▶ dimos dataprep ─▶ dataset ``` --- @@ -119,3 +119,37 @@ working example. The fields that matter: true commanded actions you'd record `joint_command` and map `action` to it. - **Old vs new sessions** — recordings made before the `coordinator_joint_state` rename use the old stream name; point a matching config at them, or re-record. + +--- + +## 4. Train and execute a policy + +Install the optional LeRobot integration. DimOS pins LeRobot 0.4.4 because it +is the newest release compatible with the repository's Transformers 4.53 +stack; both the trainer and live module use LeRobot's upstream policy and +processor APIs. + +```bash +uv sync --extra lerobot +``` + +Train ACT directly from a local DataPrep output directory: + +```bash +uv run lerobot-train \ + --dataset.repo_id=galaxea_a1z \ + --dataset.root=./a1z_lerobot_dataset \ + --policy.type=act \ + --policy.device=cuda \ + --policy.push_to_hub=false \ + --output_dir=outputs/a1z_act \ + --job_name=a1z_act \ + --wandb.enable=false +``` + +The deployable checkpoint is written under +`outputs/a1z_act/checkpoints/last/pretrained_model`. A robot blueprint can +compose `LeRobotPolicyModule` with a camera, joint-state source, and joint +command consumer. The module does not move hardware at startup; its +`execute_learned_policy` background skill starts inference explicitly, and +`stop_learned_policy` stops it while the robot holds the final command. diff --git a/dimos/learning/lerobot_policy.py b/dimos/learning/lerobot_policy.py new file mode 100644 index 0000000000..255f2a968d --- /dev/null +++ b/dimos/learning/lerobot_policy.py @@ -0,0 +1,402 @@ +# Copyright 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. + +"""Run a trained LeRobot policy against live DimOS observations.""" + +from __future__ import annotations + +from contextlib import nullcontext +from copy import copy +from importlib import import_module +from threading import Event, RLock, Thread, current_thread +import time +from typing import Any, Protocol + +import numpy as np +from numpy.typing import NDArray +from pydantic import Field +from reactivex.disposable import Disposable + +from dimos.agents.annotation import skill +from dimos.agents.capabilities import CAP_MOVEMENT +from dimos.constants import DEFAULT_THREAD_JOIN_TIMEOUT +from dimos.core.core import rpc +from dimos.core.module import Module, ModuleConfig +from dimos.core.stream import In, Out +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.utils.logging_config import setup_logger + +logger = setup_logger() + +_IMAGE_FEATURE = "observation.images.image" +_STATE_FEATURE = "observation.state" +_ACTION_FEATURE = "action" +_TOOL_NAME = "execute_learned_policy" + + +class PolicyBackend(Protocol): + """Minimal inference interface, separated so module behavior is testable without torch.""" + + def reset(self) -> None: ... + + def predict( + self, + image: NDArray[np.uint8], + state: NDArray[np.float32], + *, + task: str, + robot_type: str, + ) -> NDArray[np.float32]: ... + + +class LeRobotPolicyModuleConfig(ModuleConfig): + policy_path: str + joint_names: list[str] = Field(min_length=1) + fps: float = Field(default=15.0, gt=0) + task: str = "" + robot_type: str = "" + device: str | None = None + max_observation_age_s: float = Field(default=0.5, gt=0) + + +class _LeRobotBackend: + """Lazy LeRobot/torch adapter using the upstream inference pipeline.""" + + def __init__(self, config: LeRobotPolicyModuleConfig) -> None: + try: + torch = import_module("torch") + PreTrainedConfig = import_module("lerobot.configs.policies").PreTrainedConfig + policy_factory = import_module("lerobot.policies.factory") + get_policy_class = policy_factory.get_policy_class + make_pre_post_processors = policy_factory.make_pre_post_processors + prepare_observation_for_inference = import_module( + "lerobot.policies.utils" + ).prepare_observation_for_inference + register_third_party_plugins = import_module( + "lerobot.utils.import_utils" + ).register_third_party_plugins + except ImportError as exc: + raise ImportError( + "LeRobot policy inference is not installed. Run `uv sync --extra lerobot`." + ) from exc + + register_third_party_plugins() + policy_config = PreTrainedConfig.from_pretrained(config.policy_path) + if config.device is not None: + policy_config.device = config.device + if policy_config.device is None: + raise RuntimeError("LeRobot did not resolve an inference device") + + self._validate_features(policy_config, len(config.joint_names)) + self._device = torch.device(policy_config.device) + if self._device.type == "cuda" and not torch.cuda.is_available(): + raise RuntimeError( + f"Policy requested device {policy_config.device!r}, but CUDA is not available" + ) + + policy_class = get_policy_class(policy_config.type) + self._policy = policy_class.from_pretrained(config.policy_path, config=policy_config) + self._preprocessor, self._postprocessor = make_pre_post_processors( + policy_cfg=policy_config, + pretrained_path=config.policy_path, + preprocessor_overrides={"device_processor": {"device": str(self._device)}}, + ) + self._prepare_observation = prepare_observation_for_inference + self._torch = torch + self._use_amp = bool(policy_config.use_amp) + + @staticmethod + def _validate_features(policy_config: Any, joint_count: int) -> None: + inputs = policy_config.input_features or {} + outputs = policy_config.output_features or {} + missing = {_IMAGE_FEATURE, _STATE_FEATURE} - set(inputs) + if missing: + raise ValueError( + "Policy is incompatible with the DimOS single-camera runtime; " + f"missing input features: {sorted(missing)}" + ) + if _ACTION_FEATURE not in outputs: + raise ValueError(f"Policy has no {_ACTION_FEATURE!r} output feature") + + state_shape = tuple(inputs[_STATE_FEATURE].shape) + action_shape = tuple(outputs[_ACTION_FEATURE].shape) + if not state_shape or state_shape[0] != joint_count: + raise ValueError( + f"Policy state dimension {state_shape} does not match {joint_count} configured joints" + ) + if not action_shape or action_shape[0] != joint_count: + raise ValueError( + f"Policy action dimension {action_shape} does not match {joint_count} configured joints" + ) + + def reset(self) -> None: + self._policy.reset() + self._preprocessor.reset() + self._postprocessor.reset() + + def predict( + self, + image: NDArray[np.uint8], + state: NDArray[np.float32], + *, + task: str, + robot_type: str, + ) -> NDArray[np.float32]: + observation: dict[str, NDArray[Any]] = { + _IMAGE_FEATURE: image, + _STATE_FEATURE: state, + } + torch = self._torch + with ( + torch.inference_mode(), + torch.autocast(device_type="cuda") + if self._device.type == "cuda" and self._use_amp + else nullcontext(), + ): + prepared = self._prepare_observation( + copy(observation), + self._device, + task=task, + robot_type=robot_type, + ) + prepared = self._preprocessor(prepared) + action = self._policy.select_action(prepared) + action = self._postprocessor(action) + return np.asarray(action.squeeze(0).to("cpu").numpy(), dtype=np.float32) + + +def _load_policy_backend(config: LeRobotPolicyModuleConfig) -> PolicyBackend: + return _LeRobotBackend(config) + + +class LeRobotPolicyModule(Module): + """Convert live image and joint state observations into streaming joint targets.""" + + dedicated_worker = True + config: LeRobotPolicyModuleConfig + + color_image: In[Image] + coordinator_joint_state: In[JointState] + joint_command: Out[JointState] + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + if len(set(self.config.joint_names)) != len(self.config.joint_names): + raise ValueError("joint_names must not contain duplicates") + self._lock = RLock() + self._backend: PolicyBackend | None = None + self._latest_image: tuple[NDArray[np.uint8], float] | None = None + self._latest_joint_state: JointState | None = None + self._stop_event = Event() + self._thread: Thread | None = None + self._commands_sent = 0 + self._last_error: str | None = None + self._active_task = self.config.task + + @rpc + def build(self) -> None: + """Load the checkpoint before hardware modules are started.""" + if self._backend is None: + self._backend = _load_policy_backend(self.config) + logger.info("Loaded LeRobot policy from %s", self.config.policy_path) + + @rpc + def start(self) -> None: + super().start() + self.register_disposable(Disposable(self.color_image.subscribe(self._on_color_image))) + self.register_disposable( + Disposable(self.coordinator_joint_state.subscribe(self._on_joint_state)) + ) + + @rpc + def stop(self) -> None: + self._stop_policy() + super().stop() + + @skill(uses=[CAP_MOVEMENT], lifecycle="background") + def execute_learned_policy(self, duration: float = 10.0, task: str = "") -> str: + """Execute the loaded learned policy against the live camera and robot state. + + Args: + duration: Maximum execution time in seconds. + task: Optional task prompt; defaults to the module's configured task. + """ + self.start_tool(_TOOL_NAME) + background_launched = False + try: + if duration <= 0: + return "Duration must be greater than zero." + with self._lock: + if self._thread is not None and self._thread.is_alive(): + background_launched = True + return "The learned policy is already running." + self._snapshot_observation(time.time()) + if self._backend is None: + return "The learned policy has not been loaded." + self._backend.reset() + self._stop_event.clear() + self._commands_sent = 0 + self._last_error = None + self._active_task = task or self.config.task + self._thread = Thread( + target=self._run_policy, + args=(duration, self._active_task), + name="lerobot-policy", + daemon=True, + ) + self._thread.start() + background_launched = True + return ( + f"Learned policy started for up to {duration:.1f}s. " + "Use stop_learned_policy to stop early." + ) + except Exception as exc: + with self._lock: + self._last_error = str(exc) + return f"Learned policy did not start: {exc}" + finally: + if not background_launched: + self.stop_tool(_TOOL_NAME) + + @skill + def stop_learned_policy(self) -> str: + """Stop the running learned policy and hold the last commanded pose.""" + was_running = self._stop_policy() + return "Learned policy stopped." if was_running else "Learned policy was not running." + + @rpc + def policy_status(self) -> dict[str, Any]: + """Return live execution status for CLIs and monitoring.""" + with self._lock: + running = self._thread is not None and self._thread.is_alive() + observation_error: str | None = None + try: + self._snapshot_observation(time.time()) + except RuntimeError as exc: + observation_error = str(exc) + return { + "running": running, + "observations_ready": observation_error is None, + "observation_error": observation_error, + "policy_path": self.config.policy_path, + "task": self._active_task, + "commands_sent": self._commands_sent, + "last_error": self._last_error, + } + + def _on_color_image(self, image: Image) -> None: + rgb = image.to_rgb() + if rgb.format != ImageFormat.RGB or rgb.data.dtype != np.uint8: + logger.warning("Ignoring non-uint8 RGB policy image: %s", image) + return + if rgb.data.ndim != 3 or rgb.data.shape[2] != 3: + logger.warning("Ignoring policy image with unexpected shape: %s", rgb.data.shape) + return + with self._lock: + self._latest_image = (np.ascontiguousarray(rgb.data), rgb.ts) + + def _on_joint_state(self, state: JointState) -> None: + with self._lock: + self._latest_joint_state = JointState(state) + + def _snapshot_observation(self, now: float) -> tuple[NDArray[np.uint8], NDArray[np.float32]]: + if self._latest_image is None: + raise RuntimeError("no camera image has been received") + if self._latest_joint_state is None: + raise RuntimeError("no coordinator joint state has been received") + + image, image_ts = self._latest_image + state = self._latest_joint_state + max_age = self.config.max_observation_age_s + if now - image_ts > max_age: + raise RuntimeError(f"camera image is stale by {now - image_ts:.2f}s") + if now - state.ts > max_age: + raise RuntimeError(f"joint state is stale by {now - state.ts:.2f}s") + + positions = dict(zip(state.name, state.position, strict=False)) + missing = [name for name in self.config.joint_names if name not in positions] + if missing: + raise RuntimeError(f"joint state is missing configured joints: {missing}") + vector = np.asarray( + [positions[name] for name in self.config.joint_names], + dtype=np.float32, + ) + if not np.all(np.isfinite(vector)): + raise RuntimeError("joint state contains non-finite positions") + return image.copy(), vector + + def _run_policy(self, duration: float, task: str) -> None: + period = 1.0 / self.config.fps + deadline = time.monotonic() + duration + next_progress = time.monotonic() + 1.0 + try: + backend = self._backend + if backend is None: + raise RuntimeError("policy backend is not loaded") + while not self._stop_event.is_set() and time.monotonic() < deadline: + tick_started = time.monotonic() + with self._lock: + image, state = self._snapshot_observation(time.time()) + action = np.asarray( + backend.predict( + image, + state, + task=task, + robot_type=self.config.robot_type, + ), + dtype=np.float32, + ).reshape(-1) + if action.shape != (len(self.config.joint_names),): + raise RuntimeError( + f"policy returned {action.shape}, expected " + f"({len(self.config.joint_names)},)" + ) + if not np.all(np.isfinite(action)): + raise RuntimeError("policy returned non-finite joint targets") + if self._stop_event.is_set() or time.monotonic() >= deadline: + break + + self.joint_command.publish( + JointState( + name=list(self.config.joint_names), + position=action.astype(float).tolist(), + ) + ) + with self._lock: + self._commands_sent += 1 + commands_sent = self._commands_sent + now = time.monotonic() + if now >= next_progress: + self.tool_update(_TOOL_NAME, f"Executed {commands_sent} policy steps") + next_progress = now + 1.0 + self._stop_event.wait(max(0.0, period - (time.monotonic() - tick_started))) + except Exception as exc: + with self._lock: + self._last_error = str(exc) + logger.exception("LeRobot policy execution stopped: %s", exc) + self.tool_update(_TOOL_NAME, f"Policy stopped: {exc}") + finally: + self._stop_event.set() + self.stop_tool(_TOOL_NAME) + + def _stop_policy(self) -> bool: + with self._lock: + thread = self._thread + was_running = thread is not None and thread.is_alive() + self._stop_event.set() + if thread is not None and thread is not current_thread(): + thread.join(timeout=DEFAULT_THREAD_JOIN_TIMEOUT) + self.stop_tool(_TOOL_NAME) + return was_running diff --git a/dimos/learning/test_lerobot_policy.py b/dimos/learning/test_lerobot_policy.py new file mode 100644 index 0000000000..c91625c410 --- /dev/null +++ b/dimos/learning/test_lerobot_policy.py @@ -0,0 +1,175 @@ +# Copyright 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. + +"""Unit tests for the live LeRobot policy module.""" + +from __future__ import annotations + +from collections.abc import Callable, Iterator +from threading import Event +import time +from typing import Any + +import numpy as np +import pytest +import pytest_mock + +from dimos.learning.lerobot_policy import LeRobotPolicyModule +from dimos.msgs.sensor_msgs.Image import Image, ImageFormat +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.protocol.rpc.pubsubrpc import LCMRPC + +JOINTS = [f"arm/joint{i}" for i in range(1, 7)] + ["arm/gripper"] + + +class FakeBackend: + def __init__(self, action: np.ndarray[Any, Any]) -> None: + self.action = action + self.called = Event() + self.reset_count = 0 + self.image: np.ndarray[Any, Any] | None = None + self.state: np.ndarray[Any, Any] | None = None + self.task = "" + self.robot_type = "" + + def reset(self) -> None: + self.reset_count += 1 + + def predict( + self, + image: np.ndarray[Any, Any], + state: np.ndarray[Any, Any], + *, + task: str, + robot_type: str, + ) -> np.ndarray[Any, Any]: + self.image = image + self.state = state + self.task = task + self.robot_type = robot_type + self.called.set() + return self.action + + +class CapturingOutput: + def __init__(self) -> None: + self.messages: list[JointState] = [] + self.published = Event() + + def publish(self, message: JointState) -> None: + self.messages.append(message) + self.published.set() + + +@pytest.fixture +def make_module( + mocker: pytest_mock.MockerFixture, +) -> Iterator[Callable[[FakeBackend], tuple[LeRobotPolicyModule, CapturingOutput, Event]]]: + mocker.patch("dimos.core.module.get_loop", return_value=(mocker.MagicMock(), None)) + mocker.patch.object(LCMRPC, "__init__", return_value=None) + mocker.patch.object(LCMRPC, "serve_module_rpc", return_value=None) + mocker.patch.object(LCMRPC, "start", return_value=None) + mocker.patch.object(LCMRPC, "stop", return_value=None) + + built: list[LeRobotPolicyModule] = [] + + def _make(backend: FakeBackend) -> tuple[LeRobotPolicyModule, CapturingOutput, Event]: + mocker.patch("dimos.learning.lerobot_policy._load_policy_backend", return_value=backend) + module = LeRobotPolicyModule( + policy_path="checkpoint", + joint_names=JOINTS, + fps=50.0, + task="default task", + robot_type="galaxea_a1z", + ) + output = CapturingOutput() + finished = Event() + module.joint_command = output # type: ignore[assignment] + mocker.patch.object(module, "start_tool") + mocker.patch.object(module, "tool_update") + mocker.patch.object(module, "stop_tool", side_effect=lambda _name: finished.set()) + module.build() + built.append(module) + return module, output, finished + + yield _make + for module in built: + module.stop() + + +def _provide_observation(module: LeRobotPolicyModule) -> tuple[np.ndarray[Any, Any], list[float]]: + # BGR input verifies that the module supplies the RGB convention used by DataPrep. + bgr = np.zeros((4, 5, 3), dtype=np.uint8) + bgr[..., 0] = 10 + bgr[..., 1] = 20 + bgr[..., 2] = 30 + positions = [float(i) / 10 for i in range(len(JOINTS))] + now = time.time() + module._on_color_image(Image(data=bgr, format=ImageFormat.BGR, ts=now)) + module._on_joint_state(JointState(ts=now, name=JOINTS, position=positions)) + return bgr, positions + + +def test_policy_observation_and_action_use_canonical_order( + make_module: Callable[[FakeBackend], tuple[LeRobotPolicyModule, CapturingOutput, Event]], +) -> None: + action = np.arange(len(JOINTS), dtype=np.float32) / 20 + backend = FakeBackend(action) + module, output, _finished = make_module(backend) + bgr, positions = _provide_observation(module) + + result = module.execute_learned_policy(duration=1.0, task="pick up cube") + + assert "started" in result.lower() + assert output.published.wait(1.0), "policy did not publish a command" + assert backend.reset_count == 1 + assert backend.task == "pick up cube" + assert backend.robot_type == "galaxea_a1z" + assert backend.image is not None + np.testing.assert_array_equal(backend.image, bgr[..., ::-1]) + np.testing.assert_allclose(backend.state, positions) + assert output.messages[0].name == JOINTS + np.testing.assert_allclose(output.messages[0].position, action) + module.stop_learned_policy() + + +def test_invalid_policy_action_stops_without_publishing( + make_module: Callable[[FakeBackend], tuple[LeRobotPolicyModule, CapturingOutput, Event]], +) -> None: + backend = FakeBackend(np.zeros(len(JOINTS) - 1, dtype=np.float32)) + module, output, finished = make_module(backend) + _provide_observation(module) + + module.execute_learned_policy(duration=1.0) + + assert backend.called.wait(1.0), "policy was not invoked" + assert finished.wait(1.0), "policy thread did not stop after invalid output" + assert output.messages == [] + status = module.policy_status() + assert status["running"] is False + assert "expected (7,)" in status["last_error"] + + +def test_policy_refuses_to_start_without_live_observations( + make_module: Callable[[FakeBackend], tuple[LeRobotPolicyModule, CapturingOutput, Event]], +) -> None: + backend = FakeBackend(np.zeros(len(JOINTS), dtype=np.float32)) + module, output, _finished = make_module(backend) + + result = module.execute_learned_policy(duration=1.0) + + assert "no camera image" in result + assert backend.reset_count == 0 + assert output.messages == [] + assert module.policy_status()["running"] is False diff --git a/dimos/robot/manipulators/galaxea_a1z/README.md b/dimos/robot/manipulators/galaxea_a1z/README.md index 2a9f15ae28..d3f4368916 100644 --- a/dimos/robot/manipulators/galaxea_a1z/README.md +++ b/dimos/robot/manipulators/galaxea_a1z/README.md @@ -83,3 +83,36 @@ uv run dimos dataprep inspect ./a1z_lerobot_dataset The LeRobot output stores images as `observation.images.image`, the measured arm and gripper state as `observation.state`, and the next measured state as `action`. + +Install the optional LeRobot trainer/runtime before running the A1Z SDK setup +(an exact `uv sync` removes packages installed outside the project lock): + +```bash +uv sync --extra lerobot +./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh --sdk-only +``` + +Train an ACT checkpoint from the converted local dataset: + +```bash +uv run lerobot-train \ + --dataset.repo_id=galaxea_a1z \ + --dataset.root=./a1z_lerobot_dataset \ + --policy.type=act \ + --policy.device=cuda \ + --policy.push_to_hub=false \ + --output_dir=outputs/a1z_act \ + --job_name=a1z_act \ + --wandb.enable=false +``` + +After `setup_a1z_can.sh` passes, run the trained policy. Loading and hardware +initialization require confirmation, and inference starts only after live RGB +and seven-joint observations are ready: + +```bash +uv run dimos a1z run-policy \ + outputs/a1z_act/checkpoints/last/pretrained_model \ + --task "pick up the object" \ + --duration 20 +``` diff --git a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py index da7ef7a670..e9833991ec 100644 --- a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py +++ b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py @@ -25,12 +25,14 @@ from dimos.hardware.sensors.camera.webcam import Webcam from dimos.learning.collection.episode_monitor import EpisodeMonitorModule from dimos.learning.collection.recorder import CollectionRecorder +from dimos.learning.lerobot_policy import LeRobotPolicyModule from dimos.memory2.module import OnExisting from dimos.msgs.geometry_msgs.Transform import Transform from dimos.robot.manipulators.a1z.config import A1Z_G1Z_MODEL_PATH from dimos.robot.manipulators.galaxea_a1z.config import galaxea_a1z_hardware A1Z_REPLAY_TASK_NAME = "teach_replay_arm" +A1Z_POLICY_TASK_NAME = "lerobot_servo_arm" A1Z_TEACH_CAMERA_WIDTH = 640 A1Z_TEACH_CAMERA_HEIGHT = 480 A1Z_TEACH_CAMERA_FPS = 15.0 @@ -56,6 +58,25 @@ ) +def _a1z_camera(camera_index: int) -> Blueprint: + return CameraModule.blueprint( + hardware=partial( + Webcam, + camera_index=camera_index, + width=A1Z_TEACH_CAMERA_WIDTH, + height=A1Z_TEACH_CAMERA_HEIGHT, + fps=A1Z_TEACH_CAMERA_FPS, + ), + # Placeholder until the hackathon camera mount is calibrated. + # Learned image policies do not consume this transform, but the + # recorder and Rerun still require a connected frame tree. + transform=Transform( + frame_id="coordinator", + child_frame_id="camera_link", + ), + ) + + def make_a1z_teach_blueprint( db_path: Path, *, @@ -90,22 +111,7 @@ def make_a1z_teach_blueprint( tf_tolerance=1.5, record_tf=False, ), - CameraModule.blueprint( - hardware=partial( - Webcam, - camera_index=camera_index, - width=A1Z_TEACH_CAMERA_WIDTH, - height=A1Z_TEACH_CAMERA_HEIGHT, - fps=A1Z_TEACH_CAMERA_FPS, - ), - # Placeholder until the hackathon camera mount is calibrated. - # Learned image policies do not consume this transform, but the - # recorder and Rerun still require a connected frame tree. - transform=Transform( - frame_id="coordinator", - child_frame_id="camera_link", - ), - ), + _a1z_camera(camera_index), ) @@ -129,3 +135,42 @@ def make_a1z_replay_blueprint() -> Blueprint: ], ) ) + + +def make_a1z_policy_blueprint( + policy_path: str, + *, + task: str = "", + camera_index: int = 0, + device: str | None = None, + fps: float = A1Z_TEACH_CAMERA_FPS, +) -> Blueprint: + """Run one trained LeRobot policy against the live A1Z camera and state.""" + hardware = galaxea_a1z_hardware( + "arm", + gripper=True, + dynamics_urdf_path=_A1Z_DYNAMICS_URDF, + ) + return autoconnect( + ControlCoordinator.blueprint( + hardware=[hardware], + tasks=[ + TaskConfig( + name=A1Z_POLICY_TASK_NAME, + type="servo", + joint_names=hardware.all_joints, + priority=10, + params={"timeout": max(1.0, 3.0 / fps)}, + ) + ], + ), + LeRobotPolicyModule.blueprint( + policy_path=policy_path, + joint_names=hardware.all_joints, + fps=fps, + task=task, + robot_type="galaxea_a1z", + device=device, + ), + _a1z_camera(camera_index), + ) diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py index 109c1063e0..6b1a35711c 100644 --- a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py @@ -337,3 +337,104 @@ def replay( except (KeyboardInterrupt, EOFError): pass coordinator.stop() + + +@app.command("run-policy") +def run_policy( + checkpoint: str = typer.Argument( + ..., + help="Local LeRobot pretrained_model directory or Hugging Face model ID", + ), + task: str = typer.Option("", "--task", help="Task prompt supplied to the policy"), + duration: float = typer.Option( + 10.0, + "--duration", + min=0.1, + help="Maximum policy execution time in seconds", + ), + camera_index: int = typer.Option( + 0, + "--camera-index", + min=0, + help="Linux camera index N for /dev/videoN", + ), + device: str | None = typer.Option( + None, + "--device", + help="Torch device override, for example cuda or cpu", + ), +) -> None: + """Execute a trained LeRobot policy on the live A1Z.""" + from dimos.core.coordination.module_coordinator import ModuleCoordinator + from dimos.learning.lerobot_policy import LeRobotPolicyModule + from dimos.robot.manipulators.galaxea_a1z.blueprints.basic import ( + make_a1z_policy_blueprint, + ) + + local_checkpoint = Path(checkpoint).expanduser() + policy_path = str(local_checkpoint.resolve()) if local_checkpoint.exists() else checkpoint + + typer.echo("A1Z learned-policy execution") + typer.echo(f"Checkpoint: {policy_path}") + typer.echo(f"Camera: /dev/video{camera_index} (640x480 at 15 FPS)") + typer.echo(f"Maximum execution: {duration:.1f}s") + typer.echo("The arm has no brakes. Support it during startup and keep the workspace clear.\n") + if not typer.confirm("Load the policy and initialize the robot?", default=False): + typer.echo("Policy execution cancelled.") + return + + coordinator: ModuleCoordinator | None = None + policy: Any = None + try: + coordinator = ModuleCoordinator.build( + make_a1z_policy_blueprint( + policy_path, + task=task, + camera_index=camera_index, + device=device, + ), + {}, + ) + policy = coordinator.get_instance(LeRobotPolicyModule) + observation_deadline = time.monotonic() + 5.0 + while time.monotonic() < observation_deadline: + status = policy.policy_status() + if status["observations_ready"]: + break + time.sleep(0.1) + else: + raise RuntimeError( + f"live policy observations did not become ready: {status['observation_error']}" + ) + result = policy.execute_learned_policy(duration, task) + typer.echo(result) + if "started" not in result.lower(): + raise RuntimeError(result) + + deadline = time.monotonic() + duration + 5.0 + while time.monotonic() < deadline: + status = policy.policy_status() + if not status["running"]: + if status["last_error"]: + raise RuntimeError(status["last_error"]) + typer.echo(f"Policy execution complete ({status['commands_sent']} commands sent).") + break + time.sleep(0.1) + else: + policy.stop_learned_policy() + raise TimeoutError("Policy did not stop before its execution timeout") + except KeyboardInterrupt: + typer.echo("\nPolicy execution interrupted.", err=True) + if policy is not None: + policy.stop_learned_policy() + except Exception as exc: + typer.echo(f"A1Z policy execution failed: {exc}", err=True) + raise typer.Exit(1) + finally: + if coordinator is not None: + typer.echo("Support the arm before disabling its motors.") + try: + _press_enter("Press ENTER when the arm is supported") + except (KeyboardInterrupt, EOFError): + pass + coordinator.stop() diff --git a/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py b/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py index acdd8c0307..25cd4ef712 100644 --- a/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py +++ b/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py @@ -19,17 +19,20 @@ import numpy as np import pytest +from dimos.control.coordinator import ControlCoordinator from dimos.hardware.sensors.camera.module import CameraModule from dimos.hardware.sensors.camera.webcam import Webcam from dimos.learning.collection.episode_monitor import EpisodeStatus from dimos.learning.collection.recorder import CollectionRecorder from dimos.learning.dataprep.core import Episode +from dimos.learning.lerobot_policy import LeRobotPolicyModule from dimos.memory2.store.sqlite import SqliteStore from dimos.msgs.sensor_msgs.JointState import JointState from dimos.robot.manipulators.galaxea_a1z.blueprints.basic import ( A1Z_TEACH_CAMERA_FPS, A1Z_TEACH_CAMERA_HEIGHT, A1Z_TEACH_CAMERA_WIDTH, + make_a1z_policy_blueprint, make_a1z_teach_blueprint, ) from dimos.robot.manipulators.galaxea_a1z.teach_replay import ( @@ -78,6 +81,30 @@ def test_teach_blueprint_records_from_configured_webcam(tmp_path: Path) -> None: assert blueprint.blueprints.index(camera_atom) > blueprint.blueprints.index(recorder_atom) +def test_policy_blueprint_wires_camera_policy_and_seven_joint_servo() -> None: + blueprint = make_a1z_policy_blueprint( + "checkpoints/a1z", + task="pick up cube", + camera_index=2, + device="cuda", + ) + camera_atom = next(atom for atom in blueprint.blueprints if atom.module is CameraModule) + policy_atom = next(atom for atom in blueprint.blueprints if atom.module is LeRobotPolicyModule) + control_atom = next(atom for atom in blueprint.blueprints if atom.module is ControlCoordinator) + + camera = camera_atom.kwargs["hardware"]() + hardware = control_atom.kwargs["hardware"][0] + servo = control_atom.kwargs["tasks"][0] + + assert camera.config.camera_index == 2 + assert policy_atom.kwargs["policy_path"] == "checkpoints/a1z" + assert policy_atom.kwargs["joint_names"] == hardware.all_joints == list(A1Z_JOINT_NAMES) + assert policy_atom.kwargs["task"] == "pick up cube" + assert policy_atom.kwargs["device"] == "cuda" + assert servo.type == "servo" + assert servo.joint_names == list(A1Z_JOINT_NAMES) + + def test_loads_saved_memory2_episode_and_orders_joints(tmp_path: Path) -> None: path = tmp_path / "teach.db" store = SqliteStore(path=path) diff --git a/pyproject.toml b/pyproject.toml index f75f4ba326..904895ccd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -212,6 +212,13 @@ learning = [ "h5py", # HDF5 writer ] +# Optional training and live inference for policies built from DataPrep output. +# 0.4.4 is the newest LeRobot release compatible with the Transformers 4.53 +# stack currently used by DimOS (newer releases require huggingface-hub>=1). +lerobot = [ + "lerobot==0.4.4", +] + agents = [ "langchain>=1.2.3,<2", "langchain-core>=1.2.22,<2", @@ -476,6 +483,12 @@ override-dependencies = [ # opencv-python, which ships the same cv2/ tree as our opencv-contrib-python # core dep and clobbers it. Contrib is a superset, so never install plain. "opencv-python; sys_platform == 'never'", + # LeRobot requests the headless variant, which ships the same cv2/ tree. + # The core opencv-contrib-python dependency is already its functional superset. + "opencv-python-headless; sys_platform == 'never'", + # LeRobot uses Rerun only for optional visualization and carries an older + # upper bound; its training/inference paths work with the DimOS core pin. + "rerun-sdk==0.32.0", # moondream pins pillow<11 but we need >=12.2.0 for security fixes # (CVE-2026-25990, CVE-2026-40192, CVE-2026-42311). "pillow>=12.2.0", diff --git a/uv.lock b/uv.lock index b3dfd7a5a8..2b5bdf868b 100644 --- a/uv.lock +++ b/uv.lock @@ -6,11 +6,14 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -41,8 +44,10 @@ overrides = [ { name = "importlib-metadata", specifier = "<8.8.0" }, { name = "langgraph-prebuilt", specifier = "<=1.0.8" }, { name = "opencv-python", marker = "sys_platform == 'never'" }, + { name = "opencv-python-headless", marker = "sys_platform == 'never'" }, { name = "pillow", specifier = ">=12.2.0" }, { name = "pytest", specifier = "==8.3.5" }, + { name = "rerun-sdk", specifier = "==0.32.0" }, ] [[package]] @@ -348,31 +353,31 @@ wheels = [ [[package]] name = "av" -version = "16.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/cd/3a83ffbc3cc25b39721d174487fb0d51a76582f4a1703f98e46170ce83d4/av-16.1.0.tar.gz", hash = "sha256:a094b4fd87a3721dacf02794d3d2c82b8d712c85b9534437e82a8a978c175ffd", size = 4285203, upload-time = "2026-01-11T07:31:33.772Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/51/2217a9249409d2e88e16e3f16f7c0def9fd3e7ffc4238b2ec211f9935bdb/av-16.1.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:2395748b0c34fe3a150a1721e4f3d4487b939520991b13e7b36f8926b3b12295", size = 26942590, upload-time = "2026-01-09T20:17:58.588Z" }, - { url = "https://files.pythonhosted.org/packages/bf/cd/a7070f4febc76a327c38808e01e2ff6b94531fe0b321af54ea3915165338/av-16.1.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:72d7ac832710a158eeb7a93242370aa024a7646516291c562ee7f14a7ea881fd", size = 21507910, upload-time = "2026-01-09T20:18:02.309Z" }, - { url = "https://files.pythonhosted.org/packages/ae/30/ec812418cd9b297f0238fe20eb0747d8a8b68d82c5f73c56fe519a274143/av-16.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:6cbac833092e66b6b0ac4d81ab077970b8ca874951e9c3974d41d922aaa653ed", size = 38738309, upload-time = "2026-01-09T20:18:04.701Z" }, - { url = "https://files.pythonhosted.org/packages/3a/b8/6c5795bf1f05f45c5261f8bce6154e0e5e86b158a6676650ddd77c28805e/av-16.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:eb990672d97c18f99c02f31c8d5750236f770ffe354b5a52c5f4d16c5e65f619", size = 40293006, upload-time = "2026-01-09T20:18:07.238Z" }, - { url = "https://files.pythonhosted.org/packages/a7/44/5e183bcb9333fc3372ee6e683be8b0c9b515a506894b2d32ff465430c074/av-16.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05ad70933ac3b8ef896a820ea64b33b6cca91a5fac5259cb9ba7fa010435be15", size = 40123516, upload-time = "2026-01-09T20:18:09.955Z" }, - { url = "https://files.pythonhosted.org/packages/12/1d/b5346d582a3c3d958b4d26a2cc63ce607233582d956121eb20d2bbe55c2e/av-16.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d831a1062a3c47520bf99de6ec682bd1d64a40dfa958e5457bb613c5270e7ce3", size = 41463289, upload-time = "2026-01-09T20:18:12.459Z" }, - { url = "https://files.pythonhosted.org/packages/fa/31/acc946c0545f72b8d0d74584cb2a0ade9b7dfe2190af3ef9aa52a2e3c0b1/av-16.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:358ab910fef3c5a806c55176f2b27e5663b33c4d0a692dafeb049c6ed71f8aff", size = 31754959, upload-time = "2026-01-09T20:18:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/48/d0/b71b65d1b36520dcb8291a2307d98b7fc12329a45614a303ff92ada4d723/av-16.1.0-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:e88ad64ee9d2b9c4c5d891f16c22ae78e725188b8926eb88187538d9dd0b232f", size = 26927747, upload-time = "2026-01-09T20:18:16.976Z" }, - { url = "https://files.pythonhosted.org/packages/2f/79/720a5a6ccdee06eafa211b945b0a450e3a0b8fc3d12922f0f3c454d870d2/av-16.1.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:cb296073fa6935724de72593800ba86ae49ed48af03960a4aee34f8a611f442b", size = 21492232, upload-time = "2026-01-09T20:18:19.266Z" }, - { url = "https://files.pythonhosted.org/packages/8e/4f/a1ba8d922f2f6d1a3d52419463ef26dd6c4d43ee364164a71b424b5ae204/av-16.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:720edd4d25aa73723c1532bb0597806d7b9af5ee34fc02358782c358cfe2f879", size = 39291737, upload-time = "2026-01-09T20:18:21.513Z" }, - { url = "https://files.pythonhosted.org/packages/1a/31/fc62b9fe8738d2693e18d99f040b219e26e8df894c10d065f27c6b4f07e3/av-16.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c7f2bc703d0df260a1fdf4de4253c7f5500ca9fc57772ea241b0cb241bcf972e", size = 40846822, upload-time = "2026-01-09T20:18:24.275Z" }, - { url = "https://files.pythonhosted.org/packages/53/10/ab446583dbce730000e8e6beec6ec3c2753e628c7f78f334a35cad0317f4/av-16.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d69c393809babada7d54964d56099e4b30a3e1f8b5736ca5e27bd7be0e0f3c83", size = 40675604, upload-time = "2026-01-09T20:18:26.866Z" }, - { url = "https://files.pythonhosted.org/packages/31/d7/1003be685277005f6d63fd9e64904ee222fe1f7a0ea70af313468bb597db/av-16.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:441892be28582356d53f282873c5a951592daaf71642c7f20165e3ddcb0b4c63", size = 42015955, upload-time = "2026-01-09T20:18:29.461Z" }, - { url = "https://files.pythonhosted.org/packages/2f/4a/fa2a38ee9306bf4579f556f94ecbc757520652eb91294d2a99c7cf7623b9/av-16.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:273a3e32de64819e4a1cd96341824299fe06f70c46f2288b5dc4173944f0fd62", size = 31750339, upload-time = "2026-01-09T20:18:32.249Z" }, - { url = "https://files.pythonhosted.org/packages/9c/84/2535f55edcd426cebec02eb37b811b1b0c163f26b8d3f53b059e2ec32665/av-16.1.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:640f57b93f927fba8689f6966c956737ee95388a91bd0b8c8b5e0481f73513d6", size = 26945785, upload-time = "2026-01-09T20:18:34.486Z" }, - { url = "https://files.pythonhosted.org/packages/b6/17/ffb940c9e490bf42e86db4db1ff426ee1559cd355a69609ec1efe4d3a9eb/av-16.1.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:ae3fb658eec00852ebd7412fdc141f17f3ddce8afee2d2e1cf366263ad2a3b35", size = 21481147, upload-time = "2026-01-09T20:18:36.716Z" }, - { url = "https://files.pythonhosted.org/packages/15/c1/e0d58003d2d83c3921887d5c8c9b8f5f7de9b58dc2194356a2656a45cfdc/av-16.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:27ee558d9c02a142eebcbe55578a6d817fedfde42ff5676275504e16d07a7f86", size = 39517197, upload-time = "2026-01-11T09:57:31.937Z" }, - { url = "https://files.pythonhosted.org/packages/32/77/787797b43475d1b90626af76f80bfb0c12cfec5e11eafcfc4151b8c80218/av-16.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:7ae547f6d5fa31763f73900d43901e8c5fa6367bb9a9840978d57b5a7ae14ed2", size = 41174337, upload-time = "2026-01-11T09:57:35.792Z" }, - { url = "https://files.pythonhosted.org/packages/8e/ac/d90df7f1e3b97fc5554cf45076df5045f1e0a6adf13899e10121229b826c/av-16.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8cf065f9d438e1921dc31fc7aa045790b58aee71736897866420d80b5450f62a", size = 40817720, upload-time = "2026-01-11T09:57:39.039Z" }, - { url = "https://files.pythonhosted.org/packages/80/6f/13c3a35f9dbcebafd03fe0c4cbd075d71ac8968ec849a3cfce406c35a9d2/av-16.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a345877a9d3cc0f08e2bc4ec163ee83176864b92587afb9d08dff50f37a9a829", size = 42267396, upload-time = "2026-01-11T09:57:42.115Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b9/275df9607f7fb44317ccb1d4be74827185c0d410f52b6e2cd770fe209118/av-16.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:f49243b1d27c91cd8c66fdba90a674e344eb8eb917264f36117bf2b6879118fd", size = 31752045, upload-time = "2026-01-11T09:57:45.106Z" }, +version = "15.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/c3/83e6e73d1592bc54436eae0bc61704ae0cff0c3cfbde7b58af9ed67ebb49/av-15.1.0.tar.gz", hash = "sha256:39cda2dc810e11c1938f8cb5759c41d6b630550236b3365790e67a313660ec85", size = 3774192, upload-time = "2025-08-30T04:41:56.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/6a/91e3e68ae0d1b53b480ec69a96f2ae820fb007bc60e6b821741f31c7ba4e/av-15.1.0-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:cf067b66cee2248220b29df33b60eb4840d9e7b9b75545d6b922f9c41d88c4ee", size = 21781685, upload-time = "2025-08-30T04:39:13.118Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6d/afa951b9cb615c3bc6d95c4eed280c6cefb52c006f4e15e79043626fab39/av-15.1.0-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:26426163d96fc3bde9a015ba4d60da09ef848d9284fe79b4ca5e60965a008fc5", size = 26962481, upload-time = "2025-08-30T04:39:16.875Z" }, + { url = "https://files.pythonhosted.org/packages/3c/42/0c384884235c42c439cef28cbd129e4624ad60229119bf3c6c6020805119/av-15.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:92f524541ce74b8a12491d8934164a5c57e983da24826547c212f60123de400b", size = 37571839, upload-time = "2025-08-30T04:39:20.325Z" }, + { url = "https://files.pythonhosted.org/packages/25/c0/5c967b0872fce1add80a8f50fa7ce11e3e3e5257c2b079263570bc854699/av-15.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:659f9d6145fb2c58e8b31907283b6ba876570f5dd6e7e890d74c09614c436c8e", size = 39070227, upload-time = "2025-08-30T04:39:24.079Z" }, + { url = "https://files.pythonhosted.org/packages/e2/81/e333056d49363c35a74b828ed5f87c96dfbcc1a506b49d79a31ac773b94d/av-15.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:07a8ae30c0cfc3132eff320a6b27d18a5e0dda36effd0ae28892888f4ee14729", size = 39619362, upload-time = "2025-08-30T04:39:27.7Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ae/50cc2af1bf68452cbfec8d1b2554c18f6d167c8ba6d7ad7707797dfd1541/av-15.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e33a76e38f03bb5de026b9f66ccf23dc01ddd2223221096992cb52ac22e62538", size = 40371627, upload-time = "2025-08-30T04:39:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/381edf1779106dd31c9ef1ac9842f643af4465b8a87cbc278d3eaa76229a/av-15.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:aa4bf12bdce20edc2a3b13a2776c474c5ab63e1817d53793714504476eeba82e", size = 31340369, upload-time = "2025-08-30T04:39:34.774Z" }, + { url = "https://files.pythonhosted.org/packages/47/58/4e44cf6939be7aba96a4abce024e1be11ba7539ecac74d09369b8c03aa05/av-15.1.0-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:b785948762a8d45fc58fc24a20251496829ace1817e9a7a508a348d6de2182c3", size = 21767323, upload-time = "2025-08-30T04:39:37.989Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f6/a946544cdb49f6d892d2761b1d61a8bc6ce912fe57ba06769bdc640c0a7f/av-15.1.0-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:9c7131494a3a318612b4ee4db98fe5bc50eb705f6b6536127c7ab776c524fd8b", size = 26946268, upload-time = "2025-08-30T04:39:40.601Z" }, + { url = "https://files.pythonhosted.org/packages/70/7c/b33513c0af73d0033af59a98f035b521c5b93445a6af7e9efbf41a6e8383/av-15.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2b9623ae848625c59213b610c8665817924f913580c7c5c91e0dc18936deb00d", size = 38062118, upload-time = "2025-08-30T04:39:43.928Z" }, + { url = "https://files.pythonhosted.org/packages/5e/95/31b7fb34f9fea7c7389240364194f4f56ad2d460095038cc720f50a90bb3/av-15.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:c8ef597087db560514617143532b1fafc4825ebb2dda9a22418f548b113a0cc7", size = 39571086, upload-time = "2025-08-30T04:39:47.109Z" }, + { url = "https://files.pythonhosted.org/packages/e7/b0/7b0b45474a4e90c35c11d0032947d8b3c7386872957ce29c6f12add69a74/av-15.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08eac47a90ebae1e2bd5935f400dd515166019bab4ff5b03c4625fa6ac3a0a5e", size = 40112634, upload-time = "2025-08-30T04:39:50.981Z" }, + { url = "https://files.pythonhosted.org/packages/aa/04/038b94bc9a1ee10a451c867d4a2fc91e845f83bfc2dae9df25893abcb57f/av-15.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d3f66ff200ea166e606cb3c5cb1bd2fc714effbec2e262a5d67ce60450c8234a", size = 40878695, upload-time = "2025-08-30T04:39:54.493Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3d/9f8f96c0deeaaf648485a3dbd1699b2f0580f2ce8a36cb616c0138ba7615/av-15.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:57b99544d91121b8bea570e4ddf61700f679a6b677c1f37966bc1a22e1d4cd5c", size = 31335683, upload-time = "2025-08-30T04:39:57.861Z" }, + { url = "https://files.pythonhosted.org/packages/d1/58/de78b276d20db6ffcd4371283df771721a833ba525a3d57e753d00a9fe79/av-15.1.0-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:40c5df37f4c354ab8190c6fd68dab7881d112f527906f64ca73da4c252a58cee", size = 21760991, upload-time = "2025-08-30T04:40:00.801Z" }, + { url = "https://files.pythonhosted.org/packages/56/cc/45f85775304ae60b66976360d82ba5b152ad3fd91f9267d5020a51e9a828/av-15.1.0-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:af455ce65ada3d361f80c90c810d9bced4db5655ab9aa513024d6c71c5c476d5", size = 26953097, upload-time = "2025-08-30T04:40:03.998Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f8/2d781e5e71d02fc829487e775ccb1185e72f95340d05f2e84eb57a11e093/av-15.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:86226d2474c80c3393fa07a9c366106029ae500716098b72b3ec3f67205524c3", size = 38319710, upload-time = "2025-08-30T04:40:07.701Z" }, + { url = "https://files.pythonhosted.org/packages/ac/13/37737ef2193e83862ccacff23580c39de251da456a1bf0459e762cca273c/av-15.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:11326f197e7001c4ca53a83b2dbc67fd39ddff8cdf62ce6be3b22d9f3f9338bd", size = 39915519, upload-time = "2025-08-30T04:40:11.066Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e8032c7b8f2a4129a03f63f896544f8b7cf068e2db2950326fa2400d5c47/av-15.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a631ea879cc553080ee62874f4284765c42ba08ee0279851a98a85e2ceb3cc8d", size = 40286166, upload-time = "2025-08-30T04:40:14.561Z" }, + { url = "https://files.pythonhosted.org/packages/e2/23/612c0fd809444d04b8387a2dfd942ccc77829507bd78a387ff65a9d98c24/av-15.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8f383949b010c3e731c245f80351d19dc0c08f345e194fc46becb1cb279be3ff", size = 41150592, upload-time = "2025-08-30T04:40:17.951Z" }, + { url = "https://files.pythonhosted.org/packages/15/74/6f8e38a3b0aea5f28e72813672ff45b64615f2c69e6a4a558718c95edb9f/av-15.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d5921aa45f4c1f8c1a8d8185eb347e02aa4c3071278a2e2dd56368d54433d643", size = 31336093, upload-time = "2025-08-30T04:40:21.393Z" }, ] [[package]] @@ -558,7 +563,7 @@ name = "build" version = "1.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "colorama", marker = "(os_name == 'nt' and platform_machine != 'aarch64' and sys_platform == 'linux') or (os_name == 'nt' and sys_platform != 'darwin' and sys_platform != 'linux')" }, + { name = "colorama", marker = "os_name == 'nt' and sys_platform != 'darwin' and sys_platform != 'linux'" }, { name = "importlib-metadata" }, { name = "packaging" }, { name = "pyproject-hooks" }, @@ -713,19 +718,20 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "absl-py", marker = "python_full_version < '3.11'" }, - { name = "jax", version = "0.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "jaxlib", version = "0.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "toolz", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "absl-py" }, + { name = "jax", version = "0.6.2", source = { registry = "https://pypi.org/simple" } }, + { name = "jaxlib", version = "0.6.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "toolz" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/77/70/53c7d404ce9e2a94009aea7f77ef6e392f6740e071c62683a506647c520f/chex-0.1.90.tar.gz", hash = "sha256:d3c375aeb6154b08f1cccd2bee4ed83659ee2198a6acf1160d2fe2e4a6c87b5c", size = 92363, upload-time = "2025-07-23T19:50:47.945Z" } wheels = [ @@ -740,9 +746,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -753,12 +761,12 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "absl-py", marker = "python_full_version >= '3.11'" }, - { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "toolz", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, + { name = "absl-py" }, + { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "toolz" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/7d/812f01e7b2ddf28a0caa8dde56bd951a2c8f691c9bbfce38d469458d1502/chex-0.1.91.tar.gz", hash = "sha256:65367a521415ada905b8c0222b0a41a68337fcadf79a1fb6fc992dbd95dd9f76", size = 90302, upload-time = "2025-09-01T21:49:32.834Z" } wheels = [ @@ -829,6 +837,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, ] +[[package]] +name = "cmake" +version = "4.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/17/f8f42ae205604319cc36f46d9929bd9bfbd83d3d02d6314c44fa97c42006/cmake-4.1.3.tar.gz", hash = "sha256:89f48ddc2570eb62447e33311cffc6dfeb09631bd0a19423d8a59cec8af030f1", size = 34998, upload-time = "2025-11-19T22:41:27.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/79/1bf4009d7ef16d62e0b92ddb78efeda830ca5903149abf9dc01d270c3d4e/cmake-4.1.3-py3-none-macosx_10_10_universal2.whl", hash = "sha256:3b6b25ce8fecc768881b36a1dfbca0013adac10a299c73e24cf4cbb99e4c37d6", size = 49246088, upload-time = "2025-11-19T22:40:28.02Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9d/14e076406388efa2bbea2366ec0bbe85e2536787ebbb374dda792f068222/cmake-4.1.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893eb9c20d8a8bac3d951bbef9a4ce9d5495cd35a08b4e08d76215f5ead5897", size = 30381441, upload-time = "2025-11-19T22:40:31.55Z" }, + { url = "https://files.pythonhosted.org/packages/f7/9e/0f7216dfef03f1cbac0cdf4685da6994559f5ede3452e563335a35d6a6cb/cmake-4.1.3-py3-none-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:487faf892ff5e05084c6a7f229dd9e568d0542b88487386acb42f0cb2f6634b6", size = 30781002, upload-time = "2025-11-19T22:40:35.325Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2e/69d9b1eee7b7c68e9ce53f8449e372151b4967c223ecd43c7083a4dece8d/cmake-4.1.3-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3dbddc52f839df0ebc1c6b6915bd78d63d0805137c6f419fbddd587404276c28", size = 32613762, upload-time = "2025-11-19T22:40:39.488Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9b/deac4d6f8cf4adcaa61d7f16d1ec42d41d471bf330ffcdac4d29c83e46a3/cmake-4.1.3-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b42e99eb6e976f455f29283dd7583270d611b55c7687b5fe8d022d9ae7c95de5", size = 28577197, upload-time = "2025-11-19T22:40:42.517Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6c/323c40671c6f1b3e02bb4a7404fbe2bf653190a56e63cf4b6a4f06e876bc/cmake-4.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:81f11b72bc59cbe547d9f283487ef0519bf68176edffcdfa1a4dc5a52f292369", size = 29690899, upload-time = "2025-11-19T22:40:45.363Z" }, + { url = "https://files.pythonhosted.org/packages/19/a3/ab7866f55ee11a07aa446ee31b91b8f337f1b702b9546fc7b18e23d0566f/cmake-4.1.3-py3-none-manylinux_2_31_armv7l.whl", hash = "sha256:fd633c4395b1522caedf0b64034d1a48ea0e483f19e9c2985d14ee7152b21593", size = 26522320, upload-time = "2025-11-19T22:40:48.463Z" }, + { url = "https://files.pythonhosted.org/packages/db/21/a99ed3f1192c85d6d565e61c0cd0161f8046afcf0b0951e6492be632f2f2/cmake-4.1.3-py3-none-manylinux_2_35_riscv64.whl", hash = "sha256:ea40a64b8027f2b7fb1684312a2f170e4d0904b7a4f123cd96e7290103bb1ed4", size = 28869263, upload-time = "2025-11-19T22:40:51.618Z" }, + { url = "https://files.pythonhosted.org/packages/13/66/3c32bb2d5e72f00a0861066b29cc6981cbffcf9786f7339317f151a4d4be/cmake-4.1.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e3782d5f82e8960290e50747b1fb5ff8396363a656ad5716a3aedc77334ca94f", size = 41751469, upload-time = "2025-11-19T22:40:54.75Z" }, + { url = "https://files.pythonhosted.org/packages/05/60/922c05d62ba5b422afd211966877673ddceb634e95552893bf9a11cc4e58/cmake-4.1.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:44b011b8374aac8f3d7a7fb319b3c25d54c2fd9342d94a855ae3a64240efe828", size = 35040544, upload-time = "2025-11-19T22:40:57.669Z" }, + { url = "https://files.pythonhosted.org/packages/71/ae/957336b0489f7d3050cd19010585d4ab5ebcdef485292b9baee68ebbeccf/cmake-4.1.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f29e924fd6d1a4f2f731eb743cc687b82063f73f15f0b4fb8e2b8a8211faba8", size = 45811680, upload-time = "2025-11-19T22:41:01.124Z" }, + { url = "https://files.pythonhosted.org/packages/85/0d/41e2ac694b156b249bfaccec071897c46b21deeb4db1ec51d949e7843f4b/cmake-4.1.3-py3-none-musllinux_1_2_ppc64le.whl", hash = "sha256:466cdce904392f18b201471a3a6429cc12b4d98a166faa3ee0ad4461f3043083", size = 45859079, upload-time = "2025-11-19T22:41:04.694Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d4/42ca38f001b1f1327c19734e4c0080557a7991db832aacfe4b193ba7743a/cmake-4.1.3-py3-none-musllinux_1_2_riscv64.whl", hash = "sha256:d37db26f98ac26f0858cf6a30157a4be83b29cb195afeb640b355b097f1d94d7", size = 39946757, upload-time = "2025-11-19T22:41:08.082Z" }, + { url = "https://files.pythonhosted.org/packages/73/ab/a3965bfce6376894c76e17af095b0e360a9e1a1719e3df1e244ea6d6d893/cmake-4.1.3-py3-none-musllinux_1_2_s390x.whl", hash = "sha256:18e1e2b7b226763017521ba8721c74d1a2a3cd7d1ec8e889b0b869d4e939370b", size = 44016695, upload-time = "2025-11-19T22:41:11.84Z" }, + { url = "https://files.pythonhosted.org/packages/a4/66/fa0e8d3c66459a616f0baf9d22933e14137c259f4b62f0dad9c3723cf42d/cmake-4.1.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6966746b25d1e9c8d32c731452e220e84331b5133544f710b21bd228a93812ca", size = 43357408, upload-time = "2025-11-19T22:41:15.302Z" }, + { url = "https://files.pythonhosted.org/packages/6e/8f/5c43c6465af62bb16159de113438365c789c5a69261dad36746aa1ec74b8/cmake-4.1.3-py3-none-win32.whl", hash = "sha256:b1c890af27bb548d0a2c0e1affc81ad180fc17d8dfa9545e0658153446fe7db4", size = 34268275, upload-time = "2025-11-19T22:41:18.733Z" }, + { url = "https://files.pythonhosted.org/packages/c1/51/2bc56a4d8d9c2680913f1a7e0b7a33e48100f336df91176b74dda6dff8b3/cmake-4.1.3-py3-none-win_amd64.whl", hash = "sha256:fd5a2ea9a38c6109036d8c912a7db4df2de241cfbc00b7424ae246494387da80", size = 37545974, upload-time = "2025-11-19T22:41:21.85Z" }, + { url = "https://files.pythonhosted.org/packages/36/a5/ec213d5c228ab7a205abeb51cc23aa1be9b586041c40cdccc157c325822a/cmake-4.1.3-py3-none-win_arm64.whl", hash = "sha256:79bd8f92a3385cc6641949b0274cd10ee9a4f45a2c13840121b68b2e90b5af3a", size = 36337597, upload-time = "2025-11-19T22:41:24.968Z" }, +] + [[package]] name = "cmeel" version = "0.59.0" @@ -1112,14 +1146,15 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -1169,9 +1204,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -1182,7 +1219,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1352,7 +1389,7 @@ name = "cuda-bindings" version = "12.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/7a/d8/b546104b8da3f562c1ff8ab36d130c8fe1dd6a045ced80b4f6ad74f7d4e1/cuda_bindings-12.9.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d3c842c2a4303b2a580fe955018e31aea30278be19795ae05226235268032e5", size = 12148218, upload-time = "2025-10-21T14:51:28.855Z" }, @@ -1373,9 +1410,9 @@ name = "cupy-cuda12x" version = "13.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fastrlock", marker = "platform_machine != 'aarch64'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64'" }, + { name = "fastrlock" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/53/2b/8064d94a6ab6b5c4e643d8535ab6af6cabe5455765540931f0ef60a0bc3b/cupy_cuda12x-13.6.0-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:e78409ea72f5ac7d6b6f3d33d99426a94005254fa57e10617f430f9fd7c3a0a1", size = 112238589, upload-time = "2025-08-18T08:24:15.541Z" }, @@ -1461,14 +1498,41 @@ name = "dataclasses-json" version = "0.6.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "marshmallow", marker = "python_full_version >= '3.11'" }, - { name = "typing-inspect", marker = "python_full_version >= '3.11'" }, + { name = "marshmallow" }, + { name = "typing-inspect" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, ] +[[package]] +name = "datasets" +version = "4.8.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dill" }, + { name = "filelock" }, + { name = "fsspec", extra = ["http"] }, + { name = "httpx" }, + { name = "huggingface-hub" }, + { name = "multiprocess" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pyarrow" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "tqdm" }, + { name = "xxhash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/34/14cd8e76f907f7d4dca2334cfeec9f81d30fd15c25a015f99aaea694eaed/datasets-4.8.5.tar.gz", hash = "sha256:0f0c1c3d56ffff2c93b2f4c63c95bac94f3d7e8621aea2a2a576275233bba772", size = 605649, upload-time = "2026-04-27T15:43:57.384Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/99/00f3196036501b53032c4b1ab8337a0b978dee832ed276dae3815df4e8b5/datasets-4.8.5-py3-none-any.whl", hash = "sha256:5079900781719c0e063a8efdd2cd95a31ad0c63209178669cd23cf1b926149ff", size = 528973, upload-time = "2026-04-27T15:43:53.702Z" }, +] + [[package]] name = "dbus-fast" version = "4.0.4" @@ -1519,6 +1583,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "deepdiff" +version = "8.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "orderly-set" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/89/50/767448e792d41bfb6094ee317a355c1cb221dca24b2e178e2203bbea2a77/deepdiff-8.6.2.tar.gz", hash = "sha256:186dcbd181e4d76cef11ab05f802d0056c5d6083c5a6748c1473e9d7481e183e", size = 634860, upload-time = "2026-03-18T17:16:33.785Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/5f/c52bd1255db763d0cdcb7084d2e90c42119cb229302c56bdf1d0aa78abd2/deepdiff-8.6.2-py3-none-any.whl", hash = "sha256:4d22034a866c3928303a9332c279362f714192d9305bac17c498720d095fd1b4", size = 91979, upload-time = "2026-03-18T17:16:32.171Z" }, +] + +[[package]] +name = "diffusers" +version = "0.35.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "huggingface-hub" }, + { name = "importlib-metadata" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, + { name = "regex" }, + { name = "requests" }, + { name = "safetensors" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/68/288ca23c7c05c73e87ffe5efffc282400ac9b017f7a9bb03883f4310ea15/diffusers-0.35.2.tar.gz", hash = "sha256:30ecd552303edfcfe1724573c3918a8462ee3ab4d529bdbd4c0045f763affded", size = 3366711, upload-time = "2025-10-15T04:05:17.213Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2e/38d9824f8c6bb048c5ba21c6d4da54c29c162a46b58b3ef907a360a76d3e/diffusers-0.35.2-py3-none-any.whl", hash = "sha256:d50d5e74fdd6dcf55e5c1d304bc52cc7c2659abd1752740d736d7b54078b4db5", size = 4121649, upload-time = "2025-10-15T04:05:14.391Z" }, +] + [[package]] name = "dill" version = "0.4.1" @@ -1705,6 +1801,9 @@ learning = [ { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pyarrow" }, ] +lerobot = [ + { name = "lerobot" }, +] manipulation = [ { name = "a750-control", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "drake", version = "1.45.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, @@ -2044,6 +2143,7 @@ requires-dist = [ { name = "langchain-openai", marker = "extra == 'agents'", specifier = ">=1,<2" }, { name = "lap", marker = "extra == 'perception'", specifier = ">=0.5.12" }, { name = "lazy-loader" }, + { name = "lerobot", marker = "extra == 'lerobot'", specifier = "==0.4.4" }, { name = "llvmlite", specifier = ">=0.42.0" }, { name = "lz4", specifier = ">=4.4.5" }, { name = "matplotlib", marker = "extra == 'manipulation'", specifier = ">=3.7.1" }, @@ -2121,7 +2221,7 @@ requires-dist = [ { name = "xarm-python-sdk", marker = "extra == 'misc'", specifier = ">=1.17.0" }, { name = "yourdfpy", marker = "(platform_machine != 'aarch64' and extra == 'visualization') or (sys_platform != 'linux' and extra == 'visualization')", specifier = ">=0.0.60" }, ] -provides-extras = ["misc", "visualization", "learning", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "cpu", "cuda", "sim", "mapping", "drone", "dds", "webrtc", "base", "apriltag", "scene", "all"] +provides-extras = ["misc", "visualization", "learning", "lerobot", "agents", "web", "perception", "unitree", "unitree-dds", "manipulation", "cpu", "cuda", "sim", "mapping", "drone", "dds", "webrtc", "base", "apriltag", "scene", "all"] [package.metadata.requires-dev] autofix = [{ name = "ruff", specifier = "==0.14.3" }] @@ -2354,6 +2454,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/7b/af3d0da15bed3a8665419bb3a630585756920f4ad67abfdfef26240ebcc0/docstring_to_markdown-0.17-py3-none-any.whl", hash = "sha256:fd7d5094aa83943bf5f9e1a13701866b7c452eac19765380dead666e36d3711c", size = 23479, upload-time = "2025-05-02T15:09:06.676Z" }, ] +[[package]] +name = "draccus" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "pyyaml" }, + { name = "pyyaml-include" }, + { name = "toml" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/e2/f5012fda17ee5d1eaf3481b6ca3e11dffa5348e5e08ab745538fdc8041bb/draccus-0.10.0.tar.gz", hash = "sha256:8dd08304219becdcd66cd16058ba98e9c3e6b7bfe48ccb9579dae39f8d37ae19", size = 62243, upload-time = "2025-02-05T07:27:48.182Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/9a/a83083b230d352ee5d205757b74006dbe084448ca45e3bc5ca99215b1e55/draccus-0.10.0-py3-none-any.whl", hash = "sha256:90243418ae0e9271c390a59cafb6acfd37001193696ed36fcc8525f791a83282", size = 71783, upload-time = "2025-02-05T07:27:46.1Z" }, +] + [[package]] name = "drake" version = "1.45.0" @@ -2364,12 +2480,12 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "matplotlib", marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, - { name = "mosek", version = "11.0.24", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'" }, - { name = "pydot", marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, - { name = "pyyaml", marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, + { name = "matplotlib" }, + { name = "mosek", version = "11.0.24", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pydot" }, + { name = "pyyaml" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a0/31/aa4f1f5523381539e1028354cc535d5a3307d28fd33872f2b403454d8391/drake-1.45.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b0d9bd6196dc6d3b0e660fc6351fcf236727a45ef6a7123f8dc96f85b8662ac3", size = 57314509, upload-time = "2025-09-16T19:02:10.195Z" }, @@ -2381,19 +2497,22 @@ version = "1.49.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "matplotlib", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin'" }, - { name = "mosek", version = "11.1.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and sys_platform != 'darwin'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin'" }, - { name = "pydot", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin'" }, - { name = "pyyaml", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin'" }, + { name = "matplotlib" }, + { name = "mosek", version = "11.1.2", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pydot" }, + { name = "pyyaml" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/fb/26/2ce3a9caf431f24e39f8b1fc7b3ebba4faafef1d61c849db3194e8d2e21d/drake-1.49.0-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:6c73dbd061fcb442e82b7b5a94dadcfbf4c44949035d03394df29412114647b2", size = 41482505, upload-time = "2026-01-15T19:44:08.313Z" }, @@ -2530,12 +2649,18 @@ epy = [ { name = "typing-extensions" }, ] +[[package]] +name = "evdev" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" } + [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2560,6 +2685,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, ] +[[package]] +name = "farama-notifications" +version = "0.0.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/91/14397890dde30adc4bee6462158933806207bc5dd10d7b4d09d5c33845cf/farama_notifications-0.0.6.tar.gz", hash = "sha256:b19acac4bb41d76e59e03394b5dd165f4761c86fa327f56307a35cbee3b60158", size = 2517, upload-time = "2026-04-24T08:43:57.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/f0/21f81892e4ed10f4ec3ef2e7cf8635fb76e7c0907c55d0da66be50094760/farama_notifications-0.0.6-py3-none-any.whl", hash = "sha256:f84839188efa1ce5bb361c2a84881b2dc2c0d0d7fb661ff00421820170930935", size = 2897, upload-time = "2026-04-24T08:43:56.785Z" }, +] + [[package]] name = "fastapi" version = "0.129.0" @@ -2785,22 +2919,23 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "jax", version = "0.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "msgpack", marker = "python_full_version < '3.11'" }, - { name = "optax", marker = "python_full_version < '3.11'" }, - { name = "orbax-checkpoint", marker = "python_full_version < '3.11'" }, - { name = "pyyaml", marker = "python_full_version < '3.11'" }, - { name = "rich", marker = "python_full_version < '3.11'" }, - { name = "tensorstore", version = "0.1.78", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "treescope", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "jax", version = "0.6.2", source = { registry = "https://pypi.org/simple" } }, + { name = "msgpack" }, + { name = "optax" }, + { name = "orbax-checkpoint" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tensorstore", version = "0.1.78", source = { registry = "https://pypi.org/simple" } }, + { name = "treescope" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e6/76/4ea55a60a47e98fcff591238ee26ed4624cb4fdc4893aa3ebf78d0d021f4/flax-0.10.7.tar.gz", hash = "sha256:2930d6671e23076f6db3b96afacf45c5060898f5c189ecab6dda7e05d26c2085", size = 5136099, upload-time = "2025-07-02T06:10:07.819Z" } wheels = [ @@ -2815,9 +2950,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -2828,17 +2965,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "msgpack", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "optax", marker = "python_full_version >= '3.11'" }, - { name = "orbax-checkpoint", marker = "python_full_version >= '3.11'" }, - { name = "orbax-export", marker = "python_full_version >= '3.11'" }, - { name = "pyyaml", marker = "python_full_version >= '3.11'" }, - { name = "rich", marker = "python_full_version >= '3.11'" }, - { name = "tensorstore", version = "0.1.81", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "treescope", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, + { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "msgpack" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "optax" }, + { name = "orbax-checkpoint" }, + { name = "orbax-export" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "tensorstore", version = "0.1.81", source = { registry = "https://pypi.org/simple" } }, + { name = "treescope" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/81/802fd686d3f47d7560a83f73b23efff03de7e3a0342e4f0fc41680136709/flax-0.12.4.tar.gz", hash = "sha256:5e924734a0595ddfa06a824568617e5440c7948e744772cbe6101b7ae06d66a9", size = 5070824, upload-time = "2026-02-12T19:10:17.048Z" } wheels = [ @@ -2956,6 +3093,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, ] +[package.optional-dependencies] +http = [ + { name = "aiohttp" }, +] + [[package]] name = "ftfy" version = "6.3.1" @@ -2993,6 +3135,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/fd/a382bb6684b1fdbe5cd19aa980a04a67f6c91efd0e1e627f93614fe2d24e/gdown-6.0.0-py3-none-any.whl", hash = "sha256:c82d39a6b09ed7778012515c2fa4ab4dc36d7789300cd0b16b87d3a3e4a09955", size = 18243, upload-time = "2026-04-12T06:37:38.209Z" }, ] +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.1.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/30/a8a0c15f9480dc91b5b7f11ebd26105e5f80898d7ff02da197fef35d8395/gitpython-3.1.51.tar.gz", hash = "sha256:22c9c94bb6b0b9f3c7157c684fece45a414cea204586b600beae6cd4570dcd6d", size = 223519, upload-time = "2026-07-12T13:40:07.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/11/1232bb1ba52a230f20ff08e1b3df7cd9d43a71b61cc0a1c37941064fe4c7/gitpython-3.1.51-py3-none-any.whl", hash = "sha256:d5b29685708f3c80a56db040b665bfd69492c8e150b5848532af8013b686c5a4", size = 215246, upload-time = "2026-07-12T13:40:06.43Z" }, +] + [[package]] name = "glfw" version = "2.10.0" @@ -3132,6 +3298,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/ea/c867ef50a58978f31e9808e7511cd6e37b286f003b7ef6a43857e04bf8e6/gtsam_extended-4.3a1.post1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b8f267f3d15a155bd513c0e1d0b4aaaf391df654ed38063887cb4c8e4d6b54e8", size = 30487344, upload-time = "2026-04-02T22:28:38.251Z" }, ] +[[package]] +name = "gymnasium" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cloudpickle" }, + { name = "farama-notifications" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/ff/14b6880d703dfaca204490979d3254ccd280c99550798993319902873658/gymnasium-1.3.0.tar.gz", hash = "sha256:6939e86e835d6b71b6ba6bfd360487420876deafc79bfb7bacba83a7c446bcf3", size = 830646, upload-time = "2026-04-22T13:47:14.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/73/fda6a25f3beeb5e49d74330b44092b9e5a547395ccd478d1103ddcbff1fc/gymnasium-1.3.0-py3-none-any.whl", hash = "sha256:6b8c159a8540dcbcb221722d7efda24d78ebbcbc3bd2ea1c2611aa2a34471fc2", size = 953904, upload-time = "2026-04-22T13:47:12.13Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -3176,6 +3358,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/d9/866b7e570b39070f92d47b0ff1800f0f8239b6f9e45f02363d7112336c1f/h5py-3.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:39c2838fb1e8d97bcf1755e60ad1f3dd76a7b2a475928dc321672752678b96db", size = 2653286, upload-time = "2026-03-06T13:48:17.279Z" }, ] +[[package]] +name = "hf-transfer" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/eb/8fc64f40388c29ce8ce3b2b180a089d4d6b25b1d0d232d016704cb852104/hf_transfer-0.1.9.tar.gz", hash = "sha256:035572865dab29d17e783fbf1e84cf1cb24f3fcf8f1b17db1cfc7fdf139f02bf", size = 25201, upload-time = "2025-01-07T10:05:12.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/f5/461d2e5f307e5048289b1168d5c642ae3bb2504e88dff1a38b92ed990a21/hf_transfer-0.1.9-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e66acf91df4a8b72f60223059df3003062a5ae111757187ed1a06750a30e911b", size = 1393046, upload-time = "2025-01-07T10:04:51.003Z" }, + { url = "https://files.pythonhosted.org/packages/41/ba/8d9fd9f1083525edfcb389c93738c802f3559cb749324090d7109c8bf4c2/hf_transfer-0.1.9-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:8669dbcc7a3e2e8d61d42cd24da9c50d57770bd74b445c65123291ca842a7e7a", size = 1348126, upload-time = "2025-01-07T10:04:45.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/cd7885bc9959421065a6fae0fe67b6c55becdeda4e69b873e52976f9a9f0/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8fd0167c4407a3bc4cdd0307e65ada2294ec04f1813d8a69a5243e379b22e9d8", size = 3728604, upload-time = "2025-01-07T10:04:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2e/a072cf196edfeda3310c9a5ade0a0fdd785e6154b3ce24fc738c818da2a7/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ee8b10afedcb75f71091bcc197c526a6ebf5c58bbbadb34fdeee6160f55f619f", size = 3064995, upload-time = "2025-01-07T10:04:18.663Z" }, + { url = "https://files.pythonhosted.org/packages/c2/84/aec9ef4c0fab93c1ea2b1badff38c78b4b2f86f0555b26d2051dbc920cde/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5828057e313de59300dd1abb489444bc452efe3f479d3c55b31a8f680936ba42", size = 3580908, upload-time = "2025-01-07T10:04:32.834Z" }, + { url = "https://files.pythonhosted.org/packages/29/63/b560d39651a56603d64f1a0212d0472a44cbd965db2fa62b99d99cb981bf/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc6bd19e1cc177c66bdef15ef8636ad3bde79d5a4f608c158021153b4573509d", size = 3400839, upload-time = "2025-01-07T10:04:26.122Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d8/f87ea6f42456254b48915970ed98e993110521e9263472840174d32c880d/hf_transfer-0.1.9-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdca9bfb89e6f8f281890cc61a8aff2d3cecaff7e1a4d275574d96ca70098557", size = 3552664, upload-time = "2025-01-07T10:04:40.123Z" }, + { url = "https://files.pythonhosted.org/packages/d6/56/1267c39b65fc8f4e2113b36297320f102718bf5799b544a6cbe22013aa1d/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:89a23f58b7b7effbc047b8ca286f131b17728c99a9f972723323003ffd1bb916", size = 4073732, upload-time = "2025-01-07T10:04:55.624Z" }, + { url = "https://files.pythonhosted.org/packages/82/1a/9c748befbe3decf7cb415e34f8a0c3789a0a9c55910dea73d581e48c0ce5/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:dc7fff1345980d6c0ebb92c811d24afa4b98b3e07ed070c8e38cc91fd80478c5", size = 3390096, upload-time = "2025-01-07T10:04:59.98Z" }, + { url = "https://files.pythonhosted.org/packages/72/85/4c03da147b6b4b7cb12e074d3d44eee28604a387ed0eaf7eaaead5069c57/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1a6bd16c667ebe89a069ca163060127a794fa3a3525292c900b8c8cc47985b0d", size = 3664743, upload-time = "2025-01-07T10:05:05.416Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6e/e597b04f753f1b09e6893075d53a82a30c13855cbaa791402695b01e369f/hf_transfer-0.1.9-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d2fde99d502093ade3ab1b53f80da18480e9902aa960dab7f74fb1b9e5bc5746", size = 3695243, upload-time = "2025-01-07T10:05:11.411Z" }, + { url = "https://files.pythonhosted.org/packages/09/89/d4e234727a26b2546c8fb70a276cd924260d60135f2165bf8b9ed67bb9a4/hf_transfer-0.1.9-cp38-abi3-win32.whl", hash = "sha256:435cc3cdc8524ce57b074032b8fd76eed70a4224d2091232fa6a8cef8fd6803e", size = 1086605, upload-time = "2025-01-07T10:05:18.873Z" }, + { url = "https://files.pythonhosted.org/packages/a1/14/f1e15b851d1c2af5b0b1a82bf8eb10bda2da62d98180220ba6fd8879bb5b/hf_transfer-0.1.9-cp38-abi3-win_amd64.whl", hash = "sha256:16f208fc678911c37e11aa7b586bc66a37d02e636208f18b6bc53d29b5df40ad", size = 1160240, upload-time = "2025-01-07T10:05:14.324Z" }, +] + [[package]] name = "hf-xet" version = "1.2.0" @@ -3250,7 +3453,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "0.36.2" +version = "0.35.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -3262,9 +3465,17 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/7e/a0a97de7c73671863ca6b3f61fa12518caf35db37825e43d63a70956738c/huggingface_hub-0.35.3.tar.gz", hash = "sha256:350932eaa5cc6a4747efae85126ee220e4ef1b54e29d31c3b45c5612ddf0b32a", size = 461798, upload-time = "2025-09-29T14:29:58.625Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" }, + { url = "https://files.pythonhosted.org/packages/31/a0/651f93d154cb72323358bf2bbae3e642bdb5d2f1bfc874d096f7cb159fa0/huggingface_hub-0.35.3-py3-none-any.whl", hash = "sha256:0e3a01829c19d86d03793e4577816fe3bdfc1602ac62c7fb220d593d351224ba", size = 564262, upload-time = "2025-09-29T14:29:55.813Z" }, +] + +[package.optional-dependencies] +cli = [ + { name = "inquirerpy" }, +] +hf-transfer = [ + { name = "hf-transfer" }, ] [[package]] @@ -3331,6 +3542,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, ] +[package.optional-dependencies] +ffmpeg = [ + { name = "imageio-ffmpeg" }, + { name = "psutil" }, +] + +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" }, + { url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" }, + { url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" }, + { url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" }, +] + [[package]] name = "importlib-metadata" version = "8.7.1" @@ -3361,6 +3592,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "inquirerpy" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pfzy" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/73/7570847b9da026e07053da3bbe2ac7ea6cde6bb2cbd3c7a5a950fa0ae40b/InquirerPy-0.3.4.tar.gz", hash = "sha256:89d2ada0111f337483cb41ae31073108b2ec1e618a49d7110b0d7ade89fc197e", size = 44431, upload-time = "2022-06-27T23:11:20.598Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/ff/3b59672c47c6284e8005b42e84ceba13864aa0f39f067c973d1af02f5d91/InquirerPy-0.3.4-py3-none-any.whl", hash = "sha256:c65fdfbac1fa00e3ee4fb10679f4d3ed7a012abf4833910e63c295827fe2a7d4", size = 67677, upload-time = "2022-06-27T23:11:17.723Z" }, +] + [[package]] name = "iopath" version = "0.1.10" @@ -3404,24 +3648,25 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version < '3.11'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, - { name = "jedi", marker = "python_full_version < '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, - { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, - { name = "pygments", marker = "python_full_version < '3.11'" }, - { name = "stack-data", marker = "python_full_version < '3.11'" }, - { name = "traitlets", marker = "python_full_version < '3.11'" }, - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" } wheels = [ @@ -3436,9 +3681,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -3449,17 +3696,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.11'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, - { name = "jedi", marker = "python_full_version >= '3.11'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, - { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "pygments", marker = "python_full_version >= '3.11'" }, - { name = "stack-data", marker = "python_full_version >= '3.11'" }, - { name = "traitlets", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" } wheels = [ @@ -3471,7 +3718,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "pygments" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -3503,18 +3750,19 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "jaxlib", version = "0.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ml-dtypes", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "opt-einsum", marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "jaxlib", version = "0.6.2", source = { registry = "https://pypi.org/simple" } }, + { name = "ml-dtypes" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "opt-einsum" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/cf/1e/267f59c8fb7f143c3f778c76cb7ef1389db3fd7e4540f04b9f42ca90764d/jax-0.6.2.tar.gz", hash = "sha256:a437d29038cbc8300334119692744704ca7941490867b9665406b7f90665cd96", size = 2334091, upload-time = "2025-06-17T23:10:27.186Z" } wheels = [ @@ -3529,9 +3777,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -3542,11 +3792,11 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "ml-dtypes", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "opt-einsum", marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "ml-dtypes" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "opt-einsum" }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/52/40/f85d1feadd8f793fc1bfab726272523ef34b27302b55861ea872ec774019/jax-0.9.0.1.tar.gz", hash = "sha256:e395253449d74354fa813ff9e245acb6e42287431d8a01ff33d92e9ee57d36bd", size = 2534795, upload-time = "2026-02-05T18:47:33.088Z" } wheels = [ @@ -3560,16 +3810,17 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "ml-dtypes", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ml-dtypes" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/15/c5/41598634c99cbebba46e6777286fb76abc449d33d50aeae5d36128ca8803/jaxlib-0.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4601b2b5dc8c23d6afb293eacfb9aec4e1d1871cb2f29c5a151d103e73b0f8", size = 54298019, upload-time = "2025-06-17T23:10:36.916Z" }, @@ -3594,9 +3845,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -3607,9 +3860,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "ml-dtypes", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ml-dtypes" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" } }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/b0/fd/040321b0f4303ec7b558d69488c6130b1697c33d88dab0a0d2ccd2e0817c/jaxlib-0.9.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ff2c550dab210278ed3a3b96454b19108a02e0795625be56dca5a181c9833c9", size = 56092920, upload-time = "2026-02-05T18:46:20.873Z" }, @@ -3646,7 +3899,7 @@ name = "jaxtyping" version = "0.3.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wadler-lindig", marker = "python_full_version >= '3.11'" }, + { name = "wadler-lindig" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/40/a2ea3ce0e3e5f540eb970de7792c90fa58fef1b27d34c83f9fa94fea4729/jaxtyping-0.3.7.tar.gz", hash = "sha256:3bd7d9beb7d3cb01a89f93f90581c6f4fff3e5c5dc3c9307e8f8687a040d10c4", size = 45721, upload-time = "2026-01-30T14:18:47.409Z" } wheels = [ @@ -3740,6 +3993,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] +[[package]] +name = "jsonlines" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/87/bcda8e46c88d0e34cad2f09ee2d0c7f5957bccdb9791b0b934ec84d84be4/jsonlines-4.0.0.tar.gz", hash = "sha256:0c6d2c09117550c089995247f605ae4cf77dd1533041d366351f6f298822ea74", size = 11359, upload-time = "2023-09-01T12:34:44.187Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/62/d9ba6323b9202dd2fe166beab8a86d29465c41a0288cbe229fac60c1ab8d/jsonlines-4.0.0-py3-none-any.whl", hash = "sha256:185b334ff2ca5a91362993f42e83588a360cf95ce4b71a73548502bda52a7c55", size = 8701, upload-time = "2023-09-01T12:34:42.563Z" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -4123,6 +4388,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/3a/a31d94a20c3ac5d6fd3fa89b2de66dbf6a7f01551fdff29d9a5fbf02cba6/lcm_dimos_fork-1.5.2.post1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:700294567efed85e96f2ae435b7c55d8702e15f3d06154f56216c1be5c85b322", size = 2882574, upload-time = "2026-06-03T07:09:23.407Z" }, ] +[[package]] +name = "lerobot" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "accelerate" }, + { name = "av" }, + { name = "cmake" }, + { name = "datasets" }, + { name = "deepdiff" }, + { name = "diffusers" }, + { name = "draccus" }, + { name = "einops" }, + { name = "gymnasium" }, + { name = "huggingface-hub", extra = ["cli", "hf-transfer"] }, + { name = "imageio", extra = ["ffmpeg"] }, + { name = "jsonlines" }, + { name = "opencv-python-headless", marker = "sys_platform == 'never'" }, + { name = "packaging" }, + { name = "pynput" }, + { name = "pyserial" }, + { name = "rerun-sdk" }, + { name = "setuptools" }, + { name = "termcolor" }, + { name = "torch" }, + { name = "torchcodec", marker = "(platform_machine != 'aarch64' and platform_machine != 'arm64' and platform_machine != 'armv7l' and sys_platform == 'linux') or (platform_machine != 'x86_64' and sys_platform == 'darwin') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "torchvision" }, + { name = "wandb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/b8/3278777bcc001e9c6ae35bab57638f036af6931d8630549cc66ea616b4d7/lerobot-0.4.4.tar.gz", hash = "sha256:50ad61441b1e1260010031406ca836a514af20ae1d2c0149892fe040380b1016", size = 842972, upload-time = "2026-02-27T18:07:46.142Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/2d/2c43b7d99346b04925313f485b8f99596aeb8094f556d4312da9f2e1ca60/lerobot-0.4.4-py3-none-any.whl", hash = "sha256:e53800ead8216861540ad3aebaf12e3cf87a399b3c1f234eeead33716c9c24fd", size = 1078897, upload-time = "2026-02-27T18:07:44.028Z" }, +] + [[package]] name = "libcoal" version = "3.0.2" @@ -4505,7 +4804,7 @@ name = "marshmallow" version = "3.26.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } wheels = [ @@ -4664,6 +4963,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/8c/52f0299f1675cdfa1ab39a6028a2e5adf9032ae1118c9895c84b08af162b/mediapy-1.2.6-py3-none-any.whl", hash = "sha256:0a0ea00eb0da83c3c54d588b49c49a41ba456174aa33e530ffe13e17269c9072", size = 27494, upload-time = "2026-02-03T10:29:30.245Z" }, ] +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + [[package]] name = "ml-collections" version = "1.1.0" @@ -4790,8 +5098,8 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/37/e7/d04ea5c587fd8b491fbe9377fafa5feb063bb28a3a6949fb393a62230d9d/mosek-11.0.24-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7f2ab70ad3357f9187c96237d0c49187f82f5885250a5e211b6aa20cb0a7207f", size = 8345311, upload-time = "2025-06-25T10:51:51.777Z" }, @@ -4803,15 +5111,18 @@ version = "11.1.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c3/e9/253e759e6e00b9cfbb4e95e7fe079b0e971b3c81c75f059bf2c2be3216e9/mosek-11.1.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:5c3566d2a603d94a1773bcd27097c8390dba1d9a1543534f3527deb56f1d0a55", size = 15359313, upload-time = "2026-01-07T08:22:00.805Z" }, @@ -5012,6 +5323,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "multiprocess" +version = "0.70.19" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dill" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/f2/e783ac7f2aeeed14e9e12801f22529cc7e6b7ab80928d6dcce4e9f00922d/multiprocess-0.70.19.tar.gz", hash = "sha256:952021e0e6c55a4a9fe4cd787895b86e239a40e76802a789d6305398d3975897", size = 2079989, upload-time = "2026-01-19T06:47:39.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/b6/10832f96b499690854e574360be342a282f5f7dba58eff791299ff6c0637/multiprocess-0.70.19-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:02e5c35d7d6cd2bdc89c1858867f7bde4012837411023a4696c148c1bdd7c80e", size = 135131, upload-time = "2026-01-19T06:47:20.479Z" }, + { url = "https://files.pythonhosted.org/packages/99/50/faef2d8106534b0dc4a0b772668a1a99682696ebf17d3c0f13f2ed6a656a/multiprocess-0.70.19-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:79576c02d1207ec405b00cabf2c643c36070800cca433860e14539df7818b2aa", size = 135131, upload-time = "2026-01-19T06:47:21.879Z" }, + { url = "https://files.pythonhosted.org/packages/94/b1/0b71d18b76bf423c2e8ee00b31db37d17297ab3b4db44e188692afdca628/multiprocess-0.70.19-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6b6d78d43a03b68014ca1f0b7937d965393a670c5de7c29026beb2258f2f896", size = 135134, upload-time = "2026-01-19T06:47:23.262Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/714635c727dbfc251139226fa4eaf1b07f00dc12d9cd2eb25f931adaf873/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1bbf1b69af1cf64cd05f65337d9215b88079ec819cd0ea7bac4dab84e162efe7", size = 144743, upload-time = "2026-01-19T06:47:24.562Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e1/155f6abf5e6b5d9cef29b6d0167c180846157a4aca9b9bee1a217f67c959/multiprocess-0.70.19-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5be9ec7f0c1c49a4f4a6fd20d5dda4aeabc2d39a50f4ad53720f1cd02b3a7c2e", size = 144738, upload-time = "2026-01-19T06:47:26.636Z" }, + { url = "https://files.pythonhosted.org/packages/af/cb/f421c2869d75750a4f32301cc20c4b63fab6376e9a75c8e5e655bdeb3d9b/multiprocess-0.70.19-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:1c3dce098845a0db43b32a0b76a228ca059a668071cfeaa0f40c36c0b1585d45", size = 144741, upload-time = "2026-01-19T06:47:27.985Z" }, + { url = "https://files.pythonhosted.org/packages/e3/45/8004d1e6b9185c1a444d6b55ac5682acf9d98035e54386d967366035a03a/multiprocess-0.70.19-py310-none-any.whl", hash = "sha256:97404393419dcb2a8385910864eedf47a3cadf82c66345b44f036420eb0b5d87", size = 134948, upload-time = "2026-01-19T06:47:32.325Z" }, + { url = "https://files.pythonhosted.org/packages/86/c2/dec9722dc3474c164a0b6bcd9a7ed7da542c98af8cabce05374abab35edd/multiprocess-0.70.19-py311-none-any.whl", hash = "sha256:928851ae7973aea4ce0eaf330bbdafb2e01398a91518d5c8818802845564f45c", size = 144457, upload-time = "2026-01-19T06:47:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl", hash = "sha256:3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28", size = 150281, upload-time = "2026-01-19T06:47:35.037Z" }, + { url = "https://files.pythonhosted.org/packages/7e/82/69e539c4c2027f1e1697e09aaa2449243085a0edf81ae2c6341e84d769b6/multiprocess-0.70.19-py39-none-any.whl", hash = "sha256:0d4b4397ed669d371c81dcd1ef33fd384a44d6c3de1bd0ca7ac06d837720d3c5", size = 133477, upload-time = "2026-01-19T06:47:38.619Z" }, +] + [[package]] name = "mypy" version = "1.19.0" @@ -5095,7 +5427,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -5114,9 +5447,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -5172,7 +5507,8 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -5224,9 +5560,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -5306,7 +5644,7 @@ name = "nvidia-cudnn-cu12" version = "9.10.2.21" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'" }, + { name = "nvidia-cublas-cu12" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, @@ -5317,7 +5655,7 @@ name = "nvidia-cufft-cu12" version = "11.3.3.83" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, @@ -5344,9 +5682,9 @@ name = "nvidia-cusolver-cu12" version = "11.7.3.90" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'" }, - { name = "nvidia-cusparse-cu12", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'" }, - { name = "nvidia-nvjitlink-cu12", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'" }, + { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cusparse-cu12" }, + { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, @@ -5357,7 +5695,7 @@ name = "nvidia-cusparse-cu12" version = "12.5.8.93" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'" }, + { name = "nvidia-nvjitlink-cu12" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, @@ -5466,12 +5804,12 @@ name = "onnxruntime-gpu" version = "1.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "flatbuffers", marker = "platform_machine != 'aarch64'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64'" }, - { name = "packaging", marker = "platform_machine != 'aarch64'" }, - { name = "protobuf", marker = "platform_machine != 'aarch64'" }, - { name = "sympy", marker = "platform_machine != 'aarch64'" }, + { name = "flatbuffers" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ca/c7/07d06175f1124fc89e8b7da30d70eb8e0e1400d90961ae1cbea9da69e69b/onnxruntime_gpu-1.24.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac4bfc90c376516b13d709764ab257e4e3d78639bf6a2ccfc826e9db4a5c7ddf", size = 252616647, upload-time = "2026-02-05T17:24:02.993Z" }, @@ -5504,23 +5842,23 @@ name = "open3d" version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "addict", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "configargparse", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "dash", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "flask", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "matplotlib", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "nbformat", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, - { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, - { name = "pillow", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "pyquaternion", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "pyyaml", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, - { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, - { name = "tqdm", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, - { name = "werkzeug", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "addict" }, + { name = "configargparse" }, + { name = "dash" }, + { name = "flask" }, + { name = "matplotlib" }, + { name = "nbformat" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pillow" }, + { name = "pyquaternion" }, + { name = "pyyaml" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "tqdm" }, + { name = "werkzeug" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/5c/4b/91e8a4100adf0ccd2f7ad21dd24c2e3d8f12925396528d0462cfb1735e5a/open3d-0.19.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:f7128ded206e07987cc29d0917195fb64033dea31e0d60dead3629b33d3c175f", size = 103086005, upload-time = "2025-01-08T07:25:56.755Z" }, @@ -5539,13 +5877,13 @@ name = "open3d-unofficial-arm" version = "0.19.0.post9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "configargparse", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, - { name = "dash", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, - { name = "flask", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, - { name = "nbformat", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'" }, - { name = "werkzeug", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "configargparse" }, + { name = "dash" }, + { name = "flask" }, + { name = "nbformat" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "werkzeug" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/f9/edcfaa213800ea278804402baa65693840bc7a323b3de8a31c54ce4e42c8/open3d_unofficial_arm-0.19.0.post9.tar.gz", hash = "sha256:ee300bd557f04750db6e47ccb6c6867c6dd6cfc04169dddeb92505da9ea739ef", size = 5327, upload-time = "2026-04-16T21:21:11.152Z" } wheels = [ @@ -5615,10 +5953,20 @@ name = "opencv-python" version = "4.13.0.92" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] +[[package]] +name = "opencv-python-headless" +version = "5.0.0.93" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } + [[package]] name = "opentelemetry-api" version = "1.42.1" @@ -5762,21 +6110,30 @@ name = "orbax-export" version = "0.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "absl-py", marker = "python_full_version >= '3.11'" }, - { name = "dataclasses-json", marker = "python_full_version >= '3.11'" }, - { name = "etils", marker = "python_full_version >= '3.11'" }, - { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "jaxtyping", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "orbax-checkpoint", marker = "python_full_version >= '3.11'" }, - { name = "protobuf", marker = "python_full_version >= '3.11'" }, + { name = "absl-py" }, + { name = "dataclasses-json" }, + { name = "etils" }, + { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, + { name = "jaxtyping" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "orbax-checkpoint" }, + { name = "protobuf" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/c8/ed7ac3c3c687bf129d7469b016c2b3d8777379f4ea453474e50ee41ce5cb/orbax_export-0.0.8.tar.gz", hash = "sha256:544eef564e2a6f17cd11b1167febe348b7b7cf56d9575de994a33d5613dd568a", size = 124980, upload-time = "2025-09-17T15:41:14.264Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/a9/3a755a58c8b6a36fe7e9e66bb6b93967ff49cdbc77cca8eacb2cf66435e9/orbax_export-0.0.8-py3-none-any.whl", hash = "sha256:f8037e1666ad28411cdb08d0668a2737b1281a32902c623ceda12109a089bc36", size = 180487, upload-time = "2025-09-17T15:41:12.928Z" }, ] +[[package]] +name = "orderly-set" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" }, +] + [[package]] name = "orjson" version = "3.11.7" @@ -5887,17 +6244,18 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11'" }, - { name = "pytz", marker = "python_full_version < '3.11'" }, - { name = "tzdata", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -5932,9 +6290,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -5945,9 +6305,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" } wheels = [ @@ -6006,13 +6366,22 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "pfzy" +version = "0.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/5a/32b50c077c86bfccc7bed4881c5a2b823518f5450a30e639db5d3711952e/pfzy-0.3.4.tar.gz", hash = "sha256:717ea765dd10b63618e7298b2d98efd819e0b30cd5905c9707223dceeb94b3f1", size = 8396, upload-time = "2022-01-28T02:26:17.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d7/8ff98376b1acc4503253b685ea09981697385ce344d4e3935c2af49e044d/pfzy-0.3.4-py3-none-any.whl", hash = "sha256:5f50d5b2b3207fa72e7ec0ef08372ef652685470974a107d0d4999fc5a903a96", size = 8537, upload-time = "2022-01-28T02:26:16.047Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -6749,7 +7118,7 @@ name = "pydot" version = "4.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyparsing", marker = "platform_machine != 'aarch64'" }, + { name = "pyparsing" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } wheels = [ @@ -6913,6 +7282,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/ca/995d1201925ad49fb6b174a9d488f1d90b77256b1088ebd3d7f192b0f65a/pymavlink-2.4.49-cp312-cp312-win_arm64.whl", hash = "sha256:c7415592166d9cbd4434775828b00c71bebf292c8367744d861e3ccd2dab9f3e", size = 6231742, upload-time = "2025-08-01T23:32:20.707Z" }, ] +[[package]] +name = "pynput" +version = "1.8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "evdev", marker = "'linux' in sys_platform" }, + { name = "pyobjc-framework-applicationservices", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, + { name = "python-xlib", marker = "'linux' in sys_platform" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/c6/e2d415610cfbc78308bee44218a46124aaa3301b1df08814df819b2254a1/pynput-1.8.2.tar.gz", hash = "sha256:f493c87157cd3861b4468f7f896857051762f44ed26f1b641e7cc5840a457087", size = 82818, upload-time = "2026-05-12T19:11:39.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/98/bbeb760852adb27f166ce1617f0e51aabb15f21b1e60ea703f2aed3c78ac/pynput-1.8.2-py2.py3-none-any.whl", hash = "sha256:8cc38cf13a6ab2749cb375678be8a0fd705d7ce49c8001ff5db4007a723bbef1", size = 92028, upload-time = "2026-05-12T19:11:37.89Z" }, +] + [[package]] name = "pyobjc-core" version = "12.1" @@ -6924,12 +7309,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/5a/6b15e499de73050f4a2c88fff664ae154307d25dc04da8fb38998a428358/pyobjc_core-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:818bcc6723561f207e5b5453efe9703f34bc8781d11ce9b8be286bb415eb4962", size = 678335, upload-time = "2025-11-14T09:32:20.107Z" }, ] +[[package]] +name = "pyobjc-framework-applicationservices" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-coretext" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/6a/d4e613c8e926a5744fc47a9e9fea08384a510dc4f27d844f7ad7a2d793bd/pyobjc_framework_applicationservices-12.1.tar.gz", hash = "sha256:c06abb74f119bc27aeb41bf1aef8102c0ae1288aec1ac8665ea186a067a8945b", size = 103247, upload-time = "2025-11-14T10:08:52.18Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/9d/3cf36e7b08832e71f5d48ddfa1047865cf2dfc53df8c0f2a82843ea9507a/pyobjc_framework_applicationservices-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c4fd1b008757182b9e2603a63c6ffa930cc412fab47294ec64260ab3f8ec695d", size = 32791, upload-time = "2025-11-14T09:36:05.576Z" }, + { url = "https://files.pythonhosted.org/packages/17/86/d07eff705ff909a0ffa96d14fc14026e9fc9dd716233648c53dfd5056b8e/pyobjc_framework_applicationservices-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:bdddd492eeac6d14ff2f5bd342aba29e30dffa72a2d358c08444da22129890e2", size = 32784, upload-time = "2025-11-14T09:36:08.755Z" }, + { url = "https://files.pythonhosted.org/packages/37/a7/55fa88def5c02732c4b747606ff1cbce6e1f890734bbd00f5596b21eaa02/pyobjc_framework_applicationservices-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:c8f6e2fb3b3e9214ab4864ef04eee18f592b46a986c86ea0113448b310520532", size = 32835, upload-time = "2025-11-14T09:36:11.855Z" }, +] + [[package]] name = "pyobjc-framework-cocoa" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-core" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } wheels = [ @@ -6943,8 +7345,8 @@ name = "pyobjc-framework-corebluetooth" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, - { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/25/d21d6cb3fd249c2c2aa96ee54279f40876a0c93e7161b3304bf21cbd0bfe/pyobjc_framework_corebluetooth-12.1.tar.gz", hash = "sha256:8060c1466d90bbb9100741a1091bb79975d9ba43911c9841599879fc45c2bbe0", size = 33157, upload-time = "2025-11-14T10:13:28.064Z" } wheels = [ @@ -6953,13 +7355,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/56/01fef62a479cdd6ff9ee40b6e062a205408ff386ce5ba56d7e14a71fcf73/pyobjc_framework_corebluetooth-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe72c9732ee6c5c793b9543f08c1f5bdd98cd95dfc9d96efd5708ec9d6eeb213", size = 13209, upload-time = "2025-11-14T09:44:08.203Z" }, ] +[[package]] +name = "pyobjc-framework-coretext" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-framework-quartz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/29/da/682c9c92a39f713bd3c56e7375fa8f1b10ad558ecb075258ab6f1cdd4a6d/pyobjc_framework_coretext-12.1.tar.gz", hash = "sha256:e0adb717738fae395dc645c9e8a10bb5f6a4277e73cba8fa2a57f3b518e71da5", size = 90124, upload-time = "2025-11-14T10:14:38.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1c/ddecc72a672d681476c668bcedcfb8ade16383c028eac566ac7458fb91ef/pyobjc_framework_coretext-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:1c8315dcef6699c2953461d97117fe81402f7c29cff36d2950dacce028a362fd", size = 29987, upload-time = "2025-11-14T09:46:58.028Z" }, + { url = "https://files.pythonhosted.org/packages/f0/81/7b8efc41e743adfa2d74b92dec263c91bcebfb188d2a8f5eea1886a195ff/pyobjc_framework_coretext-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4f6742ba5b0bb7629c345e99eff928fbfd9e9d3d667421ac1a2a43bdb7ba9833", size = 29990, upload-time = "2025-11-14T09:47:01.206Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/ddf45bf0e3ba4fbdc7772de4728fd97ffc34a0b5a15e1ab1115b202fe4ae/pyobjc_framework_coretext-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d246fa654bdbf43bae3969887d58f0b336c29b795ad55a54eb76397d0e62b93c", size = 30108, upload-time = "2025-11-14T09:47:04.228Z" }, +] + [[package]] name = "pyobjc-framework-libdispatch" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, - { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/e8/75b6b9b3c88b37723c237e5a7600384ea2d84874548671139db02e76652b/pyobjc_framework_libdispatch-12.1.tar.gz", hash = "sha256:4035535b4fae1b5e976f3e0e38b6e3442ffea1b8aa178d0ca89faa9b8ecdea41", size = 38277, upload-time = "2025-11-14T10:16:46.235Z" } wheels = [ @@ -6968,6 +7386,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/6f/96e15c7b2f7b51fc53252216cd0bed0c3541bc0f0aeb32756fefd31bed7d/pyobjc_framework_libdispatch-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0e9570d7a9a3136f54b0b834683bf3f206acd5df0e421c30f8fd4f8b9b556789", size = 15650, upload-time = "2025-11-14T09:52:59.284Z" }, ] +[[package]] +name = "pyobjc-framework-quartz" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyobjc-core" }, + { name = "pyobjc-framework-cocoa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/f4/50c42c84796886e4d360407fb629000bb68d843b2502c88318375441676f/pyobjc_framework_quartz-12.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c6f312ae79ef8b3019dcf4b3374c52035c7c7bc4a09a1748b61b041bb685a0ed", size = 217799, upload-time = "2025-11-14T09:59:32.62Z" }, + { url = "https://files.pythonhosted.org/packages/b7/ef/dcd22b743e38b3c430fce4788176c2c5afa8bfb01085b8143b02d1e75201/pyobjc_framework_quartz-12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:19f99ac49a0b15dd892e155644fe80242d741411a9ed9c119b18b7466048625a", size = 217795, upload-time = "2025-11-14T09:59:46.922Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9b/780f057e5962f690f23fdff1083a4cfda5a96d5b4d3bb49505cac4f624f2/pyobjc_framework_quartz-12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7730cdce46c7e985535b5a42c31381af4aa6556e5642dc55b5e6597595e57a16", size = 218798, upload-time = "2025-11-14T10:00:01.236Z" }, +] + [[package]] name = "pyopengl" version = "3.1.10" @@ -7025,8 +7458,8 @@ name = "pyquaternion" version = "0.9.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/3d092aa20efaedacb89c3221a92c6491be5b28f618a2c36b52b53e7446c2/pyquaternion-0.9.9.tar.gz", hash = "sha256:b1f61af219cb2fe966b5fb79a192124f2e63a3f7a777ac3cadf2957b1a81bea8", size = 15530, upload-time = "2020-10-05T01:31:30.327Z" } wheels = [ @@ -7050,6 +7483,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/9b/f81c8009a3bf8cd2b1d1ce74321c6f8bdb7d7075895fb04800f3795b431d/pyrealsense2_extended-2.58.1.10581.post1-cp312-cp312-win_amd64.whl", hash = "sha256:76ddf1dadd4dd8c542d4249d50dc4507962808f9ae3b6e807f317f319abeead3", size = 8754299, upload-time = "2026-05-31T20:50:09.02Z" }, ] +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + [[package]] name = "pysocks" version = "1.7.1" @@ -7293,6 +7735,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/c7/deb8c5e604404dbf10a3808a858946ca3547692ff6316b698945bb72177e/python_socketio-5.16.1-py3-none-any.whl", hash = "sha256:a3eb1702e92aa2f2b5d3ba00261b61f062cce51f1cfb6900bf3ab4d1934d2d35", size = 82054, upload-time = "2026-02-06T23:42:05.772Z" }, ] +[[package]] +name = "python-xlib" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" }, +] + [[package]] name = "pytokens" version = "0.4.1" @@ -7406,6 +7860,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, ] +[[package]] +name = "pyyaml-include" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/be/2d07ad85e3d593d69640876a8686eae2c533db8cb7bf298d25c421b4d2d5/pyyaml-include-1.4.1.tar.gz", hash = "sha256:1a96e33a99a3e56235f5221273832464025f02ff3d8539309a3bf00dec624471", size = 20592, upload-time = "2024-03-25T14:56:43.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/ca/6a2cc3a73170d10b5af1f1613baa2ed1f8f46f62dd0bfab2bffd2c2fe260/pyyaml_include-1.4.1-py3-none-any.whl", hash = "sha256:323c7f3a19c82fbc4d73abbaab7ef4f793e146a13383866831631b26ccc7fb00", size = 19079, upload-time = "2024-03-25T14:56:41.274Z" }, +] + [[package]] name = "pyzmq" version = "27.1.0" @@ -7861,16 +8327,17 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib", marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, - { name = "threadpoolctl", marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, + { name = "joblib" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -7899,9 +8366,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", @@ -7910,10 +8379,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib", marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, - { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, - { name = "threadpoolctl", marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, + { name = "joblib" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" } }, + { name = "threadpoolctl" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -7938,14 +8407,15 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -7986,9 +8456,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -7999,7 +8471,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } wheels = [ @@ -8025,13 +8497,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/21/38165845392cae67b61843a52c6455d47d0cc2a40dd495c89f4362944654/scipy-1.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:f603d8a5518c7426414d1d8f82e253e454471de682ce5e39c29adb0df1efb86b", size = 24314368, upload-time = "2026-01-10T21:26:23.087Z" }, ] +[[package]] +name = "sentry-sdk" +version = "2.64.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/31/b7341f156a5f6f36f0b4845d6f1c28a2ae4799171dba7007f3a1e9b234b4/sentry_sdk-2.64.0.tar.gz", hash = "sha256:68be2c29e14ae310f8a39e1a79916b6d85c6cb41dcce789d14ff05fe293e4c55", size = 921020, upload-time = "2026-06-30T08:13:47.682Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/a8/3fb9a4319efa3b26f5be0e90e6d8918df43fa7c7e977d26390f589501d82/sentry_sdk-2.64.0-py3-none-any.whl", hash = "sha256:715ea91ca860a819e8d8a50a7bde3a80d0df3b4ed7b6660a20fb9a2d084188f1", size = 498901, upload-time = "2026-06-30T08:13:45.566Z" }, +] + [[package]] name = "setuptools" -version = "81.0.0" +version = "80.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, + { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, ] [[package]] @@ -8148,6 +8633,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -8367,15 +8861,16 @@ source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'win32'", "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "ml-dtypes", marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ml-dtypes" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/ee/05eb424437f4db63331c90e4605025eedc0f71da3faff97161d5d7b405af/tensorstore-0.1.78.tar.gz", hash = "sha256:e26074ffe462394cf54197eb76d6569b500f347573cd74da3f4dd5f510a4ad7c", size = 6913502, upload-time = "2025-10-06T17:44:29.649Z" } wheels = [ @@ -8404,9 +8899,11 @@ resolution-markers = [ "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'win32'", - "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'win32'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux'", + "python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'darwin'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'win32'", @@ -8417,8 +8914,8 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "ml-dtypes", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ml-dtypes" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/f6/e2403fc05b97ba74ad408a98a42c288e6e1b8eacc23780c153b0e5166179/tensorstore-0.1.81.tar.gz", hash = "sha256:687546192ea6f6c8ae28d18f13103336f68017d928b9f5a00325e9b0548d9c25", size = 7120819, upload-time = "2026-02-06T18:56:12.535Z" } wheels = [ @@ -8434,6 +8931,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/44/a9/c1a751e35a0fcff7f795398c4f98b6c8ea0f00fe7d7704f66a1e08d4352f/tensorstore-0.1.81-cp312-cp312-win_amd64.whl", hash = "sha256:b96cbf1ee74d9038762b2d81305ee1589ec89913a440df6cbd514bc5879655d2", size = 13226573, upload-time = "2026-02-06T18:55:36.463Z" }, ] +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + [[package]] name = "terminaltexteffects" version = "0.12.2" @@ -8557,6 +9063,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/f2/fd673d979185f5dcbac4be7d09461cbb99751554ffb6718d0013af8604cb/tokenizers-0.21.4-cp39-abi3-win_amd64.whl", hash = "sha256:475d807a5c3eb72c59ad9b5fcdb254f6e17f53dfcbb9903233b0dfa9c943b597", size = 2507568, upload-time = "2025-07-28T15:48:55.456Z" }, ] +[[package]] +name = "toml" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tomli" version = "2.4.0" @@ -8654,6 +9169,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/5c/dee910b87c4d5c0fcb41b50839ae04df87c1cfc663cf1b5fca7ea565eeaa/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6d3707a61863d1c4d6ebba7be4ca320f42b869ee657e9b2c21c736bf17000294", size = 79498198, upload-time = "2026-01-21T16:24:34.704Z" }, ] +[[package]] +name = "torchcodec" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/1c/549e79c0f5a7e48c1f767b648c3ea3301a8cd702f66c9cb32c86eb347d63/torchcodec-0.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2ecb3e38414a9326ffca56e49a05a9f513813aa118b7f540110d09ab725d1f7", size = 3406242, upload-time = "2026-01-22T15:41:42.056Z" }, + { url = "https://files.pythonhosted.org/packages/f0/47/041180c095e4dbc0cff8e847974bf400114379c8f114aa8c3c96e9d6bd4e/torchcodec-0.10.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:61f9b12fcd5b89d5e7e874c5feeb7c2c99821868a32f6ebbf6e8692409b2b6f7", size = 2062569, upload-time = "2026-01-22T15:41:34.99Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0d/51ab5cb4ba8eb60e3e39651a4d43be89a592cc193fe11feb6509509b0121/torchcodec-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3dde1ebd9677ec1587f1e45486b3d59bd3e41a0bf4fc9b3dc6880e64c421ad56", size = 3907950, upload-time = "2026-01-22T15:41:43.819Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/618bde55c1908583290883537326174e633a383a8337226ce0c7c6d70090/torchcodec-0.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:2e2be11c4468a58940572fcf5f8ed5e41187c1de214267f692e2fd5ac8731198", size = 2070483, upload-time = "2026-01-22T15:41:36.743Z" }, + { url = "https://files.pythonhosted.org/packages/52/ff/2b27797e039673156710e5a0febe87cafc203722acafa3d34db283b40cf9/torchcodec-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b35fa4061c5757f8d714187c040a90a11669de6470a644bb04e3cd335ff1c110", size = 4073213, upload-time = "2026-01-22T15:41:45.485Z" }, + { url = "https://files.pythonhosted.org/packages/29/34/ccc711b6dc581e43b8d8d227e4173a8826994ee7b68d6b3d82291f307325/torchcodec-0.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:6e43184d83ccced965b31cad5bb6200c779646fee2ec153a6d784b4def40c91b", size = 2083121, upload-time = "2026-01-22T15:41:37.947Z" }, +] + [[package]] name = "torchreid" version = "0.2.5" @@ -8806,7 +9334,7 @@ name = "triton" version = "3.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" }, + { name = "importlib-metadata" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8c/f7/f1c9d3424ab199ac53c2da567b859bcddbb9c9e7154805119f8bd95ec36f/triton-3.6.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6550fae429e0667e397e5de64b332d1e5695b73650ee75a6146e2e902770bea", size = 188105201, upload-time = "2026-01-20T16:00:29.272Z" }, @@ -8891,8 +9419,8 @@ name = "typing-inspect" version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "mypy-extensions", marker = "python_full_version >= '3.11'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, + { name = "mypy-extensions" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } wheels = [ @@ -9245,6 +9773,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/96/04e7b441807b26b794da5b11e59ed7f83b2cf8af202bd7eba8ad2fa6046e/wadler_lindig-0.1.7-py3-none-any.whl", hash = "sha256:e3ec83835570fd0a9509f969162aeb9c65618f998b1f42918cfc8d45122fe953", size = 20516, upload-time = "2025-06-18T07:00:41.684Z" }, ] +[[package]] +name = "wandb" +version = "0.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "gitpython" }, + { name = "packaging" }, + { name = "platformdirs" }, + { name = "protobuf" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sentry-sdk" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/5c/53cf9f74b89e90facc8c7892d1449f7b39527e50e5cd577346baeb97e423/wandb-0.24.2.tar.gz", hash = "sha256:968b5b91d0a164dfb2f8c604cdf69e6fb09de6596b85b9f9d3c916b71ae86198", size = 44237317, upload-time = "2026-02-05T00:12:16.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/82/5299fa22faf2dd55f33f05c26bf908b11ea4d25f32ac270d4bf838b0d97e/wandb-0.24.2-py3-none-macosx_12_0_arm64.whl", hash = "sha256:755b8a92edd28e15c052dc2bdc4652e26bce379fa7745360249cbfc589ff5f53", size = 21640026, upload-time = "2026-02-05T00:11:55.267Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/33cb321258778c25c00fb7eb578e69ce99428a66d4376eee4058f230a21a/wandb-0.24.2-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:5e6c0ad176792c7c3d1620a2ad65bd9a5f3886c69362af540d3667bfc97b67fb", size = 22894053, upload-time = "2026-02-05T00:11:58.304Z" }, + { url = "https://files.pythonhosted.org/packages/3e/99/33b0281ac9a0b0c251195e6ce6cb310efa2f84ee117a15e9997fc2f9503b/wandb-0.24.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85861f9b3e54a07b84bade0aa5f4caa156028ab959351d98816a45e3b1411d35", size = 21286409, upload-time = "2026-02-05T00:12:00.584Z" }, + { url = "https://files.pythonhosted.org/packages/70/c8/1b758bd903afee000f023cd03f335ff328a21b3914f9f9deda49b1e57723/wandb-0.24.2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:38661c666e70d7e1f460fc0a0edab8a393eaaa5f8773c17be534961a7022779d", size = 23026085, upload-time = "2026-02-05T00:12:02.682Z" }, + { url = "https://files.pythonhosted.org/packages/60/87/724583f258aaeb2c368c79d7412167ce628f8a5ca667faed3cd427dd3be2/wandb-0.24.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:656a4272000999569eb8e0773f1259403bc6bd3e7d1c7d2238d3e359874da9c4", size = 21342088, upload-time = "2026-02-05T00:12:05.375Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5c/e9b36ddc9beb2745a4fb1ec67ae7f995c31f7305a6d17837b72b228360ff/wandb-0.24.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:33cba098d95fd46720cc9023bd23e4a38e9b11836a836b4a57b8d41cff8985f2", size = 23120819, upload-time = "2026-02-05T00:12:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1ad011da4a5c860fdb88645c738a2dae914b1eea2249aa606659ccd1443f/wandb-0.24.2-py3-none-win32.whl", hash = "sha256:70db8680e8d7edb5bd60dfb7f31aeb5af30b31ad72498c47e1aba7471c337bb2", size = 22295643, upload-time = "2026-02-05T00:12:09.85Z" }, + { url = "https://files.pythonhosted.org/packages/38/8b/721c77616bd1fca8963bffef309da09cdff71002f9d4201dfd5bd370591a/wandb-0.24.2-py3-none-win_amd64.whl", hash = "sha256:a78ac1fa116b196cd33250b3d80f4a5c05c141ad949175515c007ec9826e49a6", size = 22295646, upload-time = "2026-02-05T00:12:11.898Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/f3919d7ee7ba99dabf0aac7e299c6c328f5eae94f9f6b28c76005f882d5d/wandb-0.24.2-py3-none-win_arm64.whl", hash = "sha256:b42614b99f8b9af69f88c15a84283a973c8cd5750e9c4752aa3ce21f13dbac9a", size = 20268261, upload-time = "2026-02-05T00:12:14.353Z" }, +] + [[package]] name = "warp-lang" version = "1.11.1" @@ -9449,7 +10006,7 @@ name = "winrt-runtime" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "sys_platform == 'win32'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } wheels = [ @@ -9469,7 +10026,7 @@ name = "winrt-windows-devices-bluetooth" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } wheels = [ @@ -9489,7 +10046,7 @@ name = "winrt-windows-devices-bluetooth-advertisement" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } wheels = [ @@ -9509,7 +10066,7 @@ name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } wheels = [ @@ -9529,7 +10086,7 @@ name = "winrt-windows-devices-enumeration" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } wheels = [ @@ -9549,7 +10106,7 @@ name = "winrt-windows-devices-radios" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } wheels = [ @@ -9569,7 +10126,7 @@ name = "winrt-windows-foundation" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } wheels = [ @@ -9589,7 +10146,7 @@ name = "winrt-windows-foundation-collections" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } wheels = [ @@ -9609,7 +10166,7 @@ name = "winrt-windows-storage-streams" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, + { name = "winrt-runtime" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } wheels = [ From 87330bfc5005c0646abe3f520721c79820163e4d Mon Sep 17 00:00:00 2001 From: Nabla7 Date: Mon, 20 Jul 2026 22:22:48 -0400 Subject: [PATCH 21/51] fix: make A1Z setup reproducible and safer --- .../manipulators/galaxea_a1z/adapter.py | 2 +- .../manipulators/galaxea_a1z/gs_usb_bus.py | 111 +++-- .../manipulators/galaxea_a1z/test_adapter.py | 9 + .../galaxea_a1z/test_gs_usb_bus.py | 70 +++ .../robot/manipulators/galaxea_a1z/README.md | 66 ++- .../galaxea_a1z/scripts/setup_a1z.sh | 220 ++++++--- .../manipulators/galaxea_a1z/test_setup.py | 53 +++ pyproject.toml | 9 + uv.lock | 434 ++++++++++-------- 9 files changed, 648 insertions(+), 326 deletions(-) create mode 100644 dimos/hardware/manipulators/galaxea_a1z/test_gs_usb_bus.py create mode 100644 dimos/robot/manipulators/galaxea_a1z/test_setup.py diff --git a/dimos/hardware/manipulators/galaxea_a1z/adapter.py b/dimos/hardware/manipulators/galaxea_a1z/adapter.py index d722607fa0..5c6bd31cdb 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/adapter.py @@ -184,7 +184,7 @@ def __init__( urdf_path: str | None = None, gripper: bool = False, gripper_free_drive: bool = False, - gripper_max_torque: float = 2.0, + gripper_max_torque: float = 0.5, gripper_max_opening_m: float = _GRIPPER_MAX_OPENING_M, transport: str = "auto", safe_start: bool = True, diff --git a/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py b/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py index 47bf5945b1..2cf278a6af 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py +++ b/dimos/hardware/manipulators/galaxea_a1z/gs_usb_bus.py @@ -35,18 +35,20 @@ USB continuously - libusb releases the GIL while blocked - and recv() becomes a queue pop that always meets the SDK's budget. -Requires: pip install pyusb gs_usb (plus libusb, e.g. brew install libusb). +Requires the locked ``galaxea-a1z`` dependency group plus system libusb; run +``dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh`` from the repo. Lives in galaxea_a1z/ because it is the only user today; promote to a shared location when a second CAN arm needs it. """ from __future__ import annotations +from collections.abc import Callable import contextlib import queue import threading import time -from typing import Any +from typing import Any, TypeVar import can @@ -63,6 +65,42 @@ # keeps returning fresh state instead of replaying stale history. _RX_QUEUE_MAX_FRAMES = 8192 _RX_READ_TIMEOUT_MS = 20 +_UsbInitialization = TypeVar("_UsbInitialization") + + +def _initialize_ready_usb_device( + vendor_id: int, + product_id: int, + discover_timeout: float, + initialize: Callable[[Any, Any], _UsbInitialization], +) -> _UsbInitialization: + """Wait for a USB device and complete a caller-supplied initialization. + + After ``GsUsb.stop()``, this adapter briefly re-enumerates on macOS. During + that window PyUSB can find its descriptor while configuration or control + transfers still raise ``USBError(ENOENT, "Entity not found")``. Retry the + complete initialization so immediate DimOS restarts are reliable. + """ + import usb.core # type: ignore[import-untyped] + + deadline = time.perf_counter() + discover_timeout + last_error: Exception | None = None + while True: + device = usb.core.find(idVendor=vendor_id, idProduct=product_id) + if device is not None: + try: + configuration = device.get_active_configuration() + return initialize(device, configuration) + except usb.core.USBError as exc: + last_error = exc + + if time.perf_counter() >= deadline: + message = ( + f"gs_usb adapter {vendor_id:04x}:{product_id:04x} not ready on USB " + f"(waited {discover_timeout:.0f}s)" + ) + raise can.CanInitializationError(message) from last_error + time.sleep(0.25) class GsUsbMacBus(can.BusABC): @@ -79,40 +117,35 @@ def __init__( discover_timeout: float = 5.0, **_: Any, ) -> None: - from gs_usb.gs_usb import GS_CAN_MODE_HW_TIMESTAMP, GsUsb - import usb.core - - # The adapter drops off the USB bus for a few seconds after a - # close (firmware reset on reopen, observed on hardware) - retry - # discovery instead of failing the first reconnect. - deadline = time.perf_counter() + discover_timeout - device = usb.core.find(idVendor=vendor_id, idProduct=product_id) - while device is None and time.perf_counter() < deadline: - time.sleep(0.25) - device = usb.core.find(idVendor=vendor_id, idProduct=product_id) - if device is None: - raise can.CanInitializationError( - f"gs_usb adapter {vendor_id:04x}:{product_id:04x} not found on USB " - f"(waited {discover_timeout:.0f}s)" - ) - # No kernel driver claims the interface on macOS; gs_usb's detach - # call would raise, so neutralize it. - device.detach_kernel_driver = lambda intf: None # type: ignore[method-assign] - - # The gs_usb library hardcodes TX endpoint 0x02; discover the real - # bulk OUT endpoint from the active configuration instead. - cfg = device.get_active_configuration() - intf = cfg[(0, 0)] - out_eps = [ep for ep in intf if not (ep.bEndpointAddress & 0x80)] - if not out_eps: - raise can.CanInitializationError("gs_usb adapter has no OUT endpoint") - self._out_endpoint = out_eps[0].bEndpointAddress - - self._gs = GsUsb(device) - if not self._gs.set_bitrate(bitrate): - raise can.CanInitializationError(f"failed to set bitrate {bitrate}") + from gs_usb.gs_usb import ( # type: ignore[import-untyped] + GS_CAN_MODE_HW_TIMESTAMP, + GsUsb, + ) + + def _initialize(device: Any, cfg: Any) -> tuple[Any, int]: + # No kernel driver claims the interface on macOS; gs_usb's detach + # call would raise, so neutralize it. + device.detach_kernel_driver = lambda intf: None + + # The gs_usb library hardcodes TX endpoint 0x02; discover the real + # bulk OUT endpoint from the active configuration instead. + intf = cfg[(0, 0)] + out_eps = [ep for ep in intf if not (ep.bEndpointAddress & 0x80)] + if not out_eps: + raise can.CanInitializationError("gs_usb adapter has no OUT endpoint") + gs = GsUsb(device) + if not gs.set_bitrate(bitrate): + raise can.CanInitializationError(f"failed to set bitrate {bitrate}") + gs.start(_GS_CAN_MODE_LISTEN_ONLY if listen_only else 0) + return gs, out_eps[0].bEndpointAddress + + self._gs, self._out_endpoint = _initialize_ready_usb_device( + vendor_id, + product_id, + discover_timeout, + _initialize, + ) self._hw_timestamp_flag = GS_CAN_MODE_HW_TIMESTAMP - self._gs.start(_GS_CAN_MODE_LISTEN_ONLY if listen_only else 0) self._flush_rx() self._rx_queue: queue.Queue[can.Message] = queue.Queue(maxsize=_RX_QUEUE_MAX_FRAMES) @@ -131,7 +164,7 @@ def _flush_rx(self, max_frames: int = 1024) -> int: disable-command acks) parse as motor feedback with garbage velocity values and trip startup safety checks. Observed on hardware. """ - from gs_usb.gs_usb_frame import GsUsbFrame + from gs_usb.gs_usb_frame import GsUsbFrame # type: ignore[import-untyped] frame = GsUsbFrame() flushed = 0 @@ -141,12 +174,12 @@ def _flush_rx(self, max_frames: int = 1024) -> int: print(f"GsUsbMacBus: flushed {flushed} stale frames from device queue") return flushed - @property + @property # type: ignore[misc] def state(self) -> can.BusState: return can.BusState.ACTIVE def send(self, msg: can.Message, timeout: float | None = None) -> None: - from gs_usb.gs_usb_frame import GsUsbFrame + from gs_usb.gs_usb_frame import GsUsbFrame # type: ignore[import-untyped] frame = GsUsbFrame(can_id=msg.arbitration_id, data=bytes(msg.data)) hw_ts = bool(self._gs.device_flags & self._hw_timestamp_flag) @@ -161,7 +194,7 @@ def _rx_loop(self) -> None: interpreter. TX echoes are discarded here so they never consume the consumer's drain budget. """ - from gs_usb.gs_usb_frame import GsUsbFrame + from gs_usb.gs_usb_frame import GsUsbFrame # type: ignore[import-untyped] frame = GsUsbFrame() while not self._rx_stop.is_set(): diff --git a/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py index 796a588784..9c811b0f9c 100644 --- a/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py +++ b/dimos/hardware/manipulators/galaxea_a1z/test_adapter.py @@ -536,6 +536,15 @@ def test_gripper_round_trips_meters_to_normalized( assert robot.gripper_fraction == pytest.approx(1.0) +def test_gripper_uses_vendor_safe_torque_default( + a1z_adapter_module: ModuleType, +) -> None: + _connected_adapter(a1z_adapter_module, gripper=True) + robot = _FakeArmRobot.instances[-1] + + assert robot.factory_kwargs["gripper_max_torque"] == pytest.approx(0.5) + + def test_configured_gripper_free_drive_tracks_adapter_lifecycle( a1z_adapter_module: ModuleType, ) -> None: diff --git a/dimos/hardware/manipulators/galaxea_a1z/test_gs_usb_bus.py b/dimos/hardware/manipulators/galaxea_a1z/test_gs_usb_bus.py new file mode 100644 index 0000000000..0970232bcc --- /dev/null +++ b/dimos/hardware/manipulators/galaxea_a1z/test_gs_usb_bus.py @@ -0,0 +1,70 @@ +# 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 sys +import time +from types import ModuleType +from typing import Any + +import pytest + +from dimos.hardware.manipulators.galaxea_a1z import gs_usb_bus + + +class _UsbError(Exception): + pass + + +class _ReenumeratingDevice: + def __init__(self) -> None: + self.configuration_calls = 0 + self.configuration = object() + + def get_active_configuration(self) -> Any: + self.configuration_calls += 1 + return self.configuration + + +def test_usb_discovery_retries_device_that_is_found_but_not_ready( + monkeypatch: pytest.MonkeyPatch, +) -> None: + device = _ReenumeratingDevice() + core = ModuleType("usb.core") + core.USBError = _UsbError # type: ignore[attr-defined] + core.find = lambda **_kwargs: device # type: ignore[attr-defined] + usb = ModuleType("usb") + usb.__path__ = [] # type: ignore[attr-defined] + usb.core = core # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "usb", usb) + monkeypatch.setitem(sys.modules, "usb.core", core) + monkeypatch.setattr(time, "sleep", lambda _seconds: None) + initialization_calls = 0 + + def _initialize(_device: Any, _configuration: Any) -> str: + nonlocal initialization_calls + initialization_calls += 1 + if initialization_calls == 1: + raise _UsbError(2, "Entity not found") + return "ready" + + result = gs_usb_bus._initialize_ready_usb_device( + 0xA8FA, + 0x8598, + 1.0, + _initialize, + ) + + assert result == "ready" + assert device.configuration_calls == 2 + assert initialization_calls == 2 diff --git a/dimos/robot/manipulators/galaxea_a1z/README.md b/dimos/robot/manipulators/galaxea_a1z/README.md index d3f4368916..c3e1503b49 100644 --- a/dimos/robot/manipulators/galaxea_a1z/README.md +++ b/dimos/robot/manipulators/galaxea_a1z/README.md @@ -4,29 +4,43 @@ The A1Z integration uses native Linux SocketCAN, the vendor's 250 Hz MIT position-control loop, the G1Z URDF for gravity compensation, and the vendor G1Z gripper implementation. -## Vendor SDK +## Host setup The G1Z requires the vendor SDK's `gripper` branch; vendor `main` does not accept `with_gripper` and cannot actuate CAN motor 7. -Run the one-command host setup as your normal user: +Run the one-command hardware setup as your normal user. The pinned vendor SDK +and, on macOS, the PyUSB/gs-usb transport are installed from the locked +`galaxea-a1z` dependency group. Linux then configures SocketCAN; macOS installs +or verifies Homebrew libusb and checks the attached HHS adapter without +enabling the arm: ```bash ./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh ``` -The wrapper verifies G1Z support and, when needed, installs a known-working -commit from the vendor's `gripper` branch into the DimOS virtual environment. -It requests `sudo` only when invoking the Linux SocketCAN setup. Use -`--sdk-only` to install or verify the Python SDK without touching CAN. +Add `--with-lerobot` to install the dataset, training, and live-policy runtime +in the same environment. Use `--sdk-only` to synchronize and verify Python +dependencies without checking or configuring attached CAN hardware: -For a manual SDK-only installation, use the same pinned source: +```bash +./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh --with-lerobot +./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh --sdk-only +``` + +The equivalent manual dependency sync is: ```bash -uv pip install \ - "a1z @ git+https://github.com/userguide-galaxea/GALAXEA-A1Z.git@e931ecd0e25ad35df251097ba42921b3d2fa7224" +uv sync --locked --inexact \ + --group galaxea-a1z \ + --extra learning \ + --extra lerobot ``` +Always include `--group galaxea-a1z` in later exact syncs, or rerun the setup +script. The group keeps the non-PyPI vendor SDK and macOS transport inside +`uv.lock`; no manual `uv pip install` step is required. + DimOS deliberately has no Linux userspace-CAN fallback. After boot or reconnecting the HHS adapter, the one-command setup can be rerun, or the CAN portion can be invoked directly to bind the adapter to the kernel driver, @@ -49,10 +63,13 @@ starting a hardware blueprint. Enabling the G1Z also initializes the gripper. ## Camera, teach, replay, and LeRobot export -The teach command uses a standard Linux UVC camera through DimOS's generic -`Webcam` and `CameraModule`. The default camera is `/dev/video0`; select another -video device with `--camera-index N`. Each saved episode contains 640x480 RGB -images at 15 Hz plus the measured six arm joints and gripper position. +The teach command uses a standard UVC camera through DimOS's generic `Webcam` +and `CameraModule`. On Linux, index N normally maps to `/dev/videoN`. On macOS, +grant the terminal application Camera access in **System Settings → Privacy & +Security → Camera**, then select the AVFoundation device index with +`--camera-index N`. Use the index reported by OpenCV, not `ffmpeg`: their +AVFoundation device ordering can differ. Each saved episode contains 640x480 +RGB images at 15 Hz plus the measured six arm joints and gripper position. After the CAN setup check passes, record one or more episodes: @@ -60,6 +77,12 @@ After the CAN setup check passes, record one or more episodes: uv run dimos a1z teach --task "pick up the object" ``` +On the hackathon Mac, OpenCV enumerates the external KS2A418 camera as index 0: + +```bash +uv run --no-sync dimos a1z teach --camera-index 0 --task "pick up the object" +``` + The command prints the Memory2 `.db` path. Replay a saved episode by passing that path (the latest saved episode is selected by default): @@ -84,12 +107,12 @@ The LeRobot output stores images as `observation.images.image`, the measured arm and gripper state as `observation.state`, and the next measured state as `action`. -Install the optional LeRobot trainer/runtime before running the A1Z SDK setup -(an exact `uv sync` removes packages installed outside the project lock): +Install or verify the complete locked training runtime: ```bash -uv sync --extra lerobot -./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh --sdk-only +./dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh \ + --sdk-only \ + --with-lerobot ``` Train an ACT checkpoint from the converted local dataset: @@ -99,20 +122,25 @@ uv run lerobot-train \ --dataset.repo_id=galaxea_a1z \ --dataset.root=./a1z_lerobot_dataset \ --policy.type=act \ - --policy.device=cuda \ + --policy.device=mps \ --policy.push_to_hub=false \ --output_dir=outputs/a1z_act \ --job_name=a1z_act \ --wandb.enable=false ``` -After `setup_a1z_can.sh` passes, run the trained policy. Loading and hardware +Use `--policy.device=cuda` on an NVIDIA training host. Apple-silicon Macs use +`mps`; CPU-only hosts can use `cpu` for a slow smoke test. + +After the host setup passes, run the trained policy. Loading and hardware initialization require confirmation, and inference starts only after live RGB and seven-joint observations are ready: ```bash uv run dimos a1z run-policy \ outputs/a1z_act/checkpoints/last/pretrained_model \ + --camera-index 0 \ + --device mps \ --task "pick up the object" \ --duration 20 ``` diff --git a/dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh b/dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh index df520cbdd8..b9a35c7a5f 100755 --- a/dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh +++ b/dimos/robot/manipulators/galaxea_a1z/scripts/setup_a1z.sh @@ -1,13 +1,9 @@ #!/usr/bin/env bash -# Install/verify the pinned Galaxea A1Z SDK as the normal user, then run the -# privileged Linux SocketCAN setup. This is the one-command A1Z host setup. +# Synchronize and verify the locked Galaxea A1Z runtime, then configure the +# platform CAN transport. This is the one-command A1Z host setup. set -euo pipefail -SDK_REPOSITORY="https://github.com/userguide-galaxea/GALAXEA-A1Z.git" -# Known-working revision from the vendor's gripper branch. Pinning the commit -# prevents a moving vendor branch from silently changing hackathon machines. -SDK_REVISION="e931ecd0e25ad35df251097ba42921b3d2fa7224" -SDK_REQUIREMENT="a1z @ git+${SDK_REPOSITORY}@${SDK_REVISION}" +DEPENDENCY_GROUP="galaxea-a1z" SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPOSITORY_ROOT="$(cd -- "$SCRIPT_DIR/../../../../.." && pwd)" @@ -15,59 +11,84 @@ CAN_SETUP_SCRIPT="$SCRIPT_DIR/setup_a1z_can.sh" usage() { cat <&2 - exit 2 - ;; -esac +with_lerobot=false +while (($#)); do + case "$1" in + --sdk-only) sdk_only=true ;; + --with-lerobot) with_lerobot=true ;; + -h|--help) + usage + exit 0 + ;; + *) + usage >&2 + exit 2 + ;; + esac + shift +done if ((EUID == 0)); then cat >&2 <&2 <&2 + exit 1 +fi + verify_sdk() { "$python_bin" - <<'PY' import inspect @@ -98,47 +119,58 @@ print(a1z.__file__) PY } -sdk_path="" -if sdk_path="$(verify_sdk 2>/dev/null)"; then - echo "A1Z vendor SDK check passed: G1Z gripper support is available." - echo "SDK package: $sdk_path" -else - uv_bin="${A1Z_UV_BIN:-$(command -v uv || true)}" - if [[ -z "$uv_bin" ]]; then - cat >&2 <&2 </dev/null 2>&1 +from importlib import metadata +import sys -Check internet/GitHub access, then retry this exact command: - "$uv_bin" pip install --python "$python_bin" \ - "$SDK_REQUIREMENT" -EOF - exit 1 +metadata.version(sys.argv[1]) +PY + then + echo "Removing conflicting $conflicting_package wheel..." + "$uv_bin" pip uninstall --python "$python_bin" "$conflicting_package" + opencv_needs_reinstall=true fi +done - if ! sdk_path="$(verify_sdk)"; then - cat >&2 <&2 </dev/null 2>&1 +import usb.backend.libusb1 + +if usb.backend.libusb1.get_backend() is None: + raise SystemExit(1) +PY + then + brew_bin="${A1Z_BREW_BIN:-$(command -v brew || true)}" + if [[ -z "$brew_bin" ]]; then + cat >&2 <&2 diff --git a/dimos/robot/manipulators/galaxea_a1z/test_setup.py b/dimos/robot/manipulators/galaxea_a1z/test_setup.py new file mode 100644 index 0000000000..0f20cd2081 --- /dev/null +++ b/dimos/robot/manipulators/galaxea_a1z/test_setup.py @@ -0,0 +1,53 @@ +# Copyright 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 pathlib import Path +import subprocess + +import tomllib + +REPOSITORY_ROOT = Path(__file__).parents[4] +PYPROJECT_PATH = REPOSITORY_ROOT / "pyproject.toml" +SETUP_SCRIPT = Path(__file__).parent / "scripts" / "setup_a1z.sh" + + +def test_locked_a1z_group_contains_vendor_and_macos_transport() -> None: + project = tomllib.loads(PYPROJECT_PATH.read_text()) + + dependencies = set(project["dependency-groups"]["galaxea-a1z"]) + + assert dependencies == { + "a1z @ git+https://github.com/userguide-galaxea/GALAXEA-A1Z.git@e931ecd0e25ad35df251097ba42921b3d2fa7224", + "gs-usb==0.3.1; sys_platform == 'darwin'", + "pyusb==1.3.1; sys_platform == 'darwin'", + } + + +def test_setup_help_documents_locked_runtime_and_lerobot_option() -> None: + result = subprocess.run( + [str(SETUP_SCRIPT), "--help"], + check=True, + capture_output=True, + text=True, + ) + + assert "--sdk-only" in result.stdout + assert "--with-lerobot" in result.stdout + assert "Synchronize the locked Galaxea A1Z runtime" in result.stdout + + +def test_macos_setup_opens_can_transport_without_transmitting() -> None: + setup_source = SETUP_SCRIPT.read_text() + + assert "GsUsbMacBus(listen_only=True)" in setup_source diff --git a/pyproject.toml b/pyproject.toml index 904895ccd8..c1d301249a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -369,6 +369,15 @@ all = [ # For autofix.yml autofix = ["ruff==0.14.3"] +# Galaxea does not publish the gripper-capable A1Z SDK on PyPI. Keep the +# pinned Git dependency in a development group so it is locked for in-repo +# hardware setup without leaking an unpublishable URL into project metadata. +galaxea-a1z = [ + "a1z @ git+https://github.com/userguide-galaxea/GALAXEA-A1Z.git@e931ecd0e25ad35df251097ba42921b3d2fa7224", + "gs-usb==0.3.1; sys_platform == 'darwin'", + "pyusb==1.3.1; sys_platform == 'darwin'", +] + # Project deps shared by `tests` and `lint`. project-deps = [ "dimos[web,visualization,webrtc]", diff --git a/uv.lock b/uv.lock index 2b5bdf868b..a3093f575e 100644 --- a/uv.lock +++ b/uv.lock @@ -50,6 +50,17 @@ overrides = [ { name = "rerun-sdk", specifier = "==0.32.0" }, ] +[[package]] +name = "a1z" +version = "0.0.1" +source = { git = "https://github.com/userguide-galaxea/GALAXEA-A1Z.git?rev=e931ecd0e25ad35df251097ba42921b3d2fa7224#e931ecd0e25ad35df251097ba42921b3d2fa7224" } +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pin" }, + { name = "python-can" }, +] + [[package]] name = "a750-control" version = "0.1.1" @@ -726,12 +737,12 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "absl-py" }, - { name = "jax", version = "0.6.2", source = { registry = "https://pypi.org/simple" } }, - { name = "jaxlib", version = "0.6.2", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "toolz" }, - { name = "typing-extensions" }, + { name = "absl-py", marker = "python_full_version < '3.11'" }, + { name = "jax", version = "0.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "jaxlib", version = "0.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "toolz", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/77/70/53c7d404ce9e2a94009aea7f77ef6e392f6740e071c62683a506647c520f/chex-0.1.90.tar.gz", hash = "sha256:d3c375aeb6154b08f1cccd2bee4ed83659ee2198a6acf1160d2fe2e4a6c87b5c", size = 92363, upload-time = "2025-07-23T19:50:47.945Z" } wheels = [ @@ -761,12 +772,12 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "absl-py" }, - { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, - { name = "toolz" }, - { name = "typing-extensions" }, + { name = "absl-py", marker = "python_full_version >= '3.11'" }, + { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "toolz", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5b/7d/812f01e7b2ddf28a0caa8dde56bd951a2c8f691c9bbfce38d469458d1502/chex-0.1.91.tar.gz", hash = "sha256:65367a521415ada905b8c0222b0a41a68337fcadf79a1fb6fc992dbd95dd9f76", size = 90302, upload-time = "2025-09-01T21:49:32.834Z" } wheels = [ @@ -1154,7 +1165,7 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -1219,7 +1230,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1389,7 +1400,7 @@ name = "cuda-bindings" version = "12.9.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder" }, + { name = "cuda-pathfinder", marker = "platform_machine != 'aarch64' and sys_platform == 'linux'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/7a/d8/b546104b8da3f562c1ff8ab36d130c8fe1dd6a045ced80b4f6ad74f7d4e1/cuda_bindings-12.9.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d3c842c2a4303b2a580fe955018e31aea30278be19795ae05226235268032e5", size = 12148218, upload-time = "2025-10-21T14:51:28.855Z" }, @@ -1410,9 +1421,9 @@ name = "cupy-cuda12x" version = "13.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "fastrlock" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "fastrlock", marker = "platform_machine != 'aarch64'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/53/2b/8064d94a6ab6b5c4e643d8535ab6af6cabe5455765540931f0ef60a0bc3b/cupy_cuda12x-13.6.0-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:e78409ea72f5ac7d6b6f3d33d99426a94005254fa57e10617f430f9fd7c3a0a1", size = 112238589, upload-time = "2025-08-18T08:24:15.541Z" }, @@ -1498,8 +1509,8 @@ name = "dataclasses-json" version = "0.6.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "marshmallow" }, - { name = "typing-inspect" }, + { name = "marshmallow", marker = "python_full_version >= '3.11'" }, + { name = "typing-inspect", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } wheels = [ @@ -1945,6 +1956,11 @@ webrtc = [ autofix = [ { name = "ruff" }, ] +galaxea-a1z = [ + { name = "a1z" }, + { name = "gs-usb", marker = "sys_platform == 'darwin'" }, + { name = "pyusb", marker = "sys_platform == 'darwin'" }, +] lint = [ { name = "aiortc" }, { name = "chromadb" }, @@ -2225,6 +2241,11 @@ provides-extras = ["misc", "visualization", "learning", "lerobot", "agents", "we [package.metadata.requires-dev] autofix = [{ name = "ruff", specifier = "==0.14.3" }] +galaxea-a1z = [ + { name = "a1z", git = "https://github.com/userguide-galaxea/GALAXEA-A1Z.git?rev=e931ecd0e25ad35df251097ba42921b3d2fa7224" }, + { name = "gs-usb", marker = "sys_platform == 'darwin'", specifier = "==0.3.1" }, + { name = "pyusb", marker = "sys_platform == 'darwin'", specifier = "==1.3.1" }, +] lint = [ { name = "aiortc", specifier = ">=1.14.0" }, { name = "chromadb", specifier = ">=1.0.0" }, @@ -2480,12 +2501,12 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "matplotlib" }, - { name = "mosek", version = "11.0.24", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pydot" }, - { name = "pyyaml" }, + { name = "matplotlib", marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, + { name = "mosek", version = "11.0.24", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'" }, + { name = "pydot", marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, + { name = "pyyaml", marker = "platform_machine != 'aarch64' and sys_platform == 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a0/31/aa4f1f5523381539e1028354cc535d5a3307d28fd33872f2b403454d8391/drake-1.45.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b0d9bd6196dc6d3b0e660fc6351fcf236727a45ef6a7123f8dc96f85b8662ac3", size = 57314509, upload-time = "2025-09-16T19:02:10.195Z" }, @@ -2507,12 +2528,12 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "matplotlib" }, - { name = "mosek", version = "11.1.2", source = { registry = "https://pypi.org/simple" } }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pydot" }, - { name = "pyyaml" }, + { name = "matplotlib", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin'" }, + { name = "mosek", version = "11.1.2", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'aarch64' and sys_platform != 'darwin'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin'" }, + { name = "pydot", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin'" }, + { name = "pyyaml", marker = "platform_machine != 'aarch64' and sys_platform != 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/fb/26/2ce3a9caf431f24e39f8b1fc7b3ebba4faafef1d61c849db3194e8d2e21d/drake-1.49.0-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:6c73dbd061fcb442e82b7b5a94dadcfbf4c44949035d03394df29412114647b2", size = 41482505, upload-time = "2026-01-15T19:44:08.313Z" }, @@ -2660,7 +2681,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -2927,15 +2948,15 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "jax", version = "0.6.2", source = { registry = "https://pypi.org/simple" } }, - { name = "msgpack" }, - { name = "optax" }, - { name = "orbax-checkpoint" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "tensorstore", version = "0.1.78", source = { registry = "https://pypi.org/simple" } }, - { name = "treescope" }, - { name = "typing-extensions" }, + { name = "jax", version = "0.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "msgpack", marker = "python_full_version < '3.11'" }, + { name = "optax", marker = "python_full_version < '3.11'" }, + { name = "orbax-checkpoint", marker = "python_full_version < '3.11'" }, + { name = "pyyaml", marker = "python_full_version < '3.11'" }, + { name = "rich", marker = "python_full_version < '3.11'" }, + { name = "tensorstore", version = "0.1.78", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "treescope", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e6/76/4ea55a60a47e98fcff591238ee26ed4624cb4fdc4893aa3ebf78d0d021f4/flax-0.10.7.tar.gz", hash = "sha256:2930d6671e23076f6db3b96afacf45c5060898f5c189ecab6dda7e05d26c2085", size = 5136099, upload-time = "2025-07-02T06:10:07.819Z" } wheels = [ @@ -2965,17 +2986,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, - { name = "msgpack" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, - { name = "optax" }, - { name = "orbax-checkpoint" }, - { name = "orbax-export" }, - { name = "pyyaml" }, - { name = "rich" }, - { name = "tensorstore", version = "0.1.81", source = { registry = "https://pypi.org/simple" } }, - { name = "treescope" }, - { name = "typing-extensions" }, + { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "msgpack", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "optax", marker = "python_full_version >= '3.11'" }, + { name = "orbax-checkpoint", marker = "python_full_version >= '3.11'" }, + { name = "orbax-export", marker = "python_full_version >= '3.11'" }, + { name = "pyyaml", marker = "python_full_version >= '3.11'" }, + { name = "rich", marker = "python_full_version >= '3.11'" }, + { name = "tensorstore", version = "0.1.81", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "treescope", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/81/802fd686d3f47d7560a83f73b23efff03de7e3a0342e4f0fc41680136709/flax-0.12.4.tar.gz", hash = "sha256:5e924734a0595ddfa06a824568617e5440c7948e744772cbe6101b7ae06d66a9", size = 5070824, upload-time = "2026-02-12T19:10:17.048Z" } wheels = [ @@ -3278,6 +3299,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" }, ] +[[package]] +name = "gs-usb" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyusb", marker = "sys_platform == 'darwin'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/d8/bb03645332d0de656b913cf4ac151aabc5c2c6e3c0c1fa30814fd4c45126/gs_usb-0.3.1.tar.gz", hash = "sha256:a8a76285fda29b45a5a633cfe232a08058f642441b3d030877dc28ef6e0b7807", size = 6605, upload-time = "2026-02-25T11:01:02.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/08/13b4e21ff15df9b7017e7e6265282df78098e50275bb99efc26b5d14ce29/gs_usb-0.3.1-py2.py3-none-any.whl", hash = "sha256:1c35d4404427db3f7955a687a1235b5829f94c61ece56eb0cf3f3b23e2d44883", size = 7333, upload-time = "2026-02-25T11:01:01.439Z" }, +] + [[package]] name = "gtsam-extended" version = "4.3a1.post1" @@ -3656,17 +3689,17 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e5/61/1810830e8b93c72dcd3c0f150c80a00c3deb229562d9423807ec92c3a539/ipython-8.38.0.tar.gz", hash = "sha256:9cfea8c903ce0867cc2f23199ed8545eb741f3a69420bfcf3743ad1cec856d39", size = 5513996, upload-time = "2026-01-05T10:59:06.901Z" } wheels = [ @@ -3696,17 +3729,17 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a6/60/2111715ea11f39b1535bed6024b7dec7918b71e5e5d30855a5b503056b50/ipython-9.10.0.tar.gz", hash = "sha256:cd9e656be97618a0676d058134cd44e6dc7012c0e5cb36a9ce96a8c904adaf77", size = 4426526, upload-time = "2026-02-02T10:00:33.594Z" } wheels = [ @@ -3718,7 +3751,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -3758,11 +3791,11 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "jaxlib", version = "0.6.2", source = { registry = "https://pypi.org/simple" } }, - { name = "ml-dtypes" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "opt-einsum" }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "jaxlib", version = "0.6.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "ml-dtypes", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "opt-einsum", marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cf/1e/267f59c8fb7f143c3f778c76cb7ef1389db3fd7e4540f04b9f42ca90764d/jax-0.6.2.tar.gz", hash = "sha256:a437d29038cbc8300334119692744704ca7941490867b9665406b7f90665cd96", size = 2334091, upload-time = "2025-06-17T23:10:27.186Z" } wheels = [ @@ -3792,11 +3825,11 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, - { name = "ml-dtypes" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, - { name = "opt-einsum" }, - { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" } }, + { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ml-dtypes", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "opt-einsum", marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/52/40/f85d1feadd8f793fc1bfab726272523ef34b27302b55861ea872ec774019/jax-0.9.0.1.tar.gz", hash = "sha256:e395253449d74354fa813ff9e245acb6e42287431d8a01ff33d92e9ee57d36bd", size = 2534795, upload-time = "2026-02-05T18:47:33.088Z" } wheels = [ @@ -3818,9 +3851,9 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "ml-dtypes" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, + { name = "ml-dtypes", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/15/c5/41598634c99cbebba46e6777286fb76abc449d33d50aeae5d36128ca8803/jaxlib-0.6.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4601b2b5dc8c23d6afb293eacfb9aec4e1d1871cb2f29c5a151d103e73b0f8", size = 54298019, upload-time = "2025-06-17T23:10:36.916Z" }, @@ -3860,9 +3893,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "ml-dtypes" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" } }, + { name = "ml-dtypes", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/b0/fd/040321b0f4303ec7b558d69488c6130b1697c33d88dab0a0d2ccd2e0817c/jaxlib-0.9.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ff2c550dab210278ed3a3b96454b19108a02e0795625be56dca5a181c9833c9", size = 56092920, upload-time = "2026-02-05T18:46:20.873Z" }, @@ -3899,7 +3932,7 @@ name = "jaxtyping" version = "0.3.7" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "wadler-lindig" }, + { name = "wadler-lindig", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/40/a2ea3ce0e3e5f540eb970de7792c90fa58fef1b27d34c83f9fa94fea4729/jaxtyping-0.3.7.tar.gz", hash = "sha256:3bd7d9beb7d3cb01a89f93f90581c6f4fff3e5c5dc3c9307e8f8687a040d10c4", size = 45721, upload-time = "2026-01-30T14:18:47.409Z" } wheels = [ @@ -4804,7 +4837,7 @@ name = "marshmallow" version = "3.26.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } wheels = [ @@ -5098,8 +5131,8 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64' and sys_platform == 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/37/e7/d04ea5c587fd8b491fbe9377fafa5feb063bb28a3a6949fb393a62230d9d/mosek-11.0.24-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7f2ab70ad3357f9187c96237d0c49187f82f5885250a5e211b6aa20cb0a7207f", size = 8345311, upload-time = "2025-06-25T10:51:51.777Z" }, @@ -5121,8 +5154,8 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64' and sys_platform != 'darwin'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c3/e9/253e759e6e00b9cfbb4e95e7fe079b0e971b3c81c75f059bf2c2be3216e9/mosek-11.1.2-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:5c3566d2a603d94a1773bcd27097c8390dba1d9a1543534f3527deb56f1d0a55", size = 15359313, upload-time = "2026-01-07T08:22:00.805Z" }, @@ -5644,7 +5677,7 @@ name = "nvidia-cudnn-cu12" version = "9.10.2.21" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine != 'aarch64' and sys_platform == 'linux'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" }, @@ -5655,7 +5688,7 @@ name = "nvidia-cufft-cu12" version = "11.3.3.83" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine != 'aarch64' and sys_platform == 'linux'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" }, @@ -5682,9 +5715,9 @@ name = "nvidia-cusolver-cu12" version = "11.7.3.90" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12" }, - { name = "nvidia-cusparse-cu12" }, - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-cublas-cu12", marker = "platform_machine != 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-cusparse-cu12", marker = "platform_machine != 'aarch64' and sys_platform == 'linux'" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine != 'aarch64' and sys_platform == 'linux'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" }, @@ -5695,7 +5728,7 @@ name = "nvidia-cusparse-cu12" version = "12.5.8.93" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink-cu12", marker = "platform_machine != 'aarch64' and sys_platform == 'linux'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" }, @@ -5804,12 +5837,12 @@ name = "onnxruntime-gpu" version = "1.24.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "flatbuffers" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "sympy" }, + { name = "flatbuffers", marker = "platform_machine != 'aarch64'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine != 'aarch64'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine != 'aarch64'" }, + { name = "packaging", marker = "platform_machine != 'aarch64'" }, + { name = "protobuf", marker = "platform_machine != 'aarch64'" }, + { name = "sympy", marker = "platform_machine != 'aarch64'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/ca/c7/07d06175f1124fc89e8b7da30d70eb8e0e1400d90961ae1cbea9da69e69b/onnxruntime_gpu-1.24.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac4bfc90c376516b13d709764ab257e4e3d78639bf6a2ccfc826e9db4a5c7ddf", size = 252616647, upload-time = "2026-02-05T17:24:02.993Z" }, @@ -5842,23 +5875,23 @@ name = "open3d" version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "addict" }, - { name = "configargparse" }, - { name = "dash" }, - { name = "flask" }, - { name = "matplotlib" }, - { name = "nbformat" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "pillow" }, - { name = "pyquaternion" }, - { name = "pyyaml" }, - { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "tqdm" }, - { name = "werkzeug" }, + { name = "addict", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "configargparse", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "dash", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "flask", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "matplotlib", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "nbformat", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, + { name = "pandas", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, + { name = "pandas", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, + { name = "pillow", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pyquaternion", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "pyyaml", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "scikit-learn", version = "1.7.2", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, + { name = "scikit-learn", version = "1.8.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, + { name = "tqdm", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, + { name = "werkzeug", marker = "platform_machine != 'aarch64' or sys_platform != 'linux'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/5c/4b/91e8a4100adf0ccd2f7ad21dd24c2e3d8f12925396528d0462cfb1735e5a/open3d-0.19.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:f7128ded206e07987cc29d0917195fb64033dea31e0d60dead3629b33d3c175f", size = 103086005, upload-time = "2025-01-08T07:25:56.755Z" }, @@ -5877,13 +5910,13 @@ name = "open3d-unofficial-arm" version = "0.19.0.post9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "configargparse" }, - { name = "dash" }, - { name = "flask" }, - { name = "nbformat" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "werkzeug" }, + { name = "configargparse", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "dash", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "flask", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "nbformat", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'" }, + { name = "werkzeug", marker = "platform_machine == 'aarch64' and sys_platform == 'linux'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ec/f9/edcfaa213800ea278804402baa65693840bc7a323b3de8a31c54ce4e42c8/open3d_unofficial_arm-0.19.0.post9.tar.gz", hash = "sha256:ee300bd557f04750db6e47ccb6c6867c6dd6cfc04169dddeb92505da9ea739ef", size = 5327, upload-time = "2026-04-16T21:21:11.152Z" } wheels = [ @@ -5953,8 +5986,8 @@ name = "opencv-python" version = "4.13.0.92" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'" }, ] [[package]] @@ -5962,8 +5995,8 @@ name = "opencv-python-headless" version = "5.0.0.93" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1d/99/76b7c80252aa83c1af16393454aafd125a0287101afe8deb0a6821af0e30/opencv_python_headless-5.0.0.93.tar.gz", hash = "sha256:b82f9831daab90b725c7c1ee1b36cb5732c367096ac76d119e64e14eb70d5f3c", size = 81817738, upload-time = "2026-07-02T07:01:06.039Z" } @@ -6110,15 +6143,15 @@ name = "orbax-export" version = "0.0.8" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "absl-py" }, - { name = "dataclasses-json" }, - { name = "etils" }, - { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" } }, - { name = "jaxtyping" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, - { name = "orbax-checkpoint" }, - { name = "protobuf" }, + { name = "absl-py", marker = "python_full_version >= '3.11'" }, + { name = "dataclasses-json", marker = "python_full_version >= '3.11'" }, + { name = "etils", marker = "python_full_version >= '3.11'" }, + { name = "jax", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jaxlib", version = "0.9.0.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "jaxtyping", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "orbax-checkpoint", marker = "python_full_version >= '3.11'" }, + { name = "protobuf", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/c8/ed7ac3c3c687bf129d7469b016c2b3d8777379f4ea453474e50ee41ce5cb/orbax_export-0.0.8.tar.gz", hash = "sha256:544eef564e2a6f17cd11b1167febe348b7b7cf56d9575de994a33d5613dd568a", size = 124980, upload-time = "2025-09-17T15:41:14.264Z" } wheels = [ @@ -6252,10 +6285,10 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "pytz" }, - { name = "tzdata" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "python-dateutil", marker = "python_full_version < '3.11'" }, + { name = "pytz", marker = "python_full_version < '3.11'" }, + { name = "tzdata", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -6305,9 +6338,9 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "python-dateutil", marker = "python_full_version >= '3.11'" }, + { name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" } wheels = [ @@ -6366,7 +6399,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -7118,7 +7151,7 @@ name = "pydot" version = "4.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyparsing" }, + { name = "pyparsing", marker = "platform_machine != 'aarch64'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } wheels = [ @@ -7314,10 +7347,10 @@ name = "pyobjc-framework-applicationservices" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-coretext" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-coretext", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/6a/d4e613c8e926a5744fc47a9e9fea08384a510dc4f27d844f7ad7a2d793bd/pyobjc_framework_applicationservices-12.1.tar.gz", hash = "sha256:c06abb74f119bc27aeb41bf1aef8102c0ae1288aec1ac8665ea186a067a8945b", size = 103247, upload-time = "2025-11-14T10:08:52.18Z" } wheels = [ @@ -7331,7 +7364,7 @@ name = "pyobjc-framework-cocoa" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/02/a3/16ca9a15e77c061a9250afbae2eae26f2e1579eb8ca9462ae2d2c71e1169/pyobjc_framework_cocoa-12.1.tar.gz", hash = "sha256:5556c87db95711b985d5efdaaf01c917ddd41d148b1e52a0c66b1a2e2c5c1640", size = 2772191, upload-time = "2025-11-14T10:13:02.069Z" } wheels = [ @@ -7345,8 +7378,8 @@ name = "pyobjc-framework-corebluetooth" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/4b/25/d21d6cb3fd249c2c2aa96ee54279f40876a0c93e7161b3304bf21cbd0bfe/pyobjc_framework_corebluetooth-12.1.tar.gz", hash = "sha256:8060c1466d90bbb9100741a1091bb79975d9ba43911c9841599879fc45c2bbe0", size = 33157, upload-time = "2025-11-14T10:13:28.064Z" } wheels = [ @@ -7360,9 +7393,9 @@ name = "pyobjc-framework-coretext" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, - { name = "pyobjc-framework-quartz" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/29/da/682c9c92a39f713bd3c56e7375fa8f1b10ad558ecb075258ab6f1cdd4a6d/pyobjc_framework_coretext-12.1.tar.gz", hash = "sha256:e0adb717738fae395dc645c9e8a10bb5f6a4277e73cba8fa2a57f3b518e71da5", size = 90124, upload-time = "2025-11-14T10:14:38.596Z" } wheels = [ @@ -7376,8 +7409,8 @@ name = "pyobjc-framework-libdispatch" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/26/e8/75b6b9b3c88b37723c237e5a7600384ea2d84874548671139db02e76652b/pyobjc_framework_libdispatch-12.1.tar.gz", hash = "sha256:4035535b4fae1b5e976f3e0e38b6e3442ffea1b8aa178d0ca89faa9b8ecdea41", size = 38277, upload-time = "2025-11-14T10:16:46.235Z" } wheels = [ @@ -7391,8 +7424,8 @@ name = "pyobjc-framework-quartz" version = "12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyobjc-core" }, - { name = "pyobjc-framework-cocoa" }, + { name = "pyobjc-core", marker = "sys_platform == 'darwin'" }, + { name = "pyobjc-framework-cocoa", marker = "sys_platform == 'darwin'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/94/18/cc59f3d4355c9456fc945eae7fe8797003c4da99212dd531ad1b0de8a0c6/pyobjc_framework_quartz-12.1.tar.gz", hash = "sha256:27f782f3513ac88ec9b6c82d9767eef95a5cf4175ce88a1e5a65875fee799608", size = 3159099, upload-time = "2025-11-14T10:21:24.31Z" } wheels = [ @@ -7458,8 +7491,8 @@ name = "pyquaternion" version = "0.9.9" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/3d092aa20efaedacb89c3221a92c6491be5b28f618a2c36b52b53e7446c2/pyquaternion-0.9.9.tar.gz", hash = "sha256:b1f61af219cb2fe966b5fb79a192124f2e63a3f7a777ac3cadf2957b1a81bea8", size = 15530, upload-time = "2020-10-05T01:31:30.327Z" } wheels = [ @@ -7808,6 +7841,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" }, ] +[[package]] +name = "pyusb" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/6b/ce3727395e52b7b76dfcf0c665e37d223b680b9becc60710d4bc08b7b7cb/pyusb-1.3.1.tar.gz", hash = "sha256:3af070b607467c1c164f49d5b0caabe8ac78dbed9298d703a8dbf9df4052d17e", size = 77281, upload-time = "2025-01-08T23:45:01.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/b8/27e6312e86408a44fe16bd28ee12dd98608b39f7e7e57884a24e8f29b573/pyusb-1.3.1-py3-none-any.whl", hash = "sha256:bf9b754557af4717fe80c2b07cc2b923a9151f5c08d17bdb5345dac09d6a0430", size = 58465, upload-time = "2025-01-08T23:45:00.029Z" }, +] + [[package]] name = "pywin32" version = "311" @@ -8334,10 +8376,10 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" } }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, + { name = "threadpoolctl", marker = "(python_full_version < '3.11' and platform_machine != 'aarch64') or (python_full_version < '3.11' and sys_platform != 'linux')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/98/c2/a7855e41c9d285dfe86dc50b250978105dce513d6e459ea66a6aeb0e1e0c/scikit_learn-1.7.2.tar.gz", hash = "sha256:20e9e49ecd130598f1ca38a1d85090e1a600147b9c02fa6f15d69cb53d968fda", size = 7193136, upload-time = "2025-09-09T08:21:29.075Z" } wheels = [ @@ -8379,10 +8421,10 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "joblib" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, - { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" } }, - { name = "threadpoolctl" }, + { name = "joblib", marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, + { name = "scipy", version = "1.17.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, + { name = "threadpoolctl", marker = "(python_full_version >= '3.11' and platform_machine != 'aarch64') or (python_full_version >= '3.11' and sys_platform != 'linux')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } wheels = [ @@ -8415,7 +8457,7 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } wheels = [ @@ -8471,7 +8513,7 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/56/3e/9cca699f3486ce6bc12ff46dc2031f1ec8eb9ccc9a320fdaf925f1417426/scipy-1.17.0.tar.gz", hash = "sha256:2591060c8e648d8b96439e111ac41fd8342fdeff1876be2e19dea3fe8930454e", size = 30396830, upload-time = "2026-01-10T21:34:23.009Z" } wheels = [ @@ -8869,8 +8911,8 @@ resolution-markers = [ "python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "ml-dtypes" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "ml-dtypes", marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/ee/05eb424437f4db63331c90e4605025eedc0f71da3faff97161d5d7b405af/tensorstore-0.1.78.tar.gz", hash = "sha256:e26074ffe462394cf54197eb76d6569b500f347573cd74da3f4dd5f510a4ad7c", size = 6913502, upload-time = "2025-10-06T17:44:29.649Z" } wheels = [ @@ -8914,8 +8956,8 @@ resolution-markers = [ "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32'", ] dependencies = [ - { name = "ml-dtypes" }, - { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" } }, + { name = "ml-dtypes", marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.3.5", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/43/f6/e2403fc05b97ba74ad408a98a42c288e6e1b8eacc23780c153b0e5166179/tensorstore-0.1.81.tar.gz", hash = "sha256:687546192ea6f6c8ae28d18f13103336f68017d928b9f5a00325e9b0548d9c25", size = 7120819, upload-time = "2026-02-06T18:56:12.535Z" } wheels = [ @@ -9334,7 +9376,7 @@ name = "triton" version = "3.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "importlib-metadata" }, + { name = "importlib-metadata", marker = "(platform_machine != 'aarch64' and sys_platform == 'linux') or (sys_platform != 'darwin' and sys_platform != 'linux' and sys_platform != 'win32')" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8c/f7/f1c9d3424ab199ac53c2da567b859bcddbb9c9e7154805119f8bd95ec36f/triton-3.6.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6550fae429e0667e397e5de64b332d1e5695b73650ee75a6146e2e902770bea", size = 188105201, upload-time = "2026-01-20T16:00:29.272Z" }, @@ -10006,7 +10048,7 @@ name = "winrt-runtime" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/dd/acdd527c1d890c8f852cc2af644aa6c160974e66631289420aa871b05e65/winrt_runtime-3.2.1.tar.gz", hash = "sha256:c8dca19e12b234ae6c3dadf1a4d0761b51e708457492c13beb666556958801ea", size = 21721, upload-time = "2025-06-06T14:40:27.593Z" } wheels = [ @@ -10026,7 +10068,7 @@ name = "winrt-windows-devices-bluetooth" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b2/a0/1c8a0c469abba7112265c6cb52f0090d08a67c103639aee71fc690e614b8/winrt_windows_devices_bluetooth-3.2.1.tar.gz", hash = "sha256:db496d2d92742006d5a052468fc355bf7bb49e795341d695c374746113d74505", size = 23732, upload-time = "2025-06-06T14:41:20.489Z" } wheels = [ @@ -10046,7 +10088,7 @@ name = "winrt-windows-devices-bluetooth-advertisement" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/06/fc/7ffe66ca4109b9e994b27c00f3d2d506e6e549e268791f755287ad9106d8/winrt_windows_devices_bluetooth_advertisement-3.2.1.tar.gz", hash = "sha256:0223852a7b7fa5c8dea3c6a93473bd783df4439b1ed938d9871f947933e574cc", size = 16906, upload-time = "2025-06-06T14:41:21.448Z" } wheels = [ @@ -10066,7 +10108,7 @@ name = "winrt-windows-devices-bluetooth-genericattributeprofile" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/44/21/aeeddc0eccdfbd25e543360b5cc093233e2eab3cdfb53ad3cabae1b5d04d/winrt_windows_devices_bluetooth_genericattributeprofile-3.2.1.tar.gz", hash = "sha256:cdf6ddc375e9150d040aca67f5a17c41ceaf13a63f3668f96608bc1d045dde71", size = 38896, upload-time = "2025-06-06T14:41:22.687Z" } wheels = [ @@ -10086,7 +10128,7 @@ name = "winrt-windows-devices-enumeration" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/dd/75835bfbd063dffa152109727dedbd80f6e92ea284855f7855d48cdf31c9/winrt_windows_devices_enumeration-3.2.1.tar.gz", hash = "sha256:df316899e39bfc0ffc1f3cb0f5ee54d04e1d167fbbcc1484d2d5121449a935cf", size = 23538, upload-time = "2025-06-06T14:41:26.787Z" } wheels = [ @@ -10106,7 +10148,7 @@ name = "winrt-windows-devices-radios" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5e/02/9704ea359ad8b0d6faa1011f98fb477e8fb6eac5201f39d19e73c2407e7b/winrt_windows_devices_radios-3.2.1.tar.gz", hash = "sha256:4dc9b9d1501846049eb79428d64ec698d6476c27a357999b78a8331072e18a0b", size = 5908, upload-time = "2025-06-06T14:41:44.868Z" } wheels = [ @@ -10126,7 +10168,7 @@ name = "winrt-windows-foundation" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0c/55/098ce7ea0679efcc1298b269c48768f010b6c68f90c588f654ec874c8a74/winrt_windows_foundation-3.2.1.tar.gz", hash = "sha256:ad2f1fcaa6c34672df45527d7c533731fdf65b67c4638c2b4aca949f6eec0656", size = 30485, upload-time = "2025-06-06T14:41:53.344Z" } wheels = [ @@ -10146,7 +10188,7 @@ name = "winrt-windows-foundation-collections" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/62/d21e3f1eeb8d47077887bbf0c3882c49277a84d8f98f7c12bda64d498a07/winrt_windows_foundation_collections-3.2.1.tar.gz", hash = "sha256:0eff1ad0d8d763ad17e9e7bbd0c26a62b27215016393c05b09b046d6503ae6d5", size = 16043, upload-time = "2025-06-06T14:41:53.983Z" } wheels = [ @@ -10166,7 +10208,7 @@ name = "winrt-windows-storage-streams" version = "3.2.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "winrt-runtime" }, + { name = "winrt-runtime", marker = "sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/00/50/f4488b07281566e3850fcae1021f0285c9653992f60a915e15567047db63/winrt_windows_storage_streams-3.2.1.tar.gz", hash = "sha256:476f522722751eb0b571bc7802d85a82a3cae8b1cce66061e6e758f525e7b80f", size = 34335, upload-time = "2025-06-06T14:43:23.905Z" } wheels = [ From 9c99f8b1a79d77a9f1e96234b8c9715b114f24e0 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 20 Jul 2026 21:35:10 -0700 Subject: [PATCH 22/51] fix: rebase Piper teleop on main --- dimos/control/components.py | 10 + dimos/control/hardware_interface.py | 33 ++- .../control/tasks/teleop_task/teleop_task.py | 2 + dimos/control/test_control.py | 20 ++ dimos/hardware/manipulators/piper/adapter.py | 254 ++++++++++++++---- .../manipulators/piper/test_adapter.py | 80 ++++++ .../manipulators/test_adapter_lifecycle.py | 186 ++++++++++++- dimos/learning/collection/blueprint.py | 13 +- dimos/robot/all_blueprints.py | 1 - .../manipulators/piper/blueprints/teleop.py | 25 +- dimos/robot/manipulators/piper/config.py | 44 ++- dimos/robot/manipulators/piper/test_config.py | 66 +++++ .../piper/test_teleop_gripper_mapping.py | 151 +++++++++++ dimos/robot/manipulators/test_blueprints.py | 31 ++- .../manipulators/xarm/blueprints/teleop.py | 12 +- dimos/robot/manipulators/xarm/config.py | 34 ++- dimos/robot/test_all_blueprints.py | 1 - .../teleop/keyboard/keyboard_teleop_module.py | 130 +++++++-- .../keyboard/test_keyboard_teleop_module.py | 66 +++++ dimos/teleop/quest/quest_teleop_module.py | 8 +- .../teleop/quest/test_quest_teleop_module.py | 39 +++ dimos/web/robot_web_interface.py | 10 +- docs/capabilities/teleoperation/piper.md | 36 +++ docs/docs.json | 7 +- 24 files changed, 1143 insertions(+), 116 deletions(-) create mode 100644 dimos/hardware/manipulators/piper/test_adapter.py create mode 100644 dimos/robot/manipulators/piper/test_config.py create mode 100644 dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py create mode 100644 dimos/teleop/quest/test_quest_teleop_module.py create mode 100644 docs/capabilities/teleoperation/piper.md diff --git a/dimos/control/components.py b/dimos/control/components.py index 1daab0b039..4565d9b249 100644 --- a/dimos/control/components.py +++ b/dimos/control/components.py @@ -74,6 +74,12 @@ class HardwareComponent: on hardware_type=WHOLE_BODY components. Keeps WB-only knobs off the generic HardwareComponent shared by manipulators, bases, and grippers. + gripper_open_position: Adapter-native open endpoint used when + normalized gripper commands are mapped. These are not universally + meters: Piper uses 0.07, while the existing XArm adapter path + uses its parent-native 0.85 endpoint. + gripper_closed_position: Adapter-native closed endpoint; typically + 0.0 for Piper and XArm. """ hardware_id: HardwareId @@ -86,6 +92,10 @@ class HardwareComponent: domain_id: int = 0 adapter_kwargs: dict[str, Any] = field(default_factory=dict) wb_config: WholeBodyConfig | None = None + # Optional mapping for normalized gripper commands. Endpoints are + # adapter-native command units, not a universal unit such as meters. + gripper_open_position: float | None = None + gripper_closed_position: float | None = None @property def all_joints(self) -> list[JointName]: diff --git a/dimos/control/hardware_interface.py b/dimos/control/hardware_interface.py index 51172ea599..3a7c74f430 100644 --- a/dimos/control/hardware_interface.py +++ b/dimos/control/hardware_interface.py @@ -61,6 +61,10 @@ def __init__( self._arm_joint_names: list[JointName] = list(component.joints) self._gripper_joints: list[JointName] = list(component.gripper_joints) self._joint_names: list[JointName] = component.all_joints + self._gripper_open = component.gripper_open_position + self._gripper_closed = component.gripper_closed_position + if (self._gripper_open is None) != (self._gripper_closed is None): + raise ValueError("gripper open/closed positions must be set together") # Track last commanded values for hold-last behavior self._last_commanded: dict[str, float] = {} @@ -123,7 +127,9 @@ def read_state(self) -> dict[JointName, JointState]: gripper_pos = self._adapter.read_gripper_position() for gj in self._gripper_joints: result[gj] = JointState( - position=gripper_pos if gripper_pos is not None else 0.0, + position=self._physical_to_normalized(gripper_pos) + if gripper_pos is not None + else 0.0, velocity=0.0, effort=0.0, ) @@ -188,7 +194,10 @@ def write_command(self, commands: dict[str, float], mode: ControlMode) -> bool: for gj in self._gripper_joints: if gj in self._last_commanded: gripper_ok = ( - self._adapter.write_gripper_position(self._last_commanded[gj]) and gripper_ok + self._adapter.write_gripper_position( + self._normalized_to_physical(self._last_commanded[gj]) + ) + and gripper_ok ) return arm_ok and gripper_ok @@ -205,7 +214,11 @@ def _initialize_last_commanded(self) -> None: if self._gripper_joints: gripper_pos = self._adapter.read_gripper_position() for gj in self._gripper_joints: - self._last_commanded[gj] = gripper_pos if gripper_pos is not None else 0.0 + self._last_commanded[gj] = ( + self._physical_to_normalized(gripper_pos) + if gripper_pos is not None + else 0.0 + ) self._initialized = True return @@ -216,6 +229,20 @@ def _initialize_last_commanded(self) -> None: f"Hardware {self.hardware_id} failed to read initial positions after retries" ) + def _normalized_to_physical(self, value: float) -> float: + """Map normalized input to adapter-native endpoint units.""" + if self._gripper_open is None or self._gripper_closed is None: + return value + value = max(0.0, min(1.0, value)) + return self._gripper_closed + (self._gripper_open - self._gripper_closed) * value + + def _physical_to_normalized(self, value: float) -> float: + """Map adapter-native endpoint units back to normalized input.""" + if self._gripper_open is None or self._gripper_closed is None: + return value + span = self._gripper_open - self._gripper_closed + return 0.0 if span == 0.0 else max(0.0, min(1.0, (value - self._gripper_closed) / span)) + def _build_ordered_command(self) -> list[float]: """Build ordered command list from last_commanded dict.""" return [self._last_commanded[name] for name in self._joint_names] diff --git a/dimos/control/tasks/teleop_task/teleop_task.py b/dimos/control/tasks/teleop_task/teleop_task.py index 8d5f31bb67..de3057c85d 100644 --- a/dimos/control/tasks/teleop_task/teleop_task.py +++ b/dimos/control/tasks/teleop_task/teleop_task.py @@ -356,6 +356,7 @@ def stop(self) -> None: class TeleopIKTaskParams(BaseConfig): model_path: str | Path ee_joint_id: int = 6 + max_joint_delta_deg: float = 5.0 hand: Literal["left", "right"] | None = None gripper_joint: str | None = None gripper_open_pos: float = 0.0 @@ -370,6 +371,7 @@ def create_task(cfg: Any, hardware: Any) -> TeleopIKTask: joint_names=cfg.joint_names, model_path=params.model_path, ee_joint_id=params.ee_joint_id, + max_joint_delta_deg=params.max_joint_delta_deg, priority=cfg.priority, hand=params.hand, gripper_joint=params.gripper_joint, diff --git a/dimos/control/test_control.py b/dimos/control/test_control.py index f4ac313f4d..79d975d888 100644 --- a/dimos/control/test_control.py +++ b/dimos/control/test_control.py @@ -173,6 +173,26 @@ def test_get_position(self): class TestConnectedHardware: + def test_normalized_gripper_commands_are_mapped_at_hardware_boundary(self, mock_adapter): + mock_adapter.read_gripper_position.return_value = 0.035 + component = HardwareComponent( + hardware_id="arm", + hardware_type=HardwareType.MANIPULATOR, + joints=make_joints("arm", 6), + gripper_joints=["arm/gripper"], + gripper_open_position=0.07, + gripper_closed_position=0.0, + ) + hardware = ConnectedHardware(mock_adapter, component) + + assert hardware.read_state()["arm/gripper"].position == pytest.approx(0.5) + hardware.write_command({"arm/gripper": 0.0}, ControlMode.POSITION) + hardware.write_command({"arm/gripper": 1.0}, ControlMode.POSITION) + assert mock_adapter.write_gripper_position.call_args_list == [ + ((0.0,), {}), + ((0.07,), {}), + ] + def test_joint_names_prefixed(self, connected_hardware): names = connected_hardware.joint_names assert names == [ diff --git a/dimos/hardware/manipulators/piper/adapter.py b/dimos/hardware/manipulators/piper/adapter.py index c9ff9e4c7e..d863a98862 100644 --- a/dimos/hardware/manipulators/piper/adapter.py +++ b/dimos/hardware/manipulators/piper/adapter.py @@ -30,6 +30,7 @@ ManipulatorAdapter, ManipulatorInfo, ) +from dimos.utils.logging_config import setup_logger # Unit conversion constants # Piper uses 0.001 degrees (millidegrees) for angles @@ -39,9 +40,19 @@ # Hardware specs GRIPPER_MAX_OPENING_M = 0.08 # Max gripper opening in meters +GRIPPER_STROKE_UNITS_PER_M = 1_000_000 +SHUTDOWN_POSITION_TOLERANCE = 0.03 +SHUTDOWN_POLL_INTERVAL = 0.05 +SHUTDOWN_SPEED_RATE = 30 +SHUTDOWN_TIMEOUT = 5.0 +STARTUP_RESET_WAIT = 0.5 +STARTUP_ZERO_WAIT = 1.0 # Default configurable parameters DEFAULT_GRIPPER_SPEED = 1000 +GRIPPER_DISABLE_CODE = 0x02 + +logger = setup_logger() class PiperAdapter(ManipulatorAdapter): @@ -70,13 +81,18 @@ def __init__( self._sdk: Any = None self._connected: bool = False self._enabled: bool = False + self._gripper_initialized: bool = False self._control_mode: ControlMode = ControlMode.POSITION def connect(self) -> bool: """Connect to Piper via CAN bus.""" try: from piper_sdk import C_PiperInterface_V2 + except ImportError: + print("ERROR: Piper SDK not installed. Please install piper_sdk") + return False + try: self._sdk = C_PiperInterface_V2( can_name=self._can_port, judge_flag=True, # Enable safety checks @@ -93,33 +109,120 @@ def connect(self) -> bool: # Check connection by trying to get status status = self._sdk.GetArmStatus() if status is not None: + if not self._initialize_startup_state(): + self._close_failed_connection() + return False self._connected = True print(f"Piper connected via CAN port {self._can_port}") return True else: print(f"ERROR: Failed to connect to Piper on {self._can_port} - no status received") return False - - except ImportError: - print("ERROR: Piper SDK not installed. Please install piper_sdk") - return False except Exception as e: print(f"ERROR: Failed to connect to Piper on {self._can_port}: {e}") return False - def disconnect(self) -> None: - """Disconnect from Piper.""" - if self._sdk: + def _initialize_startup_state(self) -> bool: + """Run Piper's fixed reset, zero-pose, and startup settle sequence.""" + sdk = self._sdk + if sdk is None: + return False + + for reset_number in range(2): try: - if self._enabled: - self._sdk.DisablePiper() - self._enabled = False - self._sdk.DisconnectPort() + sdk.MotionCtrl_1(0x02, 0, 0) except Exception: - pass - finally: - self._sdk = None - self._connected = False + logger.exception(f"Piper startup reset {reset_number + 1} failed") + return False + time.sleep(STARTUP_RESET_WAIT) + + try: + sdk.MotionCtrl_2( + ctrl_mode=0x01, + move_mode=0x01, + move_spd_rate_ctrl=SHUTDOWN_SPEED_RATE, + is_mit_mode=0x00, + ) + sdk.JointCtrl(0, 0, 0, 0, 0, 0) + except Exception: + logger.exception("Failed to command Piper startup zero pose") + return False + + if hasattr(sdk, "GripperCtrl"): + try: + sdk.GripperCtrl(0, DEFAULT_GRIPPER_SPEED, 0x01, 0) + self._gripper_initialized = True + except Exception: + logger.warning("Piper gripper startup command failed; continuing arm startup") + + time.sleep(STARTUP_ZERO_WAIT) + return True + + def _enable_piper(self) -> bool: + """Enable Piper with the SDK retry policy, without changing mode.""" + sdk = self._sdk + if sdk is None: + return False + try: + for attempt in range(50): + if sdk.EnablePiper(): + self._enabled = True + return True + if attempt < 49: + time.sleep(0.01) + except Exception: + logger.exception("Piper SDK enable command failed") + return False + + def _close_failed_connection(self) -> None: + """Release a CAN connection after mandatory startup initialization fails.""" + sdk = self._sdk + if sdk is not None: + if self._enabled: + try: + sdk.DisablePiper() + except Exception: + logger.exception("Failed to disable Piper after startup failure") + try: + sdk.DisconnectPort() + except Exception: + logger.exception("Failed to disconnect Piper after startup failure") + self._sdk = None + self._connected = False + self._enabled = False + self._gripper_initialized = False + + def disconnect(self) -> None: + """Disconnect from Piper.""" + sdk = self._sdk + if sdk is None: + self._connected = False + self._enabled = False + self._gripper_initialized = False + return + try: + if not self._move_to_zero_position(): + logger.error("Piper did not reach its zero position before disconnect") + except Exception: + logger.exception("Error homing Piper before disconnect") + try: + if not self._deactivate_gripper(): + logger.error("Failed to deactivate Piper gripper") + except Exception: + logger.exception("Error deactivating Piper gripper") + try: + sdk.DisablePiper() + except Exception: + logger.exception("Error disabling Piper") + try: + sdk.DisconnectPort() + except Exception: + logger.exception("Error disconnecting Piper CAN port") + finally: + self._sdk = None + self._connected = False + self._enabled = False + self._gripper_initialized = False def is_connected(self) -> bool: """Check if connected to Piper.""" @@ -136,9 +239,15 @@ def activate(self) -> bool: return self.write_enable(True) def deactivate(self) -> bool: + """Stop motion without disabling servos. + + Servo power must remain on until ``disconnect`` has completed the + bounded home-to-zero movement. + """ stopped = self.write_stop() - disabled = self.write_enable(False) - return stopped and disabled + if not stopped: + logger.error("Failed to stop Piper motion during deactivation") + return stopped def get_info(self) -> ManipulatorInfo: """Get Piper information.""" @@ -337,19 +446,66 @@ def write_joint_velocities(self, velocities: list[float]) -> bool: return False def write_stop(self) -> bool: - """Emergency stop.""" + """Gracefully stop Piper motion.""" if not self._sdk: return False try: - if hasattr(self._sdk, "EmergencyStop"): - self._sdk.EmergencyStop() - return True + self._sdk.MotionCtrl_1(0x01, 0, 0) + return True except Exception: - pass + return False + + def _move_to_zero_position(self) -> bool: + """Move all arm joints to zero before disabling the servos.""" + if not self._sdk: + return False + + try: + self._sdk.MotionCtrl_2( + ctrl_mode=0x01, + move_mode=0x01, + move_spd_rate_ctrl=SHUTDOWN_SPEED_RATE, + is_mit_mode=0x00, + ) + self._sdk.JointCtrl(0, 0, 0, 0, 0, 0) + except Exception: + return False - # Fallback: disable arm - return self.write_enable(False) + deadline = time.monotonic() + SHUTDOWN_TIMEOUT + while time.monotonic() < deadline: + try: + if ( + max(abs(position) for position in self.read_joint_positions()) + <= SHUTDOWN_POSITION_TOLERANCE + ): + return True + except Exception: + return False + time.sleep(SHUTDOWN_POLL_INTERVAL) + return False + + def _initialize_gripper(self) -> bool: + """Initialize the gripper in its enabled, closed position.""" + if not self._sdk or not hasattr(self._sdk, "GripperCtrl"): + return False + try: + self._sdk.GripperCtrl(0, self._gripper_speed, GRIPPER_DISABLE_CODE, 0) + self._sdk.GripperCtrl(0, self._gripper_speed, 0x01, 0) + self._gripper_initialized = True + return True + except Exception: + return False + + def _deactivate_gripper(self) -> bool: + """Disable gripper control before disconnecting the arm.""" + if not self._sdk or not hasattr(self._sdk, "GripperCtrl"): + return True + try: + self._sdk.GripperCtrl(0, self._gripper_speed, GRIPPER_DISABLE_CODE, 0) + return True + except Exception: + return False def write_enable(self, enable: bool) -> bool: """Enable or disable servos.""" @@ -358,28 +514,17 @@ def write_enable(self, enable: bool) -> bool: try: if enable: - # Enable with retries (500ms max) - attempts = 0 - max_attempts = 50 - success = False - while attempts < max_attempts: - if self._sdk.EnablePiper(): - success = True - break - time.sleep(0.01) - attempts += 1 - - if success: - self._enabled = True - # Set control mode - self._sdk.MotionCtrl_2( - ctrl_mode=0x01, - move_mode=0x01, - move_spd_rate_ctrl=30, - is_mit_mode=0x00, - ) + if self._enabled: return True - return False + if not self._enable_piper(): + return False + self._sdk.MotionCtrl_2( + ctrl_mode=0x01, + move_mode=0x01, + move_spd_rate_ctrl=30, + is_mit_mode=0x00, + ) + return True else: self._sdk.DisablePiper() self._enabled = False @@ -456,25 +601,30 @@ def read_gripper_position(self) -> float | None: if hasattr(self._sdk, "GetArmGripperMsgs"): gripper_msgs = self._sdk.GetArmGripperMsgs() if gripper_msgs and gripper_msgs.gripper_state: - # Piper gripper position is 0-100 percentage + # Piper gripper position is in 0.001 mm units. pos: float = gripper_msgs.gripper_state.grippers_angle - return (pos / 100.0) * GRIPPER_MAX_OPENING_M + return min( + GRIPPER_MAX_OPENING_M, + max(0.0, pos / GRIPPER_STROKE_UNITS_PER_M), + ) except Exception: pass return None def write_gripper_position(self, position: float) -> bool: - """Write gripper position (meters -> percentage).""" + """Write gripper position (meters -> 0.001 mm units).""" if not self._sdk: return False try: if hasattr(self._sdk, "GripperCtrl"): - # Convert meters to percentage (0-100) - percentage = int((position / GRIPPER_MAX_OPENING_M) * 100) - percentage = max(0, min(100, percentage)) - self._sdk.GripperCtrl(percentage, self._gripper_speed, 0x01, 0) + if not self._gripper_initialized and not self._initialize_gripper(): + return False + gripper_position = round( + max(0.0, min(GRIPPER_MAX_OPENING_M, position)) * GRIPPER_STROKE_UNITS_PER_M + ) + self._sdk.GripperCtrl(gripper_position, self._gripper_speed, 0x01, 0) return True except Exception: pass diff --git a/dimos/hardware/manipulators/piper/test_adapter.py b/dimos/hardware/manipulators/piper/test_adapter.py new file mode 100644 index 0000000000..d136045502 --- /dev/null +++ b/dimos/hardware/manipulators/piper/test_adapter.py @@ -0,0 +1,80 @@ +# Copyright 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 + +import builtins +import sys +from types import ModuleType +from typing import Any + +import pytest + +from dimos.hardware.manipulators.piper.adapter import PiperAdapter + + +class _FakePiper: + def __init__(self, **_: object) -> None: + self.gripper_calls: list[tuple[int, int, int, int]] = [] + + def ConnectPort(self, **_: object) -> None: + pass + + def GetArmStatus(self) -> object: + return object() + + def MotionCtrl_1(self, *_: int) -> None: + pass + + def MotionCtrl_2(self, **_: int) -> None: + pass + + def JointCtrl(self, *_: int) -> None: + pass + + def GripperCtrl(self, position: int, speed: int, code: int, param: int) -> None: + self.gripper_calls.append((position, speed, code, param)) + if len(self.gripper_calls) == 1: + raise RuntimeError("gripper unavailable") + + +def test_connect_reports_missing_sdk( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + real_import = builtins.__import__ + + def missing_piper_sdk(name: str, *args: Any, **kwargs: Any) -> ModuleType: + if name == "piper_sdk": + raise ImportError("piper_sdk unavailable") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", missing_piper_sdk) + + assert not PiperAdapter().connect() + assert "Piper SDK not installed" in capsys.readouterr().out + + +def test_connect_continues_when_gripper_startup_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sdk_module = ModuleType("piper_sdk") + sdk_module.C_PiperInterface_V2 = _FakePiper # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "piper_sdk", sdk_module) + monkeypatch.setattr("dimos.hardware.manipulators.piper.adapter.time.sleep", lambda _: None) + + adapter = PiperAdapter() + + assert adapter.connect() + assert adapter.is_connected() + assert not adapter._gripper_initialized diff --git a/dimos/hardware/manipulators/test_adapter_lifecycle.py b/dimos/hardware/manipulators/test_adapter_lifecycle.py index a955261aa9..38cf03ded7 100644 --- a/dimos/hardware/manipulators/test_adapter_lifecycle.py +++ b/dimos/hardware/manipulators/test_adapter_lifecycle.py @@ -14,30 +14,88 @@ from __future__ import annotations +import sys +from types import SimpleNamespace + +import pytest from typing_extensions import override from dimos.hardware.manipulators.a750.adapter import A750Adapter from dimos.hardware.manipulators.openarm.adapter import OpenArmAdapter +from dimos.hardware.manipulators.piper import adapter as piper_adapter from dimos.hardware.manipulators.piper.adapter import PiperAdapter class _PiperSdk: def __init__(self) -> None: self.actions: list[str] = [] + self.gripper_position = 0 def EnablePiper(self) -> bool: self.actions.append("enable") return True + def ConnectPort(self, **_: object) -> None: + self.actions.append("connect") + + def GetArmStatus(self) -> object: + return object() + def MotionCtrl_2(self, **_: object) -> None: self.actions.append("position_mode") - def EmergencyStop(self) -> None: - self.actions.append("stop") + def MotionCtrl_1(self, ctrl_mode: int, move_mode: int, move_spd_rate_ctrl: int) -> None: + self.actions.append(f"motion1:{ctrl_mode},{move_mode},{move_spd_rate_ctrl}") + + def JointCtrl(self, *joints: int) -> None: + self.actions.append(f"joints:{','.join(str(joint) for joint in joints)}") + + def GetArmJointMsgs(self) -> object: + class JointState: + joint_1 = 0 + joint_2 = 0 + joint_3 = 0 + joint_4 = 0 + joint_5 = 0 + joint_6 = 0 + + class JointMessages: + joint_state = JointState() + + return JointMessages() + + def GripperCtrl(self, position: int, speed: int, code: int, set_zero: int) -> None: + self.gripper_position = position + self.actions.append(f"gripper:{position},{speed},{code},{set_zero}") + + def GetArmGripperMsgs(self) -> object: + class GripperState: + grippers_angle = self.gripper_position + + class GripperMessages: + gripper_state = GripperState() + + return GripperMessages() def DisablePiper(self) -> None: self.actions.append("disable") + def DisconnectPort(self) -> None: + self.actions.append("disconnect") + + +class _ResetOnceFailingPiperSdk(_PiperSdk): + def __init__(self) -> None: + super().__init__() + self._reset_attempts = 0 + + def MotionCtrl_1(self, ctrl_mode: int, move_mode: int, move_spd_rate_ctrl: int) -> None: + self._reset_attempts += 1 + if self._reset_attempts == 1: + self.actions.append("reset-failed") + raise RuntimeError("transient reset failure") + super().MotionCtrl_1(ctrl_mode, move_mode, move_spd_rate_ctrl) + class _LifecyclePiperAdapter(PiperAdapter): def use_sdk(self, sdk: _PiperSdk) -> None: @@ -45,14 +103,134 @@ def use_sdk(self, sdk: _PiperSdk) -> None: self._sdk = sdk -def test_piper_lifecycle_enables_then_stops_and_disables() -> None: +def test_piper_lifecycle_enables_then_disables() -> None: sdk = _PiperSdk() adapter = _LifecyclePiperAdapter() adapter.use_sdk(sdk) assert adapter.activate() assert adapter.deactivate() - assert sdk.actions == ["enable", "position_mode", "stop", "disable"] + assert sdk.actions == [ + "enable", + "position_mode", + "motion1:1,0,0", + ] + + +def test_piper_disconnect_gracefully_stops_before_disabling() -> None: + sdk = _PiperSdk() + adapter = _LifecyclePiperAdapter() + adapter.use_sdk(sdk) + + assert adapter.activate() + adapter.disconnect() + + assert sdk.actions == [ + "enable", + "position_mode", + "position_mode", + "joints:0,0,0,0,0,0", + "gripper:0,1000,2,0", + "disable", + "disconnect", + ] + + +def test_piper_explicit_stop_uses_motion_ctrl_1() -> None: + sdk = _PiperSdk() + adapter = _LifecyclePiperAdapter() + adapter.use_sdk(sdk) + + assert adapter.write_stop() + assert sdk.actions == ["motion1:1,0,0"] + + +def test_piper_connect_initializes_recovery_enable_zero_pose_and_gripper( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sdk = _PiperSdk() + sleeps: list[float] = [] + monkeypatch.setitem( + sys.modules, "piper_sdk", SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk) + ) + monkeypatch.setattr(piper_adapter.time, "sleep", sleeps.append) + + adapter = PiperAdapter() + + assert adapter.connect() + assert sdk.actions == [ + "connect", + "motion1:2,0,0", + "motion1:2,0,0", + "position_mode", + "joints:0,0,0,0,0,0", + "gripper:0,1000,1,0", + ] + assert sleeps == [0.025, 0.5, 0.5, 1.0] + + +def test_piper_connect_reset_failure_cleans_up_without_zero( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sdk = _ResetOnceFailingPiperSdk() + monkeypatch.setitem( + sys.modules, "piper_sdk", SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk) + ) + + adapter = PiperAdapter() + + assert not adapter.connect() + assert sdk.actions == ["connect", "reset-failed", "disconnect"] + + +def test_piper_connect_does_not_enable_during_startup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + sdk = _PiperSdk() + monkeypatch.setitem( + sys.modules, "piper_sdk", SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk) + ) + monkeypatch.setattr(piper_adapter.time, "sleep", lambda _: None) + + adapter = PiperAdapter() + + assert adapter.connect() + assert "enable" not in sdk.actions + + +def test_piper_connect_joint_failure_cleans_up_without_gripper( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class FailingJointSdk(_PiperSdk): + def JointCtrl(self, *joints: int) -> None: + raise RuntimeError("joint command failed") + + sdk = FailingJointSdk() + monkeypatch.setitem( + sys.modules, "piper_sdk", SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk) + ) + monkeypatch.setattr(piper_adapter.time, "sleep", lambda _: None) + + adapter = PiperAdapter() + + assert not adapter.connect() + assert "disconnect" in sdk.actions + assert "joints:0,0,0,0,0,0" not in sdk.actions + + +def test_piper_gripper_uses_millimeter_units_and_clamps() -> None: + sdk = _PiperSdk() + adapter = _LifecyclePiperAdapter() + adapter.use_sdk(sdk) + + assert adapter.write_gripper_position(0.1) + assert sdk.gripper_position == 80_000 + assert adapter.read_gripper_position() == 0.08 + assert sdk.actions == [ + "gripper:0,1000,2,0", + "gripper:0,1000,1,0", + "gripper:80000,1000,1,0", + ] class _OpenArmLifecycle: diff --git a/dimos/learning/collection/blueprint.py b/dimos/learning/collection/blueprint.py index f34b286630..c6141b2770 100644 --- a/dimos/learning/collection/blueprint.py +++ b/dimos/learning/collection/blueprint.py @@ -29,10 +29,7 @@ from dimos.hardware.sensors.camera.realsense.camera import RealSenseCamera from dimos.learning.collection.episode_monitor import EpisodeMonitorModule from dimos.learning.collection.recorder import CollectionRecorder -from dimos.teleop.quest.blueprints import ( - teleop_quest_piper, - teleop_quest_xarm7, -) +from dimos.teleop.quest.blueprints import teleop_quest_xarm7 def _session_db(robot: str) -> str: @@ -58,11 +55,3 @@ def _camera_if_real() -> tuple[Blueprint, ...]: EpisodeMonitorModule.blueprint(), # default button_map: toggle=B, discard=Y CollectionRecorder.blueprint(db_path=_session_db("xarm7")), ) - - -learning_collect_quest_piper = autoconnect( - teleop_quest_piper, - *_camera_if_real(), - EpisodeMonitorModule.blueprint(), # default button_map: toggle=B, discard=Y - CollectionRecorder.blueprint(db_path=_session_db("piper")), -) diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index f15665fd96..f5b8541199 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -69,7 +69,6 @@ "keyboard-teleop-piper": "dimos.robot.manipulators.piper.blueprints.teleop:keyboard_teleop_piper", "keyboard-teleop-xarm6": "dimos.robot.manipulators.xarm.blueprints.teleop:keyboard_teleop_xarm6", "keyboard-teleop-xarm7": "dimos.robot.manipulators.xarm.blueprints.teleop:keyboard_teleop_xarm7", - "learning-collect-quest-piper": "dimos.learning.collection.blueprint:learning_collect_quest_piper", "learning-collect-quest-xarm7": "dimos.learning.collection.blueprint:learning_collect_quest_xarm7", "mid360": "dimos.hardware.sensors.lidar.livox.livox_blueprints:mid360", "mid360-fastlio": "dimos.hardware.sensors.lidar.fastlio2.fastlio_blueprints:mid360_fastlio", diff --git a/dimos/robot/manipulators/piper/blueprints/teleop.py b/dimos/robot/manipulators/piper/blueprints/teleop.py index 86135ec4bb..8b3e9fb4ea 100644 --- a/dimos/robot/manipulators/piper/blueprints/teleop.py +++ b/dimos/robot/manipulators/piper/blueprints/teleop.py @@ -17,7 +17,7 @@ from __future__ import annotations from dimos.control.components import make_gripper_joints -from dimos.control.coordinator import ControlCoordinator +from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.core.coordination.blueprints import autoconnect from dimos.core.global_config import global_config from dimos.manipulation.manipulation_module import ManipulationModule @@ -25,6 +25,7 @@ cartesian_ik_task, eef_twist_task, teleop_ik_task, + trajectory_task, ) from dimos.robot.manipulators.common.sim import mujoco_if_sim from dimos.robot.manipulators.piper.config import ( @@ -41,6 +42,8 @@ adapter_type="piper" if global_config.can_port else "mock", address=global_config.can_port or "can0", gripper=True, + gripper_open_position=0.07, + gripper_closed_position=0.0, ) keyboard_teleop_piper = autoconnect( @@ -50,7 +53,16 @@ publish_joint_state=True, joint_state_frame_id="coordinator", hardware=[_piper_keyboard_hw], - tasks=[eef_twist_task(_piper_keyboard_hw, model_path=PIPER_FK_MODEL, ee_joint_id=6)], + tasks=[ + eef_twist_task(_piper_keyboard_hw, model_path=PIPER_FK_MODEL, ee_joint_id=6), + TaskConfig( + name="servo_gripper", + type="servo", + joint_names=["arm/gripper"], + priority=20, + params={"timeout": 0.0, "default_positions": [0.0]}, + ), + ], ), ManipulationModule.blueprint( robots=[make_piper_model_config()], @@ -68,10 +80,11 @@ tasks=[cartesian_ik_task(_piper_mock_cartesian_hw, model_path=PIPER_FK_MODEL, ee_joint_id=6)], ) -_piper_teleop_hw = piper_hardware("arm") +_piper_teleop_hw = piper_hardware("arm", gripper_open_position=0.07, gripper_closed_position=0.0) coordinator_teleop_piper = autoconnect( ControlCoordinator.blueprint( + publish_joint_state=True, hardware=[_piper_teleop_hw], tasks=[ teleop_ik_task( @@ -82,10 +95,12 @@ name="teleop_piper", params={ "gripper_joint": make_gripper_joints("arm")[0], - "gripper_open_pos": 0.0, - "gripper_closed_pos": 0.035, + "gripper_open_pos": 1.0, + "gripper_closed_pos": 0.0, + "max_joint_delta_deg": 50.0, }, ), + trajectory_task(_piper_teleop_hw, name="traj_arm"), ], ), *mujoco_if_sim(PIPER_SIM_PATH, len(_piper_teleop_hw.joints)), diff --git a/dimos/robot/manipulators/piper/config.py b/dimos/robot/manipulators/piper/config.py index f99e9589a5..ada7df3224 100644 --- a/dimos/robot/manipulators/piper/config.py +++ b/dimos/robot/manipulators/piper/config.py @@ -18,6 +18,8 @@ from pathlib import Path +from pydantic import Field + from dimos.control.components import HardwareComponent, HardwareType, make_joints from dimos.core.global_config import global_config from dimos.manipulation.planning.spec.config import RobotModelConfig @@ -42,6 +44,20 @@ } PIPER_FK_MODEL = LfsPath("piper_description/mujoco_model/piper_no_gripper_description.xml") PIPER_SIM_PATH = LfsPath("piper/scene.xml") +PIPER_HOME_JOINTS = [ + 0.793, + 1.568186214614724, + -1.0290351975897356, + 0.0008456548489068756, + 0.9771515619106422, + -0.13286819850920156, +] + + +class PiperRobotModelConfig(RobotModelConfig): + """Piper-specific robot model configuration.""" + + preset_poses: dict[str, list[float]] = Field(default_factory=dict) def _adapter_kwargs(home_joints: list[float] | None = None) -> dict[str, object]: @@ -56,6 +72,8 @@ def make_piper_hardware( adapter_type: str = "mock", address: str | None = None, gripper: bool = True, + gripper_open_position: float | None = None, + gripper_closed_position: float | None = None, auto_enable: bool = True, adapter_kwargs: dict[str, object] | None = None, home_joints: list[float] | None = None, @@ -71,6 +89,8 @@ def make_piper_hardware( address=address, auto_enable=auto_enable, gripper_joints=[f"{hw_id}/gripper"] if gripper else [], + gripper_open_position=gripper_open_position, + gripper_closed_position=gripper_closed_position, adapter_kwargs=kwargs, ) @@ -79,7 +99,9 @@ def piper_hardware( hw_id: str = "arm", *, gripper: bool = True, - mock_without_address: bool = False, + gripper_open_position: float | None = None, + gripper_closed_position: float | None = None, + mock_without_address: bool = True, home_joints: list[float] | None = None, ) -> HardwareComponent: if global_config.simulation: @@ -88,16 +110,26 @@ def piper_hardware( adapter_type="sim_mujoco", address=str(PIPER_SIM_PATH), gripper=gripper, + gripper_open_position=gripper_open_position, + gripper_closed_position=gripper_closed_position, home_joints=home_joints, ) address = global_config.can_port or "can0" if mock_without_address and not global_config.can_port: - return make_piper_hardware(hw_id, gripper=gripper, home_joints=home_joints) + return make_piper_hardware( + hw_id, + gripper=gripper, + gripper_open_position=gripper_open_position, + gripper_closed_position=gripper_closed_position, + home_joints=home_joints, + ) return make_piper_hardware( hw_id, adapter_type="piper", address=address, gripper=gripper, + gripper_open_position=gripper_open_position, + gripper_closed_position=gripper_closed_position, home_joints=home_joints, ) @@ -108,9 +140,10 @@ def make_piper_model_config( joint_prefix: str | None = None, coordinator_task_name: str | None = None, home_joints: list[float] | None = None, -) -> RobotModelConfig: +) -> PiperRobotModelConfig: dof = 6 - return RobotModelConfig( + model_home_joints = list(home_joints) if home_joints is not None else list(PIPER_HOME_JOINTS) + return PiperRobotModelConfig( name=name, model_path=PIPER_MODEL_PATH, base_pose=base_pose(), @@ -127,5 +160,6 @@ def make_piper_model_config( ), coordinator_task_name=coordinator_task_name or f"traj_{name}", gripper_hardware_id=name, - home_joints=home_joints or [0.0] * dof, + home_joints=model_home_joints, + preset_poses={"home": list(model_home_joints)}, ) diff --git a/dimos/robot/manipulators/piper/test_config.py b/dimos/robot/manipulators/piper/test_config.py new file mode 100644 index 0000000000..3429fd6de5 --- /dev/null +++ b/dimos/robot/manipulators/piper/test_config.py @@ -0,0 +1,66 @@ +# Copyright 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. + +from pathlib import Path + +from dimos.core.global_config import global_config +from dimos.robot.manipulators.piper import config as piper_config +from dimos.robot.manipulators.piper.config import ( + PIPER_HOME_JOINTS, + make_piper_model_config, + piper_hardware, +) + + +def test_piper_model_config_exposes_default_home_preset() -> None: + config = make_piper_model_config() + + assert config.preset_poses["home"] == config.home_joints == PIPER_HOME_JOINTS + assert len(config.preset_poses["home"]) == 6 + + +def test_piper_model_config_exposes_supplied_home_preset() -> None: + home_joints = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] + config = make_piper_model_config(home_joints=home_joints) + + assert config.preset_poses["home"] == home_joints + assert config.home_joints == home_joints + assert len(config.preset_poses["home"]) == 6 + assert config.preset_poses["home"] is not home_joints + + +def test_piper_defaults_to_mock_without_can_port(monkeypatch) -> None: + monkeypatch.setattr(global_config, "simulation", "") + + for can_port in (None, ""): + monkeypatch.setattr(global_config, "can_port", can_port) + hardware = piper_hardware() + + assert hardware.adapter_type == "mock" + assert hardware.address is None + + +def test_piper_uses_configured_can_port(monkeypatch) -> None: + can_port = "can7" + monkeypatch.setattr(global_config, "simulation", "") + monkeypatch.setattr(global_config, "can_port", can_port) + + hardware = piper_hardware() + + assert hardware.adapter_type == "piper" + assert hardware.address == can_port + + +def test_piper_simulation_selection_is_unchanged(monkeypatch) -> None: + monkeypatch.setattr(global_config, "simulation", "mujoco") + monkeypatch.setattr(global_config, "can_port", "can7") + # Avoid resolving the LFS-backed scene path just to inspect selection. + simulation_path = Path("piper/scene.xml") + monkeypatch.setattr(piper_config, "PIPER_SIM_PATH", simulation_path) + + hardware = piper_hardware() + + assert hardware.adapter_type == "sim_mujoco" + assert hardware.address == str(simulation_path) diff --git a/dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py b/dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py new file mode 100644 index 0000000000..206920e6dd --- /dev/null +++ b/dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py @@ -0,0 +1,151 @@ +# Copyright 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. + +from typing import Any +from unittest.mock import MagicMock + +from dimos.control.coordinator import ControlCoordinator, TaskConfig +from dimos.control.hardware_interface import ConnectedHardware +from dimos.control.task import ControlMode, CoordinatorState, JointStateSnapshot +from dimos.hardware.manipulators.spec import ManipulatorAdapter +from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.robot.manipulators.piper.blueprints.teleop import ( + coordinator_teleop_piper, + keyboard_teleop_piper, +) +from dimos.robot.manipulators.xarm.config import xarm6_hardware, xarm7_hardware +from dimos.teleop.keyboard.keyboard_teleop_module import KeyboardTeleopModule + + +class _JointCommandStream: + def __init__(self) -> None: + self._callbacks: list[Any] = [] + + def subscribe(self, callback: Any, *_: Any) -> Any: + self._callbacks.append(callback) + return lambda: self._callbacks.remove(callback) + + def publish(self, message: JointState) -> None: + for callback in self._callbacks: + callback(message) + + def stop(self) -> None: + self._callbacks.clear() + + +def _keyboard_tasks() -> list[TaskConfig]: + coordinator_kwargs = next( + atom.kwargs + for atom in keyboard_teleop_piper.blueprints + if atom.module is ControlCoordinator + ) + return coordinator_kwargs["tasks"] + + +def test_piper_keyboard_uses_07m_open_position() -> None: + keyboard_kwargs = next( + atom.kwargs + for atom in keyboard_teleop_piper.blueprints + if atom.module is KeyboardTeleopModule + ) + + assert keyboard_kwargs == {} + hardware = next( + atom.kwargs["hardware"][0] + for atom in keyboard_teleop_piper.blueprints + if atom.module is ControlCoordinator + ) + assert (hardware.gripper_closed_position, hardware.gripper_open_position) == (0.0, 0.07) + + +def test_xarm_mock_factories_keep_normalized_mapping() -> None: + for hardware in ( + xarm6_hardware( + gripper=True, + gripper_open_position=0.85, + gripper_closed_position=0.0, + mock_without_address=True, + ), + xarm7_hardware( + gripper=True, + gripper_open_position=0.85, + gripper_closed_position=0.0, + mock_without_address=True, + ), + ): + assert (hardware.gripper_closed_position, hardware.gripper_open_position) == (0.0, 0.85) + + +def test_piper_quest_gripper_maps_neutral_open_and_full_closed() -> None: + coordinator_kwargs = next( + atom.kwargs + for atom in coordinator_teleop_piper.blueprints + if atom.module is ControlCoordinator + ) + hardware = coordinator_kwargs["hardware"][0] + task = coordinator_kwargs["tasks"][0] + + assert hardware.gripper_open_position == 0.07 + assert hardware.gripper_closed_position == 0.0 + assert task.params["gripper_open_pos"] == 1.0 + assert task.params["gripper_closed_pos"] == 0.0 + + +def test_piper_keyboard_and_quest_normalized_endpoints_reach_adapter() -> None: + component = next( + atom.kwargs["hardware"][0] + for atom in keyboard_teleop_piper.blueprints + if atom.module is ControlCoordinator + ) + adapter = MagicMock(spec=ManipulatorAdapter) + adapter.read_joint_positions.return_value = [0.0] * 6 + adapter.read_gripper_position.return_value = 0.0 + adapter.set_control_mode.return_value = True + adapter.write_joint_positions.return_value = True + adapter.write_gripper_position.return_value = True + hardware = ConnectedHardware(adapter, component) + + hardware.write_command({"arm/gripper": 0.0}, ControlMode.POSITION) + hardware.write_command({"arm/gripper": 1.0}, ControlMode.POSITION) + + assert adapter.write_gripper_position.call_args_list == [ + ((0.0,), {}), + ((0.07,), {}), + ] + + +def test_piper_keyboard_has_high_priority_gripper_servo() -> None: + servo = next(task for task in _keyboard_tasks() if task.name == "servo_gripper") + + assert servo.type == "servo" + assert servo.joint_names == ["arm/gripper"] + assert servo.priority > next( + task.priority for task in _keyboard_tasks() if task.type == "eef_twist" + ) + assert servo.params == {"timeout": 0.0, "default_positions": [0.0]} + + +def test_piper_keyboard_joint_commands_reach_gripper_servo() -> None: + servo_config = next(task for task in _keyboard_tasks() if task.name == "servo_gripper") + coordinator = ControlCoordinator(tasks=[servo_config], publish_joint_state=False) + stream = _JointCommandStream() + coordinator.joint_command.transport = stream # type: ignore[assignment] + try: + coordinator.start() + servo = coordinator.get_task("servo_gripper") + assert servo is not None + + stream.publish(JointState({"name": ["arm/gripper"], "position": [1.0]})) + opened = servo.compute(CoordinatorState(JointStateSnapshot({}), t_now=1.0, dt=0.01)) + assert opened is not None + assert opened.joint_names == ["arm/gripper"] + assert opened.positions == [1.0] + + stream.publish(JointState({"name": ["arm/gripper"], "position": [0.0]})) + closed = servo.compute(CoordinatorState(JointStateSnapshot({}), t_now=2.0, dt=0.01)) + assert closed is not None + assert closed.positions == [0.0] + finally: + coordinator.stop() diff --git a/dimos/robot/manipulators/test_blueprints.py b/dimos/robot/manipulators/test_blueprints.py index 537bcb4ee7..56011c71a5 100644 --- a/dimos/robot/manipulators/test_blueprints.py +++ b/dimos/robot/manipulators/test_blueprints.py @@ -29,7 +29,11 @@ keyboard_teleop_openarm, keyboard_teleop_openarm_mock, ) -from dimos.robot.manipulators.piper.blueprints.teleop import keyboard_teleop_piper +from dimos.robot.manipulators.piper.blueprints.teleop import ( + coordinator_teleop_piper, + keyboard_teleop_piper, +) +from dimos.robot.manipulators.piper.config import make_piper_model_config from dimos.robot.manipulators.xarm.blueprints.basic import ( dual_xarm6_planner, xarm6_planner_only, @@ -47,6 +51,8 @@ ) from dimos.simulation.engines.mujoco_sim_module import MujocoSimModuleConfig from dimos.teleop.keyboard.keyboard_teleop_module import KeyboardTeleopModule +from dimos.teleop.quest.blueprints import teleop_quest_piper +from dimos.teleop.quest.quest_extensions import ArmTeleopModule def _module_kwargs(blueprint: Blueprint, module_type: type) -> dict[str, Any]: @@ -65,6 +71,29 @@ def _coordinator_tasks(blueprint: Blueprint) -> list[TaskConfig]: return _module_kwargs(blueprint, ControlCoordinator)["tasks"] +def test_quest_piper_teleop_routes_to_declarative_teleop_task() -> None: + arm_kwargs = _module_kwargs(teleop_quest_piper, ArmTeleopModule) + assert arm_kwargs["task_names"] == {"left": "teleop_piper"} + assert "coordinator_cartesian_command" in teleop_quest_piper.remapping_map.values() + + +def test_piper_teleop_declares_teleop_and_trajectory_tasks() -> None: + tasks = _coordinator_tasks(coordinator_teleop_piper) + assert [(task.name, task.type) for task in tasks] == [ + ("teleop_piper", "teleop_ik"), + ("traj_arm", "trajectory"), + ] + assert make_piper_model_config().coordinator_task_name == "traj_arm" + + +def test_piper_keyboard_declares_high_priority_gripper_servo() -> None: + tasks = _coordinator_tasks(keyboard_teleop_piper) + servo = next(task for task in tasks if task.name == "servo_gripper") + assert servo.type == "servo" + assert servo.joint_names == ["arm/gripper"] + assert servo.priority > next(task.priority for task in tasks if task.type == "eef_twist") + + def test_planner_helper_defaults_to_no_visualization() -> None: blueprint = planner(robots=[make_xarm7_model_config(name="arm", add_gripper=True)]) diff --git a/dimos/robot/manipulators/xarm/blueprints/teleop.py b/dimos/robot/manipulators/xarm/blueprints/teleop.py index cc9e7c1880..34a6858d80 100644 --- a/dimos/robot/manipulators/xarm/blueprints/teleop.py +++ b/dimos/robot/manipulators/xarm/blueprints/teleop.py @@ -132,8 +132,12 @@ ], ) -_xarm7_teleop_hw = xarm7_hardware("arm", gripper=True) -_xarm6_teleop_hw = xarm6_hardware("arm", gripper=True) +_xarm7_teleop_hw = xarm7_hardware( + "arm", gripper=True, gripper_open_position=0.85, gripper_closed_position=0.0 +) +_xarm6_teleop_hw = xarm6_hardware( + "arm", gripper=True, gripper_open_position=0.85, gripper_closed_position=0.0 +) coordinator_teleop_xarm7 = autoconnect( ControlCoordinator.blueprint( @@ -147,7 +151,7 @@ name="teleop_xarm", params={ "gripper_joint": make_gripper_joints("arm")[0], - "gripper_open_pos": 0.85, + "gripper_open_pos": 1.0, "gripper_closed_pos": 0.0, }, ), @@ -168,7 +172,7 @@ name="teleop_xarm", params={ "gripper_joint": make_gripper_joints("arm")[0], - "gripper_open_pos": 0.85, + "gripper_open_pos": 1.0, "gripper_closed_pos": 0.0, }, ), diff --git a/dimos/robot/manipulators/xarm/config.py b/dimos/robot/manipulators/xarm/config.py index e4fe7bf972..75c0d7de91 100644 --- a/dimos/robot/manipulators/xarm/config.py +++ b/dimos/robot/manipulators/xarm/config.py @@ -102,6 +102,8 @@ def make_xarm_hardware( adapter_type: str = "mock", address: str | Path | None = None, gripper: bool = False, + gripper_open_position: float | None = None, + gripper_closed_position: float | None = None, auto_enable: bool = True, adapter_kwargs: dict[str, object] | None = None, home_joints: list[float] | None = None, @@ -117,6 +119,8 @@ def make_xarm_hardware( address=address, auto_enable=auto_enable, gripper_joints=[f"{hw_id}/gripper"] if gripper else [], + gripper_open_position=gripper_open_position, + gripper_closed_position=gripper_closed_position, adapter_kwargs=kwargs, ) @@ -125,6 +129,8 @@ def xarm7_hardware( hw_id: str = "arm", *, gripper: bool = False, + gripper_open_position: float | None = None, + gripper_closed_position: float | None = None, mock_without_address: bool = False, home_joints: list[float] | None = None, ) -> HardwareComponent: @@ -135,17 +141,28 @@ def xarm7_hardware( adapter_type="sim_mujoco", address=str(XARM7_SIM_PATH), gripper=gripper, + gripper_open_position=gripper_open_position, + gripper_closed_position=gripper_closed_position, home_joints=home_joints, ) address = global_config.xarm7_ip if mock_without_address and not address: - return make_xarm_hardware(hw_id, 7, gripper=gripper, home_joints=home_joints) + return make_xarm_hardware( + hw_id, + 7, + gripper=gripper, + gripper_open_position=gripper_open_position, + gripper_closed_position=gripper_closed_position, + home_joints=home_joints, + ) return make_xarm_hardware( hw_id, 7, adapter_type="xarm", address=address, gripper=gripper, + gripper_open_position=gripper_open_position, + gripper_closed_position=gripper_closed_position, home_joints=home_joints, ) @@ -154,6 +171,8 @@ def xarm6_hardware( hw_id: str = "arm", *, gripper: bool = False, + gripper_open_position: float | None = None, + gripper_closed_position: float | None = None, mock_without_address: bool = False, home_joints: list[float] | None = None, ) -> HardwareComponent: @@ -164,17 +183,28 @@ def xarm6_hardware( adapter_type="sim_mujoco", address=str(XARM6_SIM_PATH), gripper=gripper, + gripper_open_position=gripper_open_position, + gripper_closed_position=gripper_closed_position, home_joints=home_joints, ) address = global_config.xarm6_ip if mock_without_address and not address: - return make_xarm_hardware(hw_id, 6, gripper=gripper, home_joints=home_joints) + return make_xarm_hardware( + hw_id, + 6, + gripper=gripper, + gripper_open_position=gripper_open_position, + gripper_closed_position=gripper_closed_position, + home_joints=home_joints, + ) return make_xarm_hardware( hw_id, 6, adapter_type="xarm", address=address, gripper=gripper, + gripper_open_position=gripper_open_position, + gripper_closed_position=gripper_closed_position, home_joints=home_joints, ) diff --git a/dimos/robot/test_all_blueprints.py b/dimos/robot/test_all_blueprints.py index adbc60ef00..56d95102ae 100644 --- a/dimos/robot/test_all_blueprints.py +++ b/dimos/robot/test_all_blueprints.py @@ -50,7 +50,6 @@ "coordinator-xarm6", "coordinator-xarm7", "dual-xarm6-planner", - "learning-collect-quest-piper", "learning-collect-quest-xarm7", "teleop-hosted-go2", "teleop-hosted-go2-multicam", diff --git a/dimos/teleop/keyboard/keyboard_teleop_module.py b/dimos/teleop/keyboard/keyboard_teleop_module.py index 916dd958bc..1d86faca15 100644 --- a/dimos/teleop/keyboard/keyboard_teleop_module.py +++ b/dimos/teleop/keyboard/keyboard_teleop_module.py @@ -24,6 +24,8 @@ R/F: +Roll/-Roll T/G: +Pitch/-Pitch Y/H: +Yaw/-Yaw + [: Open gripper + ]: Close gripper ESC: Quit """ @@ -31,7 +33,7 @@ import os import threading -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast try: import pygame @@ -46,6 +48,7 @@ from dimos.core.module import Module, ModuleConfig from dimos.core.stream import Out from dimos.msgs.geometry_msgs.TwistStamped import TwistStamped +from dimos.msgs.sensor_msgs.JointState import JointState from dimos.robot.manipulators.common.topics import EEF_TWIST_TASK_NAME from dimos.utils.logging_config import setup_logger @@ -57,12 +60,50 @@ # Jog speeds LINEAR_SPEED = 0.05 # m/s ANGULAR_SPEED = 0.5 # rad/s +# Normalized gripper command values. +GRIPPER_OPEN_POSITION = 1.0 +GRIPPER_CLOSED_POSITION = 0.0 +GRIPPER_JOINT_NAME = "arm/gripper" TwistVector = tuple[float, float, float] class KeyboardTeleopConfig(ModuleConfig): task_name: str = EEF_TWIST_TASK_NAME + linear_speed: float = LINEAR_SPEED + angular_speed: float = ANGULAR_SPEED + gripper_open_position: float = GRIPPER_OPEN_POSITION + + +def _motion_key_codes() -> frozenset[int]: + if pygame is None: + return frozenset() + return frozenset( + ( + pygame.K_w, + pygame.K_s, + pygame.K_a, + pygame.K_d, + pygame.K_q, + pygame.K_e, + pygame.K_r, + pygame.K_f, + pygame.K_t, + pygame.K_g, + pygame.K_y, + pygame.K_h, + ) + ) + + +def _gripper_key_codes() -> tuple[int, int]: + """Return pygame's bracket key codes without relying on stub attributes.""" + if pygame is None: + return (-1, -1) + return ( + cast("int", getattr(pygame, "K_LEFTBRACKET", ord("["))), + cast("int", getattr(pygame, "K_RIGHTBRACKET", ord("]"))), + ) class KeyboardTeleopModule(Module): @@ -74,13 +115,16 @@ class KeyboardTeleopModule(Module): config: KeyboardTeleopConfig coordinator_ee_twist_command: Out[TwistStamped] + joint_command: Out[JointState] _stop_event: threading.Event _thread: threading.Thread | None = None + _gripper_position: float | None = None def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) self._stop_event = threading.Event() + self._gripper_position = None @rpc def start(self) -> None: @@ -108,17 +152,19 @@ def _pygame_loop(self) -> None: pygame.display.set_caption(f"Keyboard Teleop — {task_name}") font = pygame.font.Font(None, 28) clock = pygame.time.Clock() + held_motion_keys: set[int] = set() was_moving = False while not self._stop_event.is_set(): for event in pygame.event.get(): - if event.type == pygame.QUIT: + if self._handle_pygame_event(event, held_motion_keys, task_name): self._stop_event.set() - elif event.type == pygame.KEYDOWN: - if event.key == pygame.K_ESCAPE: - self._stop_event.set() - linear, angular = _twist_from_keys(pygame.key.get_pressed()) + linear, angular = _twist_from_keys( + held_motion_keys, + linear_speed=self.config.linear_speed, + angular_speed=self.config.angular_speed, + ) linear_x, linear_y, linear_z = linear angular_x, angular_y, angular_z = angular @@ -156,6 +202,7 @@ def _pygame_loop(self) -> None: ("R/F", "+Roll/-Roll"), ("T/G", "+Pitch/-Pitch"), ("Y/H", "+Yaw/-Yaw"), + ("[/]", "Open/close gripper"), ("ESC", "Quit"), ] for key, desc in controls: @@ -168,6 +215,37 @@ def _pygame_loop(self) -> None: self._publish_twist(task_name) pygame.quit() + def _handle_pygame_event( + self, + event: Any, + held_motion_keys: set[int], + task_name: str, + ) -> bool: + """Apply one pygame event and synchronously stop motion on KEYUP.""" + if pygame is None: + return False + if event.type == pygame.QUIT: + return True + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_ESCAPE: + return True + left_bracket, right_bracket = _gripper_key_codes() + if event.key in _motion_key_codes(): + held_motion_keys.add(event.key) + elif event.key == left_bracket: + self._set_gripper_position(self.config.gripper_open_position) + elif event.key == right_bracket: + self._set_gripper_position(GRIPPER_CLOSED_POSITION) + elif event.type == pygame.KEYUP and event.key in _motion_key_codes(): + held_motion_keys.discard(event.key) + linear, angular = _twist_from_keys( + held_motion_keys, + linear_speed=self.config.linear_speed, + angular_speed=self.config.angular_speed, + ) + self._publish_twist(task_name, linear=linear, angular=angular) + return False + def _publish_twist( self, task_name: str, @@ -179,26 +257,38 @@ def _publish_twist( TwistStamped(frame_id=task_name, linear=list(linear), angular=list(angular)) ) + def _set_gripper_position(self, position: float) -> None: + """Latch and publish a changed gripper endpoint command.""" + if self._gripper_position == position: + return + self._gripper_position = position + self.joint_command.publish(JointState(name=[GRIPPER_JOINT_NAME], position=[position])) + -def _twist_from_keys(keys: ScancodeWrapper) -> tuple[TwistVector, TwistVector]: +def _twist_from_keys( + keys: ScancodeWrapper | set[int], + *, + linear_speed: float = LINEAR_SPEED, + angular_speed: float = ANGULAR_SPEED, +) -> tuple[TwistVector, TwistVector]: linear = [0.0, 0.0, 0.0] angular = [0.0, 0.0, 0.0] bindings = { - pygame.K_w: (linear, 0, LINEAR_SPEED), - pygame.K_s: (linear, 0, -LINEAR_SPEED), - pygame.K_a: (linear, 1, LINEAR_SPEED), - pygame.K_d: (linear, 1, -LINEAR_SPEED), - pygame.K_q: (linear, 2, LINEAR_SPEED), - pygame.K_e: (linear, 2, -LINEAR_SPEED), - pygame.K_r: (angular, 0, ANGULAR_SPEED), - pygame.K_f: (angular, 0, -ANGULAR_SPEED), - pygame.K_t: (angular, 1, ANGULAR_SPEED), - pygame.K_g: (angular, 1, -ANGULAR_SPEED), - pygame.K_y: (angular, 2, ANGULAR_SPEED), - pygame.K_h: (angular, 2, -ANGULAR_SPEED), + pygame.K_w: (linear, 0, linear_speed), + pygame.K_s: (linear, 0, -linear_speed), + pygame.K_a: (linear, 1, linear_speed), + pygame.K_d: (linear, 1, -linear_speed), + pygame.K_q: (linear, 2, linear_speed), + pygame.K_e: (linear, 2, -linear_speed), + pygame.K_r: (angular, 0, angular_speed), + pygame.K_f: (angular, 0, -angular_speed), + pygame.K_t: (angular, 1, angular_speed), + pygame.K_g: (angular, 1, -angular_speed), + pygame.K_y: (angular, 2, angular_speed), + pygame.K_h: (angular, 2, -angular_speed), } for key, (vector, axis, delta) in bindings.items(): - if keys[key]: + if key in keys if isinstance(keys, set) else keys[key]: vector[axis] += delta return (linear[0], linear[1], linear[2]), (angular[0], angular[1], angular[2]) diff --git a/dimos/teleop/keyboard/test_keyboard_teleop_module.py b/dimos/teleop/keyboard/test_keyboard_teleop_module.py index 35fbb3aa03..09fb018f89 100644 --- a/dimos/teleop/keyboard/test_keyboard_teleop_module.py +++ b/dimos/teleop/keyboard/test_keyboard_teleop_module.py @@ -19,10 +19,14 @@ import pytest from dimos.msgs.geometry_msgs.TwistStamped import TwistStamped +from dimos.msgs.sensor_msgs.JointState import JointState from dimos.robot.manipulators.common.topics import EEF_TWIST_TASK_NAME import dimos.teleop.keyboard.keyboard_teleop_module as keyboard_mod from dimos.teleop.keyboard.keyboard_teleop_module import ( ANGULAR_SPEED, + GRIPPER_CLOSED_POSITION, + GRIPPER_JOINT_NAME, + GRIPPER_OPEN_POSITION, LINEAR_SPEED, KeyboardTeleopModule, _twist_from_keys, @@ -85,3 +89,65 @@ def test_twist_from_keys_maps_rotation_keys_to_eef_angular_twist() -> None: assert linear == (0.0, 0.0, 0.0) assert angular == (ANGULAR_SPEED, -ANGULAR_SPEED, ANGULAR_SPEED) + + +def test_keyup_of_last_motion_key_publishes_zero_immediately( + module: KeyboardTeleopModule, mocker +) -> None: + publish = mocker.patch.object(module.coordinator_ee_twist_command, "publish") + held = {keyboard_mod.pygame.K_w} + event = keyboard_mod.pygame.event.Event(keyboard_mod.pygame.KEYUP, key=keyboard_mod.pygame.K_w) + + assert not module._handle_pygame_event(event, held, EEF_TWIST_TASK_NAME) + + assert held == set() + assert publish.call_count == 1 + msg = publish.call_args.args[0] + assert [msg.linear.x, msg.linear.y, msg.linear.z] == [0.0, 0.0, 0.0] + assert [msg.angular.x, msg.angular.y, msg.angular.z] == [0.0, 0.0, 0.0] + + +def test_keyup_preserves_remaining_motion_key(module: KeyboardTeleopModule, mocker) -> None: + publish = mocker.patch.object(module.coordinator_ee_twist_command, "publish") + held = {keyboard_mod.pygame.K_w, keyboard_mod.pygame.K_a} + event = keyboard_mod.pygame.event.Event(keyboard_mod.pygame.KEYUP, key=keyboard_mod.pygame.K_w) + + module._handle_pygame_event(event, held, EEF_TWIST_TASK_NAME) + + assert held == {keyboard_mod.pygame.K_a} + assert publish.call_count == 1 + msg = publish.call_args.args[0] + assert [msg.linear.x, msg.linear.y, msg.linear.z] == [0.0, LINEAR_SPEED, 0.0] + + +def test_keyup_publishes_directly_without_timeout_wait( + module: KeyboardTeleopModule, mocker +) -> None: + publish = mocker.patch.object(module.coordinator_ee_twist_command, "publish") + held = {keyboard_mod.pygame.K_w} + event = keyboard_mod.pygame.event.Event(keyboard_mod.pygame.KEYUP, key=keyboard_mod.pygame.K_w) + + module._handle_pygame_event(event, held, EEF_TWIST_TASK_NAME) + + publish.assert_called_once() + + +def test_set_gripper_position_publishes_partial_joint_state_only_on_change( + module: KeyboardTeleopModule, mocker +) -> None: + assert module.config.gripper_open_position == GRIPPER_OPEN_POSITION + + publish = mocker.patch.object(module.joint_command, "publish") + + module._set_gripper_position(GRIPPER_OPEN_POSITION) + module._set_gripper_position(GRIPPER_OPEN_POSITION) + module._set_gripper_position(GRIPPER_CLOSED_POSITION) + + assert publish.call_count == 2 + msg = publish.call_args_list[0].args[0] + assert isinstance(msg, JointState) + assert msg.name == [GRIPPER_JOINT_NAME] + assert msg.position == [GRIPPER_OPEN_POSITION] + assert msg.velocity == [] + assert msg.effort == [] + assert publish.call_args_list[1].args[0].position == [GRIPPER_CLOSED_POSITION] diff --git a/dimos/teleop/quest/quest_teleop_module.py b/dimos/teleop/quest/quest_teleop_module.py index 24e69f032f..b0888d7efa 100644 --- a/dimos/teleop/quest/quest_teleop_module.py +++ b/dimos/teleop/quest/quest_teleop_module.py @@ -110,8 +110,7 @@ def __init__(self, **kwargs: Any) -> None: self._control_loop_thread: threading.Thread | None = None self._stop_event = threading.Event() - # Embedded web server — created lazily in _start_server() so subclasses - # with a different transport (e.g. hosted/broker) never build it. + # Embedded web server, initialized during the module start lifecycle. self._web_server: RobotWebInterface | None = None self._web_server_thread: threading.Thread | None = None @@ -169,6 +168,8 @@ async def websocket_endpoint(ws: WebSocket) -> None: @rpc def start(self) -> None: super().start() + self._web_server = RobotWebInterface(host="0.0.0.0", port=self.config.server_port) + self._setup_routes() self._start_server() self._start_control_loop() logger.info("Quest Teleoperation Module started") @@ -248,8 +249,7 @@ def _start_server(self) -> None: return if self._web_server is None: - self._web_server = RobotWebInterface(port=self.config.server_port) - self._setup_routes() + return self._web_server_thread = threading.Thread( target=self._web_server.run, diff --git a/dimos/teleop/quest/test_quest_teleop_module.py b/dimos/teleop/quest/test_quest_teleop_module.py new file mode 100644 index 0000000000..1f5fb8b2c3 --- /dev/null +++ b/dimos/teleop/quest/test_quest_teleop_module.py @@ -0,0 +1,39 @@ +# Copyright 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 unittest.mock import patch + +from dimos.teleop.quest.quest_teleop_module import QuestTeleopModule + + +def test_quest_web_server_is_initialized_during_start() -> None: + with ( + patch("dimos.teleop.quest.quest_teleop_module.RobotWebInterface") as web_interface, + patch.object(QuestTeleopModule, "_setup_routes") as setup_routes, + patch.object(QuestTeleopModule, "_start_server") as start_server, + patch.object(QuestTeleopModule, "_start_control_loop"), + ): + module = QuestTeleopModule(server_port=9443) + try: + web_interface.assert_not_called() + assert module._web_server is None + + module.start() + + web_interface.assert_called_once_with(host="0.0.0.0", port=9443) + assert module._web_server is web_interface.return_value + setup_routes.assert_called_once_with() + start_server.assert_called_once_with() + finally: + module.stop() diff --git a/dimos/web/robot_web_interface.py b/dimos/web/robot_web_interface.py index 043aea71ec..bd3fa7c3b4 100644 --- a/dimos/web/robot_web_interface.py +++ b/dimos/web/robot_web_interface.py @@ -23,10 +23,18 @@ class RobotWebInterface(FastAPIServer): """Wrapper class for the dimos-interface FastAPI server.""" - def __init__(self, port: int = 5555, text_streams=None, audio_subject=None, **streams) -> None: # type: ignore[no-untyped-def] + def __init__( + self, + port: int = 5555, + host: str | None = None, + text_streams=None, + audio_subject=None, + **streams, + ) -> None: # type: ignore[no-untyped-def] super().__init__( dev_name="Robot Web Interface", edge_type="Bidirectional", + host=host, port=port, text_streams=text_streams, audio_subject=audio_subject, diff --git a/docs/capabilities/teleoperation/piper.md b/docs/capabilities/teleoperation/piper.md new file mode 100644 index 0000000000..cf2fa2b609 --- /dev/null +++ b/docs/capabilities/teleoperation/piper.md @@ -0,0 +1,36 @@ +--- +title: "Piper Teleoperation" +description: "Operate a Piper arm with Quest VR or a keyboard." +--- + +DimOS supports direct Piper teleoperation through Quest VR and keyboard input. +These paths operate the arm only; they do not record episodes or create +datasets. + +## Quest + +Start the Quest blueprint on the robot: + +```bash +dimos run teleop-quest-piper +``` + +Open the robot's LAN address from the Quest browser. The Quest web endpoint +binds to all LAN interfaces (HTTPS, port `8443` by default), so the headset +and robot must be on the same network. The left controller drives the Piper +arm; the blueprint routes its command stream to the Piper coordinator. + +Use `--simulation` to run the same composition against the supported simulator. + +## Keyboard + +Start keyboard teleoperation from the robot's terminal: + +```bash +dimos run keyboard-teleop-piper +``` + +Use the keyboard controls shown by the teleop module for Cartesian arm motion. +The configured gripper keys open and close the Piper gripper while leaving arm +motion controls unchanged. Both teleop paths apply the Piper motion safety +limits before commands reach the coordinator. diff --git a/docs/docs.json b/docs/docs.json index 7074b3e52a..12d1e15adf 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -129,7 +129,8 @@ { "group": "Teleoperation", "pages": [ - "capabilities/teleoperation/hosted" + "capabilities/teleoperation/hosted", + "capabilities/teleoperation/piper" ] } ] @@ -468,6 +469,10 @@ "source": "/docs/capabilities/teleoperation/hosted.md", "destination": "/capabilities/teleoperation/hosted" }, + { + "source": "/docs/capabilities/teleoperation/piper.md", + "destination": "/capabilities/teleoperation/piper" + }, { "source": "/docs/platforms/quadruped/go2/index.md", "destination": "/platforms/quadruped/go2/index" From c7c765f4ae96be096fff5c111c63867b1a66317a Mon Sep 17 00:00:00 2001 From: Nabla7 Date: Tue, 21 Jul 2026 00:57:40 -0400 Subject: [PATCH 23/51] feat: allow undoing the last saved episode --- dimos/learning/collection/episode_monitor.py | 15 +++++++--- .../collection/test_episode_monitor.py | 29 +++++++++++++++++++ dimos/learning/dataprep/core.py | 9 ++++++ dimos/learning/dataprep/test_core.py | 20 +++++++++++++ .../robot/manipulators/galaxea_a1z/README.md | 4 +++ .../galaxea_a1z/teach_replay_cli.py | 9 ++++-- 6 files changed, 79 insertions(+), 7 deletions(-) diff --git a/dimos/learning/collection/episode_monitor.py b/dimos/learning/collection/episode_monitor.py index debbc9f178..1058fdedf0 100644 --- a/dimos/learning/collection/episode_monitor.py +++ b/dimos/learning/collection/episode_monitor.py @@ -42,7 +42,7 @@ # `start`/`save` based on the current state, so it never reaches the output. EpisodeCommand: TypeAlias = Literal["start", "save", "discard", "toggle"] # What gets published as `EpisodeStatus.last_event` (`init` on boot). -EpisodeEvent: TypeAlias = Literal["start", "save", "discard", "init"] +EpisodeEvent: TypeAlias = Literal["start", "save", "discard", "undo", "init"] RecordingState: TypeAlias = Literal["idle", "recording"] @@ -122,7 +122,7 @@ def save_episode(self) -> EpisodeStatus: @rpc def discard_episode(self) -> EpisodeStatus: - """Discard the active episode and return the published status.""" + """Discard the active episode, or undo the latest save when idle.""" return self._transition("discard", time.time()) # ── port handlers ──────────────────────────────────────────────────────── @@ -159,11 +159,13 @@ def _transition(self, event: EpisodeCommand, ts: float) -> EpisodeStatus: ``toggle`` resolves to ``start`` when idle and ``save`` when recording, so one button can begin and end a take. The resolved event is what gets - published (DataPrep only ever sees start/save/discard). + published. An idle discard with a prior save resolves to ``undo`` so + DataPrep can retract that episode without changing older recordings. """ with self._lock: if event == "toggle": event = "save" if self._state == "recording" else "start" + resolved_event: EpisodeEvent = event if event == "start": # Auto-commit any in-progress episode (matches DataPrep extractor). if self._state == "recording": @@ -176,9 +178,13 @@ def _transition(self, event: EpisodeCommand, ts: float) -> EpisodeStatus: elif event == "discard": if self._state == "recording": self._discarded += 1 + elif self._saved > 0: + self._saved -= 1 + self._discarded += 1 + resolved_event = "undo" self._state = "idle" # Snapshot under the mutation's lock so the event matches the state. - status = self._snapshot(event, ts) + status = self._snapshot(resolved_event, ts) return self._emit(status) def _snapshot(self, last_event: EpisodeEvent, ts: float) -> EpisodeStatus: @@ -204,6 +210,7 @@ def _log_status(self, status: EpisodeStatus) -> None: "start": "▶ RECORDING episode", "save": "✓ SAVED episode", "discard": "✗ DISCARDED episode", + "undo": "↶ DISCARDED previous saved episode", "init": "· ready", }.get(status.last_event, status.last_event) label = f" [{status.task_label}]" if status.task_label else "" diff --git a/dimos/learning/collection/test_episode_monitor.py b/dimos/learning/collection/test_episode_monitor.py index f65b4060b2..9d3c87e4bd 100644 --- a/dimos/learning/collection/test_episode_monitor.py +++ b/dimos/learning/collection/test_episode_monitor.py @@ -107,6 +107,35 @@ def test_discard_does_not_count_as_saved( assert last.episodes_discarded == 1 +def test_discard_while_idle_undoes_latest_save( + make_monitor: Callable[..., EpisodeMonitorModule], +) -> None: + m = make_monitor() + _press(m, "B") + _press(m, "B") + + _press(m, "Y") + + last = _events(m)[-1] + assert last.last_event == "undo" + assert last.state == "idle" + assert last.episodes_saved == 0 + assert last.episodes_discarded == 1 + + +def test_discard_while_idle_without_a_save_is_a_noop( + make_monitor: Callable[..., EpisodeMonitorModule], +) -> None: + m = make_monitor() + + _press(m, "Y") + + last = _events(m)[-1] + assert last.last_event == "discard" + assert last.episodes_saved == 0 + assert last.episodes_discarded == 0 + + def test_start_while_recording_autocommits_previous( make_monitor: Callable[..., EpisodeMonitorModule], ) -> None: diff --git a/dimos/learning/dataprep/core.py b/dimos/learning/dataprep/core.py index c12ea397ff..8ebc1687fb 100644 --- a/dimos/learning/dataprep/core.py +++ b/dimos/learning/dataprep/core.py @@ -175,6 +175,7 @@ def extract_episodes(store: SqliteStore, cfg: EpisodeExtractor) -> list[Episode] ev.last_event == "start": begin (auto-commit any prior pending) ev.last_event == "save": commit (success=True) ev.last_event == "discard": drop (success=False) + ev.last_event == "undo": mark the latest successful episode failed end of stream with pending: dropped (matches live spec) RANGES: emit one Episode per (start, end) tuple in `cfg.ranges`. @@ -230,6 +231,14 @@ def _commit(end_ts: float, success: bool, label: str | None) -> None: _commit(ts, success=True, label=pending_label or label) elif last_event == "discard": _commit(ts, success=False, label=pending_label or label) + elif last_event == "undo": + # Undo is emitted only while idle, so no episode is pending. Keep + # the raw interval for inspection but exclude the latest save from + # replay and dataset builds by marking it unsuccessful. + for index in range(len(episodes) - 1, -1, -1): + if episodes[index].success: + episodes[index] = episodes[index].model_copy(update={"success": False}) + break # "init" and unknown events are no-ops. # Anything still pending at end-of-stream is dropped (state-machine spec). diff --git a/dimos/learning/dataprep/test_core.py b/dimos/learning/dataprep/test_core.py index 6b2899e77b..536ea096c1 100644 --- a/dimos/learning/dataprep/test_core.py +++ b/dimos/learning/dataprep/test_core.py @@ -159,6 +159,26 @@ def test_extract_discard_marks_failure() -> None: assert eps[0].success is False +def test_extract_undo_marks_latest_saved_episode_failed() -> None: + store = _FakeStore( + { + "status": _status( + [ + (1.0, "start", "first"), + (2.0, "save", None), + (3.0, "start", "second"), + (4.0, "save", None), + (5.0, "undo", None), + ] + ) + } + ) + + eps = extract_episodes(store, EpisodeExtractor(status_stream="status")) + + assert [episode.success for episode in eps] == [True, False] + + def test_extract_auto_commit_on_restart() -> None: # start, then another start without save → first auto-commits (success=True) store = _FakeStore( diff --git a/dimos/robot/manipulators/galaxea_a1z/README.md b/dimos/robot/manipulators/galaxea_a1z/README.md index c3e1503b49..7f4dbb8bd2 100644 --- a/dimos/robot/manipulators/galaxea_a1z/README.md +++ b/dimos/robot/manipulators/galaxea_a1z/README.md @@ -83,6 +83,10 @@ On the hackathon Mac, OpenCV enumerates the external KS2A418 camera as index 0: uv run --no-sync dimos a1z teach --camera-index 0 --task "pick up the object" ``` +While recording, press `SPACE` to save the current episode or `d` to discard +it. While idle, press `d` to discard the most recently saved episode; replay +and dataset export will exclude it. + The command prints the Memory2 `.db` path. Replay a saved episode by passing that path (the latest saved episode is selected by default): diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py index 6b1a35711c..add4cd7216 100644 --- a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py @@ -136,7 +136,7 @@ def _status() -> str: keys = "SPACE save · g gripper · d discard · q quit" else: state = "IDLE" - keys = "SPACE record · g gripper · q quit" + keys = "SPACE record · d undo last · g gripper · q quit" return f"[{state} | saved: {saved_count} | gripper: {gripper}] {keys}" try: @@ -192,12 +192,15 @@ def _toggle_gripper() -> None: continue if command == "d": + status = monitor.discard_episode() if recording: - monitor.discard_episode() recording = False typer.echo(">> episode discarded") + elif status.last_event == "undo": + saved_count = status.episodes_saved + typer.echo(f">> previous saved episode discarded ({saved_count} remain)") else: - typer.echo(">> nothing to discard (not recording)") + typer.echo(">> nothing saved to discard") continue if command == "q": From 75ab36ff28d7a13c861319b0b1dee44d7de47c62 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 20 Jul 2026 22:08:57 -0700 Subject: [PATCH 24/51] refactor: simplify Piper gripper routing --- dimos/control/tasks/teleop_task/teleop_task.py | 2 -- dimos/robot/manipulators/piper/blueprints/teleop.py | 4 ---- dimos/robot/manipulators/test_blueprints.py | 5 +---- 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/dimos/control/tasks/teleop_task/teleop_task.py b/dimos/control/tasks/teleop_task/teleop_task.py index de3057c85d..8d5f31bb67 100644 --- a/dimos/control/tasks/teleop_task/teleop_task.py +++ b/dimos/control/tasks/teleop_task/teleop_task.py @@ -356,7 +356,6 @@ def stop(self) -> None: class TeleopIKTaskParams(BaseConfig): model_path: str | Path ee_joint_id: int = 6 - max_joint_delta_deg: float = 5.0 hand: Literal["left", "right"] | None = None gripper_joint: str | None = None gripper_open_pos: float = 0.0 @@ -371,7 +370,6 @@ def create_task(cfg: Any, hardware: Any) -> TeleopIKTask: joint_names=cfg.joint_names, model_path=params.model_path, ee_joint_id=params.ee_joint_id, - max_joint_delta_deg=params.max_joint_delta_deg, priority=cfg.priority, hand=params.hand, gripper_joint=params.gripper_joint, diff --git a/dimos/robot/manipulators/piper/blueprints/teleop.py b/dimos/robot/manipulators/piper/blueprints/teleop.py index 8b3e9fb4ea..9c68730fb9 100644 --- a/dimos/robot/manipulators/piper/blueprints/teleop.py +++ b/dimos/robot/manipulators/piper/blueprints/teleop.py @@ -25,7 +25,6 @@ cartesian_ik_task, eef_twist_task, teleop_ik_task, - trajectory_task, ) from dimos.robot.manipulators.common.sim import mujoco_if_sim from dimos.robot.manipulators.piper.config import ( @@ -84,7 +83,6 @@ coordinator_teleop_piper = autoconnect( ControlCoordinator.blueprint( - publish_joint_state=True, hardware=[_piper_teleop_hw], tasks=[ teleop_ik_task( @@ -97,10 +95,8 @@ "gripper_joint": make_gripper_joints("arm")[0], "gripper_open_pos": 1.0, "gripper_closed_pos": 0.0, - "max_joint_delta_deg": 50.0, }, ), - trajectory_task(_piper_teleop_hw, name="traj_arm"), ], ), *mujoco_if_sim(PIPER_SIM_PATH, len(_piper_teleop_hw.joints)), diff --git a/dimos/robot/manipulators/test_blueprints.py b/dimos/robot/manipulators/test_blueprints.py index 56011c71a5..424ede6489 100644 --- a/dimos/robot/manipulators/test_blueprints.py +++ b/dimos/robot/manipulators/test_blueprints.py @@ -33,7 +33,6 @@ coordinator_teleop_piper, keyboard_teleop_piper, ) -from dimos.robot.manipulators.piper.config import make_piper_model_config from dimos.robot.manipulators.xarm.blueprints.basic import ( dual_xarm6_planner, xarm6_planner_only, @@ -77,13 +76,11 @@ def test_quest_piper_teleop_routes_to_declarative_teleop_task() -> None: assert "coordinator_cartesian_command" in teleop_quest_piper.remapping_map.values() -def test_piper_teleop_declares_teleop_and_trajectory_tasks() -> None: +def test_piper_teleop_declares_teleop_task() -> None: tasks = _coordinator_tasks(coordinator_teleop_piper) assert [(task.name, task.type) for task in tasks] == [ ("teleop_piper", "teleop_ik"), - ("traj_arm", "trajectory"), ] - assert make_piper_model_config().coordinator_task_name == "traj_arm" def test_piper_keyboard_declares_high_priority_gripper_servo() -> None: From 63ed5e35581a165fca3a7f8d22444a09ebeab26a Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 20 Jul 2026 22:31:26 -0700 Subject: [PATCH 25/51] refactor: address Piper teleop review --- dimos/hardware/manipulators/piper/adapter.py | 82 +++++++++++-------- dimos/learning/collection/blueprint.py | 13 ++- dimos/robot/manipulators/piper/config.py | 13 +-- dimos/robot/manipulators/piper/test_config.py | 12 ++- .../teleop/keyboard/keyboard_teleop_module.py | 14 ++-- .../keyboard/test_keyboard_teleop_module.py | 16 ++-- 6 files changed, 83 insertions(+), 67 deletions(-) diff --git a/dimos/hardware/manipulators/piper/adapter.py b/dimos/hardware/manipulators/piper/adapter.py index d863a98862..e3bbd9ac1d 100644 --- a/dimos/hardware/manipulators/piper/adapter.py +++ b/dimos/hardware/manipulators/piper/adapter.py @@ -20,6 +20,7 @@ from __future__ import annotations +from collections.abc import Callable import math import time from typing import Any @@ -179,14 +180,32 @@ def _close_failed_connection(self) -> None: sdk = self._sdk if sdk is not None: if self._enabled: - try: - sdk.DisablePiper() - except Exception: - logger.exception("Failed to disable Piper after startup failure") - try: - sdk.DisconnectPort() - except Exception: - logger.exception("Failed to disconnect Piper after startup failure") + self._attempt_cleanup_step( + sdk.DisablePiper, + exception_message="Failed to disable Piper after startup failure", + ) + self._attempt_cleanup_step( + sdk.DisconnectPort, + exception_message="Failed to disconnect Piper after startup failure", + ) + self._clear_connection_state() + + def _attempt_cleanup_step( + self, + action: Callable[[], Any], + *, + false_message: str | None = None, + exception_message: str, + ) -> None: + """Run one best-effort shutdown action and retain its failure logging.""" + try: + if action() is False and false_message is not None: + logger.error(false_message) + except Exception: + logger.exception(exception_message) + + def _clear_connection_state(self) -> None: + """Clear all local state after a connection attempt ends.""" self._sdk = None self._connected = False self._enabled = False @@ -196,33 +215,28 @@ def disconnect(self) -> None: """Disconnect from Piper.""" sdk = self._sdk if sdk is None: - self._connected = False - self._enabled = False - self._gripper_initialized = False + self._clear_connection_state() return - try: - if not self._move_to_zero_position(): - logger.error("Piper did not reach its zero position before disconnect") - except Exception: - logger.exception("Error homing Piper before disconnect") - try: - if not self._deactivate_gripper(): - logger.error("Failed to deactivate Piper gripper") - except Exception: - logger.exception("Error deactivating Piper gripper") - try: - sdk.DisablePiper() - except Exception: - logger.exception("Error disabling Piper") - try: - sdk.DisconnectPort() - except Exception: - logger.exception("Error disconnecting Piper CAN port") - finally: - self._sdk = None - self._connected = False - self._enabled = False - self._gripper_initialized = False + + self._attempt_cleanup_step( + self._move_to_zero_position, + false_message="Piper did not reach its zero position before disconnect", + exception_message="Error homing Piper before disconnect", + ) + self._attempt_cleanup_step( + self._deactivate_gripper, + false_message="Failed to deactivate Piper gripper", + exception_message="Error deactivating Piper gripper", + ) + self._attempt_cleanup_step( + sdk.DisablePiper, + exception_message="Error disabling Piper", + ) + self._attempt_cleanup_step( + sdk.DisconnectPort, + exception_message="Error disconnecting Piper CAN port", + ) + self._clear_connection_state() def is_connected(self) -> bool: """Check if connected to Piper.""" diff --git a/dimos/learning/collection/blueprint.py b/dimos/learning/collection/blueprint.py index c6141b2770..f34b286630 100644 --- a/dimos/learning/collection/blueprint.py +++ b/dimos/learning/collection/blueprint.py @@ -29,7 +29,10 @@ from dimos.hardware.sensors.camera.realsense.camera import RealSenseCamera from dimos.learning.collection.episode_monitor import EpisodeMonitorModule from dimos.learning.collection.recorder import CollectionRecorder -from dimos.teleop.quest.blueprints import teleop_quest_xarm7 +from dimos.teleop.quest.blueprints import ( + teleop_quest_piper, + teleop_quest_xarm7, +) def _session_db(robot: str) -> str: @@ -55,3 +58,11 @@ def _camera_if_real() -> tuple[Blueprint, ...]: EpisodeMonitorModule.blueprint(), # default button_map: toggle=B, discard=Y CollectionRecorder.blueprint(db_path=_session_db("xarm7")), ) + + +learning_collect_quest_piper = autoconnect( + teleop_quest_piper, + *_camera_if_real(), + EpisodeMonitorModule.blueprint(), # default button_map: toggle=B, discard=Y + CollectionRecorder.blueprint(db_path=_session_db("piper")), +) diff --git a/dimos/robot/manipulators/piper/config.py b/dimos/robot/manipulators/piper/config.py index ada7df3224..dbe4a0af0c 100644 --- a/dimos/robot/manipulators/piper/config.py +++ b/dimos/robot/manipulators/piper/config.py @@ -18,8 +18,6 @@ from pathlib import Path -from pydantic import Field - from dimos.control.components import HardwareComponent, HardwareType, make_joints from dimos.core.global_config import global_config from dimos.manipulation.planning.spec.config import RobotModelConfig @@ -54,12 +52,6 @@ ] -class PiperRobotModelConfig(RobotModelConfig): - """Piper-specific robot model configuration.""" - - preset_poses: dict[str, list[float]] = Field(default_factory=dict) - - def _adapter_kwargs(home_joints: list[float] | None = None) -> dict[str, object]: if home_joints is None: return {} @@ -140,10 +132,10 @@ def make_piper_model_config( joint_prefix: str | None = None, coordinator_task_name: str | None = None, home_joints: list[float] | None = None, -) -> PiperRobotModelConfig: +) -> RobotModelConfig: dof = 6 model_home_joints = list(home_joints) if home_joints is not None else list(PIPER_HOME_JOINTS) - return PiperRobotModelConfig( + return RobotModelConfig( name=name, model_path=PIPER_MODEL_PATH, base_pose=base_pose(), @@ -161,5 +153,4 @@ def make_piper_model_config( coordinator_task_name=coordinator_task_name or f"traj_{name}", gripper_hardware_id=name, home_joints=model_home_joints, - preset_poses={"home": list(model_home_joints)}, ) diff --git a/dimos/robot/manipulators/piper/test_config.py b/dimos/robot/manipulators/piper/test_config.py index 3429fd6de5..4c4c16d525 100644 --- a/dimos/robot/manipulators/piper/test_config.py +++ b/dimos/robot/manipulators/piper/test_config.py @@ -14,21 +14,19 @@ ) -def test_piper_model_config_exposes_default_home_preset() -> None: +def test_piper_model_config_uses_default_home_joints() -> None: config = make_piper_model_config() - assert config.preset_poses["home"] == config.home_joints == PIPER_HOME_JOINTS - assert len(config.preset_poses["home"]) == 6 + assert config.home_joints == PIPER_HOME_JOINTS + assert config.home_joints is not PIPER_HOME_JOINTS -def test_piper_model_config_exposes_supplied_home_preset() -> None: +def test_piper_model_config_uses_supplied_home_joints() -> None: home_joints = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] config = make_piper_model_config(home_joints=home_joints) - assert config.preset_poses["home"] == home_joints assert config.home_joints == home_joints - assert len(config.preset_poses["home"]) == 6 - assert config.preset_poses["home"] is not home_joints + assert config.home_joints is not home_joints def test_piper_defaults_to_mock_without_can_port(monkeypatch) -> None: diff --git a/dimos/teleop/keyboard/keyboard_teleop_module.py b/dimos/teleop/keyboard/keyboard_teleop_module.py index 1d86faca15..75ba61f8ad 100644 --- a/dimos/teleop/keyboard/keyboard_teleop_module.py +++ b/dimos/teleop/keyboard/keyboard_teleop_module.py @@ -57,9 +57,9 @@ # Force X11 driver to avoid OpenGL threading issues os.environ["SDL_VIDEODRIVER"] = "x11" -# Jog speeds -LINEAR_SPEED = 0.05 # m/s -ANGULAR_SPEED = 0.5 # rad/s +# Default jog speeds +DEFAULT_LINEAR_SPEED = 0.05 # m/s +DEFAULT_ANGULAR_SPEED = 0.5 # rad/s # Normalized gripper command values. GRIPPER_OPEN_POSITION = 1.0 GRIPPER_CLOSED_POSITION = 0.0 @@ -70,8 +70,8 @@ class KeyboardTeleopConfig(ModuleConfig): task_name: str = EEF_TWIST_TASK_NAME - linear_speed: float = LINEAR_SPEED - angular_speed: float = ANGULAR_SPEED + linear_speed: float = DEFAULT_LINEAR_SPEED + angular_speed: float = DEFAULT_ANGULAR_SPEED gripper_open_position: float = GRIPPER_OPEN_POSITION @@ -268,8 +268,8 @@ def _set_gripper_position(self, position: float) -> None: def _twist_from_keys( keys: ScancodeWrapper | set[int], *, - linear_speed: float = LINEAR_SPEED, - angular_speed: float = ANGULAR_SPEED, + linear_speed: float, + angular_speed: float, ) -> tuple[TwistVector, TwistVector]: linear = [0.0, 0.0, 0.0] angular = [0.0, 0.0, 0.0] diff --git a/dimos/teleop/keyboard/test_keyboard_teleop_module.py b/dimos/teleop/keyboard/test_keyboard_teleop_module.py index 09fb018f89..253fd4a864 100644 --- a/dimos/teleop/keyboard/test_keyboard_teleop_module.py +++ b/dimos/teleop/keyboard/test_keyboard_teleop_module.py @@ -23,11 +23,9 @@ from dimos.robot.manipulators.common.topics import EEF_TWIST_TASK_NAME import dimos.teleop.keyboard.keyboard_teleop_module as keyboard_mod from dimos.teleop.keyboard.keyboard_teleop_module import ( - ANGULAR_SPEED, GRIPPER_CLOSED_POSITION, GRIPPER_JOINT_NAME, GRIPPER_OPEN_POSITION, - LINEAR_SPEED, KeyboardTeleopModule, _twist_from_keys, ) @@ -75,20 +73,24 @@ def test_publish_twist_defaults_to_zero_twist(module: KeyboardTeleopModule, mock def test_twist_from_keys_maps_translation_keys_to_eef_linear_twist() -> None: linear, angular = _twist_from_keys( - PressedKeys(keyboard_mod.pygame.K_w, keyboard_mod.pygame.K_d, keyboard_mod.pygame.K_q) + PressedKeys(keyboard_mod.pygame.K_w, keyboard_mod.pygame.K_d, keyboard_mod.pygame.K_q), + linear_speed=0.05, + angular_speed=0.5, ) - assert linear == (LINEAR_SPEED, -LINEAR_SPEED, LINEAR_SPEED) + assert linear == (0.05, -0.05, 0.05) assert angular == (0.0, 0.0, 0.0) def test_twist_from_keys_maps_rotation_keys_to_eef_angular_twist() -> None: linear, angular = _twist_from_keys( - PressedKeys(keyboard_mod.pygame.K_r, keyboard_mod.pygame.K_g, keyboard_mod.pygame.K_y) + PressedKeys(keyboard_mod.pygame.K_r, keyboard_mod.pygame.K_g, keyboard_mod.pygame.K_y), + linear_speed=0.05, + angular_speed=0.5, ) assert linear == (0.0, 0.0, 0.0) - assert angular == (ANGULAR_SPEED, -ANGULAR_SPEED, ANGULAR_SPEED) + assert angular == (0.5, -0.5, 0.5) def test_keyup_of_last_motion_key_publishes_zero_immediately( @@ -117,7 +119,7 @@ def test_keyup_preserves_remaining_motion_key(module: KeyboardTeleopModule, mock assert held == {keyboard_mod.pygame.K_a} assert publish.call_count == 1 msg = publish.call_args.args[0] - assert [msg.linear.x, msg.linear.y, msg.linear.z] == [0.0, LINEAR_SPEED, 0.0] + assert [msg.linear.x, msg.linear.y, msg.linear.z] == [0.0, 0.05, 0.0] def test_keyup_publishes_directly_without_timeout_wait( From e41baf2884e5792feab967ce10e19e59e7be9025 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 20 Jul 2026 22:44:50 -0700 Subject: [PATCH 26/51] test: refine Piper teleop coverage --- .../manipulators/piper/test_adapter.py | 55 ++--- .../manipulators/test_adapter_lifecycle.py | 217 ++++++------------ dimos/robot/manipulators/piper/test_config.py | 2 - .../piper/test_teleop_gripper_mapping.py | 134 +---------- dimos/robot/manipulators/test_blueprints.py | 17 ++ dimos/robot/manipulators/xarm/test_config.py | 29 +++ .../keyboard/test_keyboard_teleop_module.py | 25 +- .../teleop/quest/test_quest_teleop_module.py | 45 ++-- 8 files changed, 171 insertions(+), 353 deletions(-) create mode 100644 dimos/robot/manipulators/xarm/test_config.py diff --git a/dimos/hardware/manipulators/piper/test_adapter.py b/dimos/hardware/manipulators/piper/test_adapter.py index d136045502..6f22101b89 100644 --- a/dimos/hardware/manipulators/piper/test_adapter.py +++ b/dimos/hardware/manipulators/piper/test_adapter.py @@ -14,9 +14,8 @@ from __future__ import annotations -import builtins import sys -from types import ModuleType +from types import SimpleNamespace from typing import Any import pytest @@ -24,57 +23,29 @@ from dimos.hardware.manipulators.piper.adapter import PiperAdapter -class _FakePiper: - def __init__(self, **_: object) -> None: - self.gripper_calls: list[tuple[int, int, int, int]] = [] - - def ConnectPort(self, **_: object) -> None: - pass - - def GetArmStatus(self) -> object: - return object() - - def MotionCtrl_1(self, *_: int) -> None: - pass - - def MotionCtrl_2(self, **_: int) -> None: - pass - - def JointCtrl(self, *_: int) -> None: - pass - - def GripperCtrl(self, position: int, speed: int, code: int, param: int) -> None: - self.gripper_calls.append((position, speed, code, param)) - if len(self.gripper_calls) == 1: - raise RuntimeError("gripper unavailable") - - def test_connect_reports_missing_sdk( - monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] + mocker: Any, capsys: pytest.CaptureFixture[str] ) -> None: - real_import = builtins.__import__ - - def missing_piper_sdk(name: str, *args: Any, **kwargs: Any) -> ModuleType: - if name == "piper_sdk": - raise ImportError("piper_sdk unavailable") - return real_import(name, *args, **kwargs) - - monkeypatch.setattr(builtins, "__import__", missing_piper_sdk) + mocker.patch.dict(sys.modules, {"piper_sdk": None}) assert not PiperAdapter().connect() assert "Piper SDK not installed" in capsys.readouterr().out def test_connect_continues_when_gripper_startup_fails( - monkeypatch: pytest.MonkeyPatch, + mocker: Any, ) -> None: - sdk_module = ModuleType("piper_sdk") - sdk_module.C_PiperInterface_V2 = _FakePiper # type: ignore[attr-defined] - monkeypatch.setitem(sys.modules, "piper_sdk", sdk_module) - monkeypatch.setattr("dimos.hardware.manipulators.piper.adapter.time.sleep", lambda _: None) + sdk = mocker.Mock() + sdk.GetArmStatus.return_value = object() + sdk.GripperCtrl.side_effect = RuntimeError("gripper unavailable") + mocker.patch.dict( + sys.modules, + {"piper_sdk": SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk)}, + ) + mocker.patch("dimos.hardware.manipulators.piper.adapter.time.sleep") adapter = PiperAdapter() assert adapter.connect() assert adapter.is_connected() - assert not adapter._gripper_initialized + assert sdk.GripperCtrl.called diff --git a/dimos/hardware/manipulators/test_adapter_lifecycle.py b/dimos/hardware/manipulators/test_adapter_lifecycle.py index 38cf03ded7..194d853fad 100644 --- a/dimos/hardware/manipulators/test_adapter_lifecycle.py +++ b/dimos/hardware/manipulators/test_adapter_lifecycle.py @@ -16,6 +16,7 @@ import sys from types import SimpleNamespace +from typing import Any import pytest from typing_extensions import override @@ -26,211 +27,133 @@ from dimos.hardware.manipulators.piper.adapter import PiperAdapter -class _PiperSdk: - def __init__(self) -> None: - self.actions: list[str] = [] - self.gripper_position = 0 - - def EnablePiper(self) -> bool: - self.actions.append("enable") - return True - - def ConnectPort(self, **_: object) -> None: - self.actions.append("connect") - - def GetArmStatus(self) -> object: - return object() - - def MotionCtrl_2(self, **_: object) -> None: - self.actions.append("position_mode") - - def MotionCtrl_1(self, ctrl_mode: int, move_mode: int, move_spd_rate_ctrl: int) -> None: - self.actions.append(f"motion1:{ctrl_mode},{move_mode},{move_spd_rate_ctrl}") - - def JointCtrl(self, *joints: int) -> None: - self.actions.append(f"joints:{','.join(str(joint) for joint in joints)}") - - def GetArmJointMsgs(self) -> object: - class JointState: - joint_1 = 0 - joint_2 = 0 - joint_3 = 0 - joint_4 = 0 - joint_5 = 0 - joint_6 = 0 - - class JointMessages: - joint_state = JointState() - - return JointMessages() - - def GripperCtrl(self, position: int, speed: int, code: int, set_zero: int) -> None: - self.gripper_position = position - self.actions.append(f"gripper:{position},{speed},{code},{set_zero}") - - def GetArmGripperMsgs(self) -> object: - class GripperState: - grippers_angle = self.gripper_position - - class GripperMessages: - gripper_state = GripperState() - - return GripperMessages() - - def DisablePiper(self) -> None: - self.actions.append("disable") - - def DisconnectPort(self) -> None: - self.actions.append("disconnect") - - -class _ResetOnceFailingPiperSdk(_PiperSdk): - def __init__(self) -> None: - super().__init__() - self._reset_attempts = 0 - - def MotionCtrl_1(self, ctrl_mode: int, move_mode: int, move_spd_rate_ctrl: int) -> None: - self._reset_attempts += 1 - if self._reset_attempts == 1: - self.actions.append("reset-failed") - raise RuntimeError("transient reset failure") - super().MotionCtrl_1(ctrl_mode, move_mode, move_spd_rate_ctrl) - - class _LifecyclePiperAdapter(PiperAdapter): - def use_sdk(self, sdk: _PiperSdk) -> None: - self._sdk: _PiperSdk | None + def use_sdk(self, sdk: Any) -> None: + self._sdk: Any self._sdk = sdk -def test_piper_lifecycle_enables_then_disables() -> None: - sdk = _PiperSdk() +@pytest.fixture +def piper_sdk(mocker: Any) -> Any: + sdk = mocker.Mock() + sdk.EnablePiper.return_value = True + sdk.GetArmStatus.return_value = object() + sdk.GetArmJointMsgs.return_value = SimpleNamespace( + joint_state=SimpleNamespace( + joint_1=0, joint_2=0, joint_3=0, joint_4=0, joint_5=0, joint_6=0 + ) + ) + sdk.gripper_position = 0 + sdk.GripperCtrl.side_effect = lambda position, *_: setattr( + sdk, "gripper_position", position + ) + sdk.GetArmGripperMsgs.side_effect = lambda: SimpleNamespace( + gripper_state=SimpleNamespace(grippers_angle=sdk.gripper_position) + ) + mocker.patch.dict( + sys.modules, + {"piper_sdk": SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk)}, + ) + mocker.patch.object(piper_adapter.time, "sleep") + return sdk + + +def test_piper_lifecycle_enables_then_disables(piper_sdk: Any) -> None: adapter = _LifecyclePiperAdapter() - adapter.use_sdk(sdk) + adapter.use_sdk(piper_sdk) assert adapter.activate() assert adapter.deactivate() - assert sdk.actions == [ - "enable", - "position_mode", - "motion1:1,0,0", - ] + piper_sdk.EnablePiper.assert_called_once_with() -def test_piper_disconnect_gracefully_stops_before_disabling() -> None: - sdk = _PiperSdk() +def test_piper_disconnect_gracefully_stops_before_disabling(piper_sdk: Any) -> None: adapter = _LifecyclePiperAdapter() - adapter.use_sdk(sdk) + adapter.use_sdk(piper_sdk) assert adapter.activate() adapter.disconnect() - assert sdk.actions == [ - "enable", - "position_mode", - "position_mode", - "joints:0,0,0,0,0,0", - "gripper:0,1000,2,0", - "disable", - "disconnect", - ] + assert not adapter.is_connected() + assert not adapter.read_enabled() + piper_sdk.DisablePiper.assert_called_once_with() + piper_sdk.DisconnectPort.assert_called_once_with() -def test_piper_explicit_stop_uses_motion_ctrl_1() -> None: - sdk = _PiperSdk() +def test_piper_explicit_stop_uses_motion_ctrl_1(piper_sdk: Any) -> None: adapter = _LifecyclePiperAdapter() - adapter.use_sdk(sdk) + adapter.use_sdk(piper_sdk) assert adapter.write_stop() - assert sdk.actions == ["motion1:1,0,0"] + piper_sdk.MotionCtrl_1.assert_called_once_with(1, 0, 0) def test_piper_connect_initializes_recovery_enable_zero_pose_and_gripper( - monkeypatch: pytest.MonkeyPatch, + piper_sdk: Any, ) -> None: - sdk = _PiperSdk() - sleeps: list[float] = [] - monkeypatch.setitem( - sys.modules, "piper_sdk", SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk) - ) - monkeypatch.setattr(piper_adapter.time, "sleep", sleeps.append) - adapter = PiperAdapter() assert adapter.connect() - assert sdk.actions == [ - "connect", - "motion1:2,0,0", - "motion1:2,0,0", - "position_mode", - "joints:0,0,0,0,0,0", - "gripper:0,1000,1,0", - ] - assert sleeps == [0.025, 0.5, 0.5, 1.0] + assert piper_sdk.ConnectPort.called + assert piper_sdk.MotionCtrl_1.call_count == 2 + piper_sdk.JointCtrl.assert_called_once_with(0, 0, 0, 0, 0, 0) + assert piper_sdk.GripperCtrl.called def test_piper_connect_reset_failure_cleans_up_without_zero( - monkeypatch: pytest.MonkeyPatch, + mocker: Any, ) -> None: - sdk = _ResetOnceFailingPiperSdk() - monkeypatch.setitem( - sys.modules, "piper_sdk", SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk) + sdk = mocker.Mock() + sdk.GetArmStatus.return_value = object() + sdk.MotionCtrl_1.side_effect = RuntimeError("reset failed") + mocker.patch.dict( + sys.modules, + {"piper_sdk": SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk)}, ) adapter = PiperAdapter() assert not adapter.connect() - assert sdk.actions == ["connect", "reset-failed", "disconnect"] + sdk.DisconnectPort.assert_called_once_with() + sdk.JointCtrl.assert_not_called() def test_piper_connect_does_not_enable_during_startup( - monkeypatch: pytest.MonkeyPatch, + piper_sdk: Any, ) -> None: - sdk = _PiperSdk() - monkeypatch.setitem( - sys.modules, "piper_sdk", SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk) - ) - monkeypatch.setattr(piper_adapter.time, "sleep", lambda _: None) - adapter = PiperAdapter() assert adapter.connect() - assert "enable" not in sdk.actions + piper_sdk.EnablePiper.assert_not_called() def test_piper_connect_joint_failure_cleans_up_without_gripper( - monkeypatch: pytest.MonkeyPatch, + mocker: Any, ) -> None: - class FailingJointSdk(_PiperSdk): - def JointCtrl(self, *joints: int) -> None: - raise RuntimeError("joint command failed") - - sdk = FailingJointSdk() - monkeypatch.setitem( - sys.modules, "piper_sdk", SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk) + sdk = mocker.Mock() + sdk.GetArmStatus.return_value = object() + sdk.JointCtrl.side_effect = RuntimeError("joint command failed") + mocker.patch.dict( + sys.modules, + {"piper_sdk": SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk)}, ) - monkeypatch.setattr(piper_adapter.time, "sleep", lambda _: None) + mocker.patch.object(piper_adapter.time, "sleep") adapter = PiperAdapter() assert not adapter.connect() - assert "disconnect" in sdk.actions - assert "joints:0,0,0,0,0,0" not in sdk.actions + sdk.DisconnectPort.assert_called_once_with() + sdk.GripperCtrl.assert_not_called() -def test_piper_gripper_uses_millimeter_units_and_clamps() -> None: - sdk = _PiperSdk() +def test_piper_gripper_uses_sdk_units_and_clamps(piper_sdk: Any) -> None: adapter = _LifecyclePiperAdapter() - adapter.use_sdk(sdk) + adapter.use_sdk(piper_sdk) assert adapter.write_gripper_position(0.1) - assert sdk.gripper_position == 80_000 + assert piper_sdk.gripper_position == 80_000 assert adapter.read_gripper_position() == 0.08 - assert sdk.actions == [ - "gripper:0,1000,2,0", - "gripper:0,1000,1,0", - "gripper:80000,1000,1,0", - ] + assert piper_sdk.GripperCtrl.call_args.args[0] == 80_000 class _OpenArmLifecycle: diff --git a/dimos/robot/manipulators/piper/test_config.py b/dimos/robot/manipulators/piper/test_config.py index 4c4c16d525..5c32d432dc 100644 --- a/dimos/robot/manipulators/piper/test_config.py +++ b/dimos/robot/manipulators/piper/test_config.py @@ -18,7 +18,6 @@ def test_piper_model_config_uses_default_home_joints() -> None: config = make_piper_model_config() assert config.home_joints == PIPER_HOME_JOINTS - assert config.home_joints is not PIPER_HOME_JOINTS def test_piper_model_config_uses_supplied_home_joints() -> None: @@ -26,7 +25,6 @@ def test_piper_model_config_uses_supplied_home_joints() -> None: config = make_piper_model_config(home_joints=home_joints) assert config.home_joints == home_joints - assert config.home_joints is not home_joints def test_piper_defaults_to_mock_without_can_port(monkeypatch) -> None: diff --git a/dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py b/dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py index 206920e6dd..b61c5ce851 100644 --- a/dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py +++ b/dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py @@ -3,101 +3,18 @@ # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. -from typing import Any from unittest.mock import MagicMock -from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.control.hardware_interface import ConnectedHardware -from dimos.control.task import ControlMode, CoordinatorState, JointStateSnapshot +from dimos.control.task import ControlMode from dimos.hardware.manipulators.spec import ManipulatorAdapter -from dimos.msgs.sensor_msgs.JointState import JointState -from dimos.robot.manipulators.piper.blueprints.teleop import ( - coordinator_teleop_piper, - keyboard_teleop_piper, -) -from dimos.robot.manipulators.xarm.config import xarm6_hardware, xarm7_hardware -from dimos.teleop.keyboard.keyboard_teleop_module import KeyboardTeleopModule +from dimos.robot.manipulators.piper.config import make_piper_hardware -class _JointCommandStream: - def __init__(self) -> None: - self._callbacks: list[Any] = [] - - def subscribe(self, callback: Any, *_: Any) -> Any: - self._callbacks.append(callback) - return lambda: self._callbacks.remove(callback) - - def publish(self, message: JointState) -> None: - for callback in self._callbacks: - callback(message) - - def stop(self) -> None: - self._callbacks.clear() - - -def _keyboard_tasks() -> list[TaskConfig]: - coordinator_kwargs = next( - atom.kwargs - for atom in keyboard_teleop_piper.blueprints - if atom.module is ControlCoordinator - ) - return coordinator_kwargs["tasks"] - - -def test_piper_keyboard_uses_07m_open_position() -> None: - keyboard_kwargs = next( - atom.kwargs - for atom in keyboard_teleop_piper.blueprints - if atom.module is KeyboardTeleopModule - ) - - assert keyboard_kwargs == {} - hardware = next( - atom.kwargs["hardware"][0] - for atom in keyboard_teleop_piper.blueprints - if atom.module is ControlCoordinator - ) - assert (hardware.gripper_closed_position, hardware.gripper_open_position) == (0.0, 0.07) - - -def test_xarm_mock_factories_keep_normalized_mapping() -> None: - for hardware in ( - xarm6_hardware( - gripper=True, - gripper_open_position=0.85, - gripper_closed_position=0.0, - mock_without_address=True, - ), - xarm7_hardware( - gripper=True, - gripper_open_position=0.85, - gripper_closed_position=0.0, - mock_without_address=True, - ), - ): - assert (hardware.gripper_closed_position, hardware.gripper_open_position) == (0.0, 0.85) - - -def test_piper_quest_gripper_maps_neutral_open_and_full_closed() -> None: - coordinator_kwargs = next( - atom.kwargs - for atom in coordinator_teleop_piper.blueprints - if atom.module is ControlCoordinator - ) - hardware = coordinator_kwargs["hardware"][0] - task = coordinator_kwargs["tasks"][0] - - assert hardware.gripper_open_position == 0.07 - assert hardware.gripper_closed_position == 0.0 - assert task.params["gripper_open_pos"] == 1.0 - assert task.params["gripper_closed_pos"] == 0.0 - - -def test_piper_keyboard_and_quest_normalized_endpoints_reach_adapter() -> None: - component = next( - atom.kwargs["hardware"][0] - for atom in keyboard_teleop_piper.blueprints - if atom.module is ControlCoordinator +def test_connected_piper_hardware_converts_normalized_gripper_to_native_position() -> None: + component = make_piper_hardware( + gripper_open_position=0.07, + gripper_closed_position=0.0, ) adapter = MagicMock(spec=ManipulatorAdapter) adapter.read_joint_positions.return_value = [0.0] * 6 @@ -107,45 +24,10 @@ def test_piper_keyboard_and_quest_normalized_endpoints_reach_adapter() -> None: adapter.write_gripper_position.return_value = True hardware = ConnectedHardware(adapter, component) - hardware.write_command({"arm/gripper": 0.0}, ControlMode.POSITION) - hardware.write_command({"arm/gripper": 1.0}, ControlMode.POSITION) + assert hardware.write_command({"arm/gripper": 0.0}, ControlMode.POSITION) + assert hardware.write_command({"arm/gripper": 1.0}, ControlMode.POSITION) assert adapter.write_gripper_position.call_args_list == [ ((0.0,), {}), ((0.07,), {}), ] - - -def test_piper_keyboard_has_high_priority_gripper_servo() -> None: - servo = next(task for task in _keyboard_tasks() if task.name == "servo_gripper") - - assert servo.type == "servo" - assert servo.joint_names == ["arm/gripper"] - assert servo.priority > next( - task.priority for task in _keyboard_tasks() if task.type == "eef_twist" - ) - assert servo.params == {"timeout": 0.0, "default_positions": [0.0]} - - -def test_piper_keyboard_joint_commands_reach_gripper_servo() -> None: - servo_config = next(task for task in _keyboard_tasks() if task.name == "servo_gripper") - coordinator = ControlCoordinator(tasks=[servo_config], publish_joint_state=False) - stream = _JointCommandStream() - coordinator.joint_command.transport = stream # type: ignore[assignment] - try: - coordinator.start() - servo = coordinator.get_task("servo_gripper") - assert servo is not None - - stream.publish(JointState({"name": ["arm/gripper"], "position": [1.0]})) - opened = servo.compute(CoordinatorState(JointStateSnapshot({}), t_now=1.0, dt=0.01)) - assert opened is not None - assert opened.joint_names == ["arm/gripper"] - assert opened.positions == [1.0] - - stream.publish(JointState({"name": ["arm/gripper"], "position": [0.0]})) - closed = servo.compute(CoordinatorState(JointStateSnapshot({}), t_now=2.0, dt=0.01)) - assert closed is not None - assert closed.positions == [0.0] - finally: - coordinator.stop() diff --git a/dimos/robot/manipulators/test_blueprints.py b/dimos/robot/manipulators/test_blueprints.py index 424ede6489..69eabf09ba 100644 --- a/dimos/robot/manipulators/test_blueprints.py +++ b/dimos/robot/manipulators/test_blueprints.py @@ -91,6 +91,23 @@ def test_piper_keyboard_declares_high_priority_gripper_servo() -> None: assert servo.priority > next(task.priority for task in tasks if task.type == "eef_twist") +def test_piper_keyboard_declares_gripper_endpoints_and_light_keyboard_kwargs() -> None: + hardware = _module_kwargs(keyboard_teleop_piper, ControlCoordinator)["hardware"][0] + keyboard_kwargs = _module_kwargs(keyboard_teleop_piper, KeyboardTeleopModule) + + assert keyboard_kwargs == {} + assert (hardware.gripper_closed_position, hardware.gripper_open_position) == (0.0, 0.07) + + +def test_piper_quest_declares_normalized_gripper_endpoints() -> None: + hardware = _module_kwargs(coordinator_teleop_piper, ControlCoordinator)["hardware"][0] + task = _coordinator_tasks(coordinator_teleop_piper)[0] + + assert (hardware.gripper_closed_position, hardware.gripper_open_position) == (0.0, 0.07) + assert task.params["gripper_open_pos"] == 1.0 + assert task.params["gripper_closed_pos"] == 0.0 + + def test_planner_helper_defaults_to_no_visualization() -> None: blueprint = planner(robots=[make_xarm7_model_config(name="arm", add_gripper=True)]) diff --git a/dimos/robot/manipulators/xarm/test_config.py b/dimos/robot/manipulators/xarm/test_config.py new file mode 100644 index 0000000000..674758abbe --- /dev/null +++ b/dimos/robot/manipulators/xarm/test_config.py @@ -0,0 +1,29 @@ +# Copyright 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. + +from dimos.core.global_config import global_config +from dimos.robot.manipulators.xarm.config import xarm6_hardware, xarm7_hardware + + +def test_xarm_mock_factories_configure_gripper_endpoints(monkeypatch) -> None: + monkeypatch.setattr(global_config, "simulation", "") + monkeypatch.setattr(global_config, "xarm6_ip", "") + monkeypatch.setattr(global_config, "xarm7_ip", "") + + for hardware in ( + xarm6_hardware( + gripper=True, + gripper_open_position=0.85, + gripper_closed_position=0.0, + mock_without_address=True, + ), + xarm7_hardware( + gripper=True, + gripper_open_position=0.85, + gripper_closed_position=0.0, + mock_without_address=True, + ), + ): + assert (hardware.gripper_closed_position, hardware.gripper_open_position) == (0.0, 0.85) diff --git a/dimos/teleop/keyboard/test_keyboard_teleop_module.py b/dimos/teleop/keyboard/test_keyboard_teleop_module.py index 253fd4a864..4c12df7dca 100644 --- a/dimos/teleop/keyboard/test_keyboard_teleop_module.py +++ b/dimos/teleop/keyboard/test_keyboard_teleop_module.py @@ -19,12 +19,10 @@ import pytest from dimos.msgs.geometry_msgs.TwistStamped import TwistStamped -from dimos.msgs.sensor_msgs.JointState import JointState from dimos.robot.manipulators.common.topics import EEF_TWIST_TASK_NAME import dimos.teleop.keyboard.keyboard_teleop_module as keyboard_mod from dimos.teleop.keyboard.keyboard_teleop_module import ( GRIPPER_CLOSED_POSITION, - GRIPPER_JOINT_NAME, GRIPPER_OPEN_POSITION, KeyboardTeleopModule, _twist_from_keys, @@ -93,7 +91,7 @@ def test_twist_from_keys_maps_rotation_keys_to_eef_angular_twist() -> None: assert angular == (0.5, -0.5, 0.5) -def test_keyup_of_last_motion_key_publishes_zero_immediately( +def test_final_key_release_publishes_zero_velocity( module: KeyboardTeleopModule, mocker ) -> None: publish = mocker.patch.object(module.coordinator_ee_twist_command, "publish") @@ -134,22 +132,19 @@ def test_keyup_publishes_directly_without_timeout_wait( publish.assert_called_once() -def test_set_gripper_position_publishes_partial_joint_state_only_on_change( +def test_set_gripper_position_emits_only_when_position_changes( module: KeyboardTeleopModule, mocker ) -> None: - assert module.config.gripper_open_position == GRIPPER_OPEN_POSITION - publish = mocker.patch.object(module.joint_command, "publish") module._set_gripper_position(GRIPPER_OPEN_POSITION) + publish.assert_called_once() + assert publish.call_args.args[0].position == [GRIPPER_OPEN_POSITION] + + publish.reset_mock() module._set_gripper_position(GRIPPER_OPEN_POSITION) - module._set_gripper_position(GRIPPER_CLOSED_POSITION) + publish.assert_not_called() - assert publish.call_count == 2 - msg = publish.call_args_list[0].args[0] - assert isinstance(msg, JointState) - assert msg.name == [GRIPPER_JOINT_NAME] - assert msg.position == [GRIPPER_OPEN_POSITION] - assert msg.velocity == [] - assert msg.effort == [] - assert publish.call_args_list[1].args[0].position == [GRIPPER_CLOSED_POSITION] + module._set_gripper_position(GRIPPER_CLOSED_POSITION) + publish.assert_called_once() + assert publish.call_args.args[0].position == [GRIPPER_CLOSED_POSITION] diff --git a/dimos/teleop/quest/test_quest_teleop_module.py b/dimos/teleop/quest/test_quest_teleop_module.py index 1f5fb8b2c3..d231cc551b 100644 --- a/dimos/teleop/quest/test_quest_teleop_module.py +++ b/dimos/teleop/quest/test_quest_teleop_module.py @@ -12,28 +12,31 @@ # See the License for the specific language governing permissions and # limitations under the License. -from unittest.mock import patch +from collections.abc import Iterator + +import pytest from dimos.teleop.quest.quest_teleop_module import QuestTeleopModule -def test_quest_web_server_is_initialized_during_start() -> None: - with ( - patch("dimos.teleop.quest.quest_teleop_module.RobotWebInterface") as web_interface, - patch.object(QuestTeleopModule, "_setup_routes") as setup_routes, - patch.object(QuestTeleopModule, "_start_server") as start_server, - patch.object(QuestTeleopModule, "_start_control_loop"), - ): - module = QuestTeleopModule(server_port=9443) - try: - web_interface.assert_not_called() - assert module._web_server is None - - module.start() - - web_interface.assert_called_once_with(host="0.0.0.0", port=9443) - assert module._web_server is web_interface.return_value - setup_routes.assert_called_once_with() - start_server.assert_called_once_with() - finally: - module.stop() +@pytest.fixture +def module() -> Iterator[QuestTeleopModule]: + module = QuestTeleopModule(server_port=9443) + try: + yield module + finally: + module.stop() + + +def test_quest_web_server_is_initialized_during_start(module: QuestTeleopModule, mocker) -> None: + web_interface = mocker.patch("dimos.teleop.quest.quest_teleop_module.RobotWebInterface") + setup_routes = mocker.patch.object(module, "_setup_routes") + start_server = mocker.patch.object(module, "_start_server") + start_control_loop = mocker.patch.object(module, "_start_control_loop") + + module.start() + + web_interface.assert_called_once_with(host="0.0.0.0", port=9443) + setup_routes.assert_called_once_with() + start_server.assert_called_once_with() + start_control_loop.assert_called_once_with() From 1dbf767e30ddbe284677b6a1ddc27113f99ca1a2 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 05:45:20 +0000 Subject: [PATCH 27/51] [autofix.ci] apply automated fixes --- dimos/hardware/manipulators/piper/test_adapter.py | 4 +--- dimos/hardware/manipulators/test_adapter_lifecycle.py | 4 +--- dimos/teleop/keyboard/test_keyboard_teleop_module.py | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/dimos/hardware/manipulators/piper/test_adapter.py b/dimos/hardware/manipulators/piper/test_adapter.py index 6f22101b89..4e66a609cb 100644 --- a/dimos/hardware/manipulators/piper/test_adapter.py +++ b/dimos/hardware/manipulators/piper/test_adapter.py @@ -23,9 +23,7 @@ from dimos.hardware.manipulators.piper.adapter import PiperAdapter -def test_connect_reports_missing_sdk( - mocker: Any, capsys: pytest.CaptureFixture[str] -) -> None: +def test_connect_reports_missing_sdk(mocker: Any, capsys: pytest.CaptureFixture[str]) -> None: mocker.patch.dict(sys.modules, {"piper_sdk": None}) assert not PiperAdapter().connect() diff --git a/dimos/hardware/manipulators/test_adapter_lifecycle.py b/dimos/hardware/manipulators/test_adapter_lifecycle.py index 194d853fad..56739ea9df 100644 --- a/dimos/hardware/manipulators/test_adapter_lifecycle.py +++ b/dimos/hardware/manipulators/test_adapter_lifecycle.py @@ -44,9 +44,7 @@ def piper_sdk(mocker: Any) -> Any: ) ) sdk.gripper_position = 0 - sdk.GripperCtrl.side_effect = lambda position, *_: setattr( - sdk, "gripper_position", position - ) + sdk.GripperCtrl.side_effect = lambda position, *_: setattr(sdk, "gripper_position", position) sdk.GetArmGripperMsgs.side_effect = lambda: SimpleNamespace( gripper_state=SimpleNamespace(grippers_angle=sdk.gripper_position) ) diff --git a/dimos/teleop/keyboard/test_keyboard_teleop_module.py b/dimos/teleop/keyboard/test_keyboard_teleop_module.py index 4c12df7dca..4c59f906a2 100644 --- a/dimos/teleop/keyboard/test_keyboard_teleop_module.py +++ b/dimos/teleop/keyboard/test_keyboard_teleop_module.py @@ -91,9 +91,7 @@ def test_twist_from_keys_maps_rotation_keys_to_eef_angular_twist() -> None: assert angular == (0.5, -0.5, 0.5) -def test_final_key_release_publishes_zero_velocity( - module: KeyboardTeleopModule, mocker -) -> None: +def test_final_key_release_publishes_zero_velocity(module: KeyboardTeleopModule, mocker) -> None: publish = mocker.patch.object(module.coordinator_ee_twist_command, "publish") held = {keyboard_mod.pygame.K_w} event = keyboard_mod.pygame.event.Event(keyboard_mod.pygame.KEYUP, key=keyboard_mod.pygame.K_w) From c380ca11e7c6d8e205275773aefeac8ef63fd646 Mon Sep 17 00:00:00 2001 From: cc Date: Mon, 20 Jul 2026 23:59:19 -0700 Subject: [PATCH 28/51] refactor: simplify Piper integration --- dimos/hardware/manipulators/piper/adapter.py | 19 +++---- .../manipulators/piper/test_adapter.py | 19 ++----- .../manipulators/test_adapter_lifecycle.py | 21 +++---- dimos/robot/all_blueprints.py | 1 + .../teleop/keyboard/keyboard_teleop_module.py | 7 ++- .../manipulation/piper_integration.md | 55 +++++++++++++++++++ docs/capabilities/teleoperation/piper.md | 36 ------------ docs/docs.json | 12 ++-- 8 files changed, 87 insertions(+), 83 deletions(-) create mode 100644 docs/capabilities/manipulation/piper_integration.md delete mode 100644 docs/capabilities/teleoperation/piper.md diff --git a/dimos/hardware/manipulators/piper/adapter.py b/dimos/hardware/manipulators/piper/adapter.py index e3bbd9ac1d..329b7e32e1 100644 --- a/dimos/hardware/manipulators/piper/adapter.py +++ b/dimos/hardware/manipulators/piper/adapter.py @@ -25,6 +25,8 @@ import time from typing import Any +from piper_sdk import C_PiperInterface_V2 + from dimos.hardware.manipulators.spec import ( ControlMode, JointLimits, @@ -87,12 +89,6 @@ def __init__( def connect(self) -> bool: """Connect to Piper via CAN bus.""" - try: - from piper_sdk import C_PiperInterface_V2 - except ImportError: - print("ERROR: Piper SDK not installed. Please install piper_sdk") - return False - try: self._sdk = C_PiperInterface_V2( can_name=self._can_port, @@ -149,12 +145,11 @@ def _initialize_startup_state(self) -> bool: logger.exception("Failed to command Piper startup zero pose") return False - if hasattr(sdk, "GripperCtrl"): - try: - sdk.GripperCtrl(0, DEFAULT_GRIPPER_SPEED, 0x01, 0) - self._gripper_initialized = True - except Exception: - logger.warning("Piper gripper startup command failed; continuing arm startup") + try: + sdk.GripperCtrl(0, DEFAULT_GRIPPER_SPEED, 0x01, 0) + self._gripper_initialized = True + except Exception: + logger.warning("Piper gripper startup command failed; continuing arm startup") time.sleep(STARTUP_ZERO_WAIT) return True diff --git a/dimos/hardware/manipulators/piper/test_adapter.py b/dimos/hardware/manipulators/piper/test_adapter.py index 4e66a609cb..48b9d57a4d 100644 --- a/dimos/hardware/manipulators/piper/test_adapter.py +++ b/dimos/hardware/manipulators/piper/test_adapter.py @@ -15,31 +15,24 @@ from __future__ import annotations import sys -from types import SimpleNamespace +from types import ModuleType from typing import Any -import pytest +piper_sdk_module = ModuleType("piper_sdk") +piper_sdk_module.__dict__["C_PiperInterface_V2"] = lambda **_: None +sys.modules.setdefault("piper_sdk", piper_sdk_module) +from dimos.hardware.manipulators.piper import adapter as piper_adapter from dimos.hardware.manipulators.piper.adapter import PiperAdapter -def test_connect_reports_missing_sdk(mocker: Any, capsys: pytest.CaptureFixture[str]) -> None: - mocker.patch.dict(sys.modules, {"piper_sdk": None}) - - assert not PiperAdapter().connect() - assert "Piper SDK not installed" in capsys.readouterr().out - - def test_connect_continues_when_gripper_startup_fails( mocker: Any, ) -> None: sdk = mocker.Mock() sdk.GetArmStatus.return_value = object() sdk.GripperCtrl.side_effect = RuntimeError("gripper unavailable") - mocker.patch.dict( - sys.modules, - {"piper_sdk": SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk)}, - ) + mocker.patch.object(piper_adapter, "C_PiperInterface_V2", lambda **_: sdk) mocker.patch("dimos.hardware.manipulators.piper.adapter.time.sleep") adapter = PiperAdapter() diff --git a/dimos/hardware/manipulators/test_adapter_lifecycle.py b/dimos/hardware/manipulators/test_adapter_lifecycle.py index 56739ea9df..d56f25c48a 100644 --- a/dimos/hardware/manipulators/test_adapter_lifecycle.py +++ b/dimos/hardware/manipulators/test_adapter_lifecycle.py @@ -15,12 +15,16 @@ from __future__ import annotations import sys -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace from typing import Any import pytest from typing_extensions import override +piper_sdk_module = ModuleType("piper_sdk") +piper_sdk_module.__dict__["C_PiperInterface_V2"] = lambda **_: None +sys.modules.setdefault("piper_sdk", piper_sdk_module) + from dimos.hardware.manipulators.a750.adapter import A750Adapter from dimos.hardware.manipulators.openarm.adapter import OpenArmAdapter from dimos.hardware.manipulators.piper import adapter as piper_adapter @@ -48,10 +52,7 @@ def piper_sdk(mocker: Any) -> Any: sdk.GetArmGripperMsgs.side_effect = lambda: SimpleNamespace( gripper_state=SimpleNamespace(grippers_angle=sdk.gripper_position) ) - mocker.patch.dict( - sys.modules, - {"piper_sdk": SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk)}, - ) + mocker.patch.object(piper_adapter, "C_PiperInterface_V2", lambda **_: sdk) mocker.patch.object(piper_adapter.time, "sleep") return sdk @@ -104,10 +105,7 @@ def test_piper_connect_reset_failure_cleans_up_without_zero( sdk = mocker.Mock() sdk.GetArmStatus.return_value = object() sdk.MotionCtrl_1.side_effect = RuntimeError("reset failed") - mocker.patch.dict( - sys.modules, - {"piper_sdk": SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk)}, - ) + mocker.patch.object(piper_adapter, "C_PiperInterface_V2", lambda **_: sdk) adapter = PiperAdapter() @@ -131,10 +129,7 @@ def test_piper_connect_joint_failure_cleans_up_without_gripper( sdk = mocker.Mock() sdk.GetArmStatus.return_value = object() sdk.JointCtrl.side_effect = RuntimeError("joint command failed") - mocker.patch.dict( - sys.modules, - {"piper_sdk": SimpleNamespace(C_PiperInterface_V2=lambda **_: sdk)}, - ) + mocker.patch.object(piper_adapter, "C_PiperInterface_V2", lambda **_: sdk) mocker.patch.object(piper_adapter.time, "sleep") adapter = PiperAdapter() diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index f5b8541199..f15665fd96 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -69,6 +69,7 @@ "keyboard-teleop-piper": "dimos.robot.manipulators.piper.blueprints.teleop:keyboard_teleop_piper", "keyboard-teleop-xarm6": "dimos.robot.manipulators.xarm.blueprints.teleop:keyboard_teleop_xarm6", "keyboard-teleop-xarm7": "dimos.robot.manipulators.xarm.blueprints.teleop:keyboard_teleop_xarm7", + "learning-collect-quest-piper": "dimos.learning.collection.blueprint:learning_collect_quest_piper", "learning-collect-quest-xarm7": "dimos.learning.collection.blueprint:learning_collect_quest_xarm7", "mid360": "dimos.hardware.sensors.lidar.livox.livox_blueprints:mid360", "mid360-fastlio": "dimos.hardware.sensors.lidar.fastlio2.fastlio_blueprints:mid360_fastlio", diff --git a/dimos/teleop/keyboard/keyboard_teleop_module.py b/dimos/teleop/keyboard/keyboard_teleop_module.py index 75ba61f8ad..b0aa51729f 100644 --- a/dimos/teleop/keyboard/keyboard_teleop_module.py +++ b/dimos/teleop/keyboard/keyboard_teleop_module.py @@ -33,7 +33,7 @@ import os import threading -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any try: import pygame @@ -63,6 +63,7 @@ # Normalized gripper command values. GRIPPER_OPEN_POSITION = 1.0 GRIPPER_CLOSED_POSITION = 0.0 +# TODO: Improve gripper handling. GRIPPER_JOINT_NAME = "arm/gripper" TwistVector = tuple[float, float, float] @@ -101,8 +102,8 @@ def _gripper_key_codes() -> tuple[int, int]: if pygame is None: return (-1, -1) return ( - cast("int", getattr(pygame, "K_LEFTBRACKET", ord("["))), - cast("int", getattr(pygame, "K_RIGHTBRACKET", ord("]"))), + pygame.K_LEFTBRACKET, + pygame.K_RIGHTBRACKET, ) diff --git a/docs/capabilities/manipulation/piper_integration.md b/docs/capabilities/manipulation/piper_integration.md new file mode 100644 index 0000000000..89e21ad64e --- /dev/null +++ b/docs/capabilities/manipulation/piper_integration.md @@ -0,0 +1,55 @@ +--- +title: "Piper Integration" +description: "Connect and run a Piper arm with DimOS manipulation and teleoperation blueprints." +--- + +DimOS integrates the Piper arm through its CAN-based `piper_sdk` adapter and the +standard manipulation stack. The Piper hardware configuration uses the CAN port +from `GlobalConfig.can_port`; when it is not set, teleoperation blueprints use +the mock adapter where supported. + +The manipulation extra provides the verified software prerequisites: Drake and +`piper-sdk`. Piper is a six-degree-of-freedom arm in the DimOS hardware model. + +For real hardware, set `GlobalConfig.can_port` to the CAN interface used by the +arm. The default hardware address is `can0`; the standard Piper configuration +uses the mock adapter when no CAN port is configured. No separate DimOS hardware +bring-up command is documented here. + +## Run a Piper blueprint + +Use the coordinator for the basic manipulation composition: + +```bash +dimos run coordinator-piper +``` + +For keyboard Cartesian teleoperation, use: + +```bash +dimos run keyboard-teleop-piper +``` + +The Quest teleoperation composition is available as: + +```bash +dimos run teleop-quest-piper +``` + +Add `--simulation` to run the supported MuJoCo composition instead of real +hardware. + +## Integration points + +The Piper blueprints are defined in: + +- [`basic.py`](/dimos/robot/manipulators/piper/blueprints/basic.py) — coordinator +- [`teleop.py`](/dimos/robot/manipulators/piper/blueprints/teleop.py) — keyboard, + Cartesian, and Quest teleoperation compositions +- [`config.py`](/dimos/robot/manipulators/piper/config.py) — hardware and model + configuration + +The manipulation requirements include Drake and `piper-sdk`. Follow the +hardware vendor's documentation for connecting and powering the arm; DimOS +provides the integration and blueprint compositions, not a separate hardware +bring-up procedure. diff --git a/docs/capabilities/teleoperation/piper.md b/docs/capabilities/teleoperation/piper.md deleted file mode 100644 index cf2fa2b609..0000000000 --- a/docs/capabilities/teleoperation/piper.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -title: "Piper Teleoperation" -description: "Operate a Piper arm with Quest VR or a keyboard." ---- - -DimOS supports direct Piper teleoperation through Quest VR and keyboard input. -These paths operate the arm only; they do not record episodes or create -datasets. - -## Quest - -Start the Quest blueprint on the robot: - -```bash -dimos run teleop-quest-piper -``` - -Open the robot's LAN address from the Quest browser. The Quest web endpoint -binds to all LAN interfaces (HTTPS, port `8443` by default), so the headset -and robot must be on the same network. The left controller drives the Piper -arm; the blueprint routes its command stream to the Piper coordinator. - -Use `--simulation` to run the same composition against the supported simulator. - -## Keyboard - -Start keyboard teleoperation from the robot's terminal: - -```bash -dimos run keyboard-teleop-piper -``` - -Use the keyboard controls shown by the teleop module for Cartesian arm motion. -The configured gripper keys open and close the Piper gripper while leaving arm -motion controls unchanged. Both teleop paths apply the Piper motion safety -limits before commands reach the coordinator. diff --git a/docs/docs.json b/docs/docs.json index 12d1e15adf..9b4246f2be 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -101,6 +101,7 @@ "capabilities/manipulation/agentic", "capabilities/manipulation/adding_a_custom_arm", "capabilities/manipulation/openarm_integration", + "capabilities/manipulation/piper_integration", "capabilities/manipulation/a750" ] }, @@ -129,8 +130,7 @@ { "group": "Teleoperation", "pages": [ - "capabilities/teleoperation/hosted", - "capabilities/teleoperation/piper" + "capabilities/teleoperation/hosted" ] } ] @@ -421,6 +421,10 @@ "source": "/docs/capabilities/manipulation/openarm_integration.md", "destination": "/capabilities/manipulation/openarm_integration" }, + { + "source": "/docs/capabilities/manipulation/piper_integration.md", + "destination": "/capabilities/manipulation/piper_integration" + }, { "source": "/docs/capabilities/manipulation/a750.md", "destination": "/capabilities/manipulation/a750" @@ -469,10 +473,6 @@ "source": "/docs/capabilities/teleoperation/hosted.md", "destination": "/capabilities/teleoperation/hosted" }, - { - "source": "/docs/capabilities/teleoperation/piper.md", - "destination": "/capabilities/teleoperation/piper" - }, { "source": "/docs/platforms/quadruped/go2/index.md", "destination": "/platforms/quadruped/go2/index" From 1a15f189ef7083ad11b0509f395e7747c539e02b Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 21 Jul 2026 00:46:03 -0700 Subject: [PATCH 29/51] fix: satisfy Piper teleop type checks --- dimos/teleop/keyboard/keyboard_teleop_module.py | 4 ++-- dimos/web/robot_web_interface.py | 15 +++++++++++---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/dimos/teleop/keyboard/keyboard_teleop_module.py b/dimos/teleop/keyboard/keyboard_teleop_module.py index b0aa51729f..85e4ef3e7b 100644 --- a/dimos/teleop/keyboard/keyboard_teleop_module.py +++ b/dimos/teleop/keyboard/keyboard_teleop_module.py @@ -102,8 +102,8 @@ def _gripper_key_codes() -> tuple[int, int]: if pygame is None: return (-1, -1) return ( - pygame.K_LEFTBRACKET, - pygame.K_RIGHTBRACKET, + pygame.K_LEFTBRACKET, # type: ignore[attr-defined] + pygame.K_RIGHTBRACKET, # type: ignore[attr-defined] ) diff --git a/dimos/web/robot_web_interface.py b/dimos/web/robot_web_interface.py index bd3fa7c3b4..667ad41466 100644 --- a/dimos/web/robot_web_interface.py +++ b/dimos/web/robot_web_interface.py @@ -17,6 +17,13 @@ Provides a clean interface to the dimensional-interface FastAPI server. """ +from collections.abc import Mapping +from typing import Any + +from reactivex import Observable +from reactivex.subject import Subject + +from dimos.stream.audio.base import AudioEvent from dimos.web.dimos_interface.api.server import FastAPIServer @@ -27,10 +34,10 @@ def __init__( self, port: int = 5555, host: str | None = None, - text_streams=None, - audio_subject=None, - **streams, - ) -> None: # type: ignore[no-untyped-def] + text_streams: Mapping[str, Observable[str]] | None = None, + audio_subject: Subject[AudioEvent] | None = None, + **streams: Observable[Any], + ) -> None: super().__init__( dev_name="Robot Web Interface", edge_type="Bidirectional", From 40b1152c18bf419903f6743edfbac8e89a61f07a Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 21 Jul 2026 01:29:09 -0700 Subject: [PATCH 30/51] fix: type Piper SDK state --- dimos/hardware/manipulators/piper/adapter.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/dimos/hardware/manipulators/piper/adapter.py b/dimos/hardware/manipulators/piper/adapter.py index 329b7e32e1..ea717f1026 100644 --- a/dimos/hardware/manipulators/piper/adapter.py +++ b/dimos/hardware/manipulators/piper/adapter.py @@ -81,7 +81,7 @@ def __init__( self._can_port = address self._dof = dof self._gripper_speed = gripper_speed - self._sdk: Any = None + self._sdk: C_PiperInterface_V2 | None = None self._connected: bool = False self._enabled: bool = False self._gripper_initialized: bool = False @@ -90,21 +90,22 @@ def __init__( def connect(self) -> bool: """Connect to Piper via CAN bus.""" try: - self._sdk = C_PiperInterface_V2( + sdk = C_PiperInterface_V2( can_name=self._can_port, judge_flag=True, # Enable safety checks can_auto_init=True, # Let SDK handle CAN initialization dh_is_offset=False, ) + self._sdk = sdk # Connect to CAN port - self._sdk.ConnectPort(piper_init=True, start_thread=True) + sdk.ConnectPort(piper_init=True, start_thread=True) # Wait for initialization time.sleep(0.025) # Check connection by trying to get status - status = self._sdk.GetArmStatus() + status = sdk.GetArmStatus() if status is not None: if not self._initialize_startup_state(): self._close_failed_connection() From fa7ea5c37191f08f75dec819022360f1929c4001 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 21 Jul 2026 01:46:49 -0700 Subject: [PATCH 31/51] chore: fix Piper pre-commit checks --- dimos/robot/manipulators/piper/test_config.py | 9 +++++++++ .../manipulators/piper/test_teleop_gripper_mapping.py | 9 +++++++++ dimos/robot/manipulators/xarm/test_config.py | 9 +++++++++ docs/capabilities/manipulation/piper_integration.md | 6 +++--- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/dimos/robot/manipulators/piper/test_config.py b/dimos/robot/manipulators/piper/test_config.py index 5c32d432dc..e699ad2734 100644 --- a/dimos/robot/manipulators/piper/test_config.py +++ b/dimos/robot/manipulators/piper/test_config.py @@ -2,6 +2,15 @@ # # 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 pathlib import Path diff --git a/dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py b/dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py index b61c5ce851..2da7f42136 100644 --- a/dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py +++ b/dimos/robot/manipulators/piper/test_teleop_gripper_mapping.py @@ -2,6 +2,15 @@ # # 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 unittest.mock import MagicMock diff --git a/dimos/robot/manipulators/xarm/test_config.py b/dimos/robot/manipulators/xarm/test_config.py index 674758abbe..d7106b2f5c 100644 --- a/dimos/robot/manipulators/xarm/test_config.py +++ b/dimos/robot/manipulators/xarm/test_config.py @@ -2,6 +2,15 @@ # # 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 dimos.core.global_config import global_config from dimos.robot.manipulators.xarm.config import xarm6_hardware, xarm7_hardware diff --git a/docs/capabilities/manipulation/piper_integration.md b/docs/capabilities/manipulation/piper_integration.md index 89e21ad64e..7037ab62ff 100644 --- a/docs/capabilities/manipulation/piper_integration.md +++ b/docs/capabilities/manipulation/piper_integration.md @@ -43,10 +43,10 @@ hardware. The Piper blueprints are defined in: -- [`basic.py`](/dimos/robot/manipulators/piper/blueprints/basic.py) — coordinator -- [`teleop.py`](/dimos/robot/manipulators/piper/blueprints/teleop.py) — keyboard, +- [`robot/manipulators/piper/blueprints/basic.py`](/dimos/robot/manipulators/piper/blueprints/basic.py) — coordinator +- [`robot/manipulators/piper/blueprints/teleop.py`](/dimos/robot/manipulators/piper/blueprints/teleop.py) — keyboard, Cartesian, and Quest teleoperation compositions -- [`config.py`](/dimos/robot/manipulators/piper/config.py) — hardware and model +- [`robot/manipulators/piper/config.py`](/dimos/robot/manipulators/piper/config.py) — hardware and model configuration The manipulation requirements include Drake and `piper-sdk`. Follow the From 339c3ec24c4269206e2b7899c87399d81fe0a77d Mon Sep 17 00:00:00 2001 From: Nabla7 Date: Tue, 21 Jul 2026 06:46:49 -0400 Subject: [PATCH 32/51] Add composable A1Z learned policy skills --- dimos/learning/README.md | 40 ++++- dimos/learning/lerobot_policy.py | 160 +++++++++++++----- dimos/learning/test_lerobot_policy.py | 137 ++++++++++++--- .../robot/manipulators/galaxea_a1z/README.md | 89 ++++++++++ .../galaxea_a1z/blueprints/basic.py | 34 +++- .../galaxea_a1z/teach_replay_cli.py | 2 +- .../galaxea_a1z/test_teach_replay.py | 32 +++- 7 files changed, 417 insertions(+), 77 deletions(-) diff --git a/dimos/learning/README.md b/dimos/learning/README.md index ac263416e5..80b52e4572 100644 --- a/dimos/learning/README.md +++ b/dimos/learning/README.md @@ -148,8 +148,38 @@ uv run lerobot-train \ ``` The deployable checkpoint is written under -`outputs/a1z_act/checkpoints/last/pretrained_model`. A robot blueprint can -compose `LeRobotPolicyModule` with a camera, joint-state source, and joint -command consumer. The module does not move hardware at startup; its -`execute_learned_policy` background skill starts inference explicitly, and -`stop_learned_policy` stops it while the robot holds the final command. +`outputs/a1z_act/checkpoints/last/pretrained_model`. ACT is the currently +tested A1Z path, but the runtime loads policies through LeRobot's policy +factory rather than hard-coding ACT. Another LeRobot policy type can be used +when its saved configuration has the same single RGB image input, seven-value +state input, and seven-value action output. + +`LeRobotPolicyModule` owns a named catalog of checkpoints. It loads each +checkpoint on first use and caches it, so one running robot stack can execute +several trained behaviors without restarting. The generic +`execute_learned_policy(policy_name, duration)` skill is useful for testing. +For an agent-facing robot, expose each behavior as a normal, descriptive +DimOS skill instead: + +```python +from dimos.agents.annotation import skill +from dimos.agents.capabilities import CAP_MOVEMENT +from dimos.learning.lerobot_policy import LeRobotPolicyModule + + +class HackathonPolicies(LeRobotPolicyModule): + @skill(uses=[CAP_MOVEMENT], lifecycle="background") + def pick_up_cup(self) -> str: + """Pick up the wooden cup from the table.""" + return self.start_configured_policy("pick_up_cup", tool_name="pick_up_cup") + + @skill(uses=[CAP_MOVEMENT], lifecycle="background") + def place_cup(self) -> str: + """Place the held wooden cup on the table.""" + return self.start_configured_policy("place_cup", tool_name="place_cup") +``` + +These wrappers are deliberately ordinary `@skill` methods. Their names, +docstrings, movement-capability locking, background progress, and stop events +therefore pass through the existing DimOS agent and MCP machinery unchanged. +The shared parent module only selects and executes checkpoints. diff --git a/dimos/learning/lerobot_policy.py b/dimos/learning/lerobot_policy.py index 255f2a968d..2c7c49c27f 100644 --- a/dimos/learning/lerobot_policy.py +++ b/dimos/learning/lerobot_policy.py @@ -36,6 +36,7 @@ from dimos.core.stream import In, Out from dimos.msgs.sensor_msgs.Image import Image, ImageFormat from dimos.msgs.sensor_msgs.JointState import JointState +from dimos.protocol.service.spec import BaseConfig from dimos.utils.logging_config import setup_logger logger = setup_logger() @@ -61,20 +62,31 @@ def predict( ) -> NDArray[np.float32]: ... -class LeRobotPolicyModuleConfig(ModuleConfig): +class LeRobotPolicyConfig(BaseConfig): + """Configuration for one named learned policy.""" + policy_path: str + task: str = "" + device: str | None = None + default_duration: float = Field(default=10.0, gt=0) + + +class LeRobotPolicyModuleConfig(ModuleConfig): + policies: dict[str, LeRobotPolicyConfig] = Field(min_length=1) joint_names: list[str] = Field(min_length=1) fps: float = Field(default=15.0, gt=0) - task: str = "" robot_type: str = "" - device: str | None = None max_observation_age_s: float = Field(default=0.5, gt=0) class _LeRobotBackend: """Lazy LeRobot/torch adapter using the upstream inference pipeline.""" - def __init__(self, config: LeRobotPolicyModuleConfig) -> None: + def __init__( + self, + config: LeRobotPolicyModuleConfig, + policy: LeRobotPolicyConfig, + ) -> None: try: torch = import_module("torch") PreTrainedConfig = import_module("lerobot.configs.policies").PreTrainedConfig @@ -93,9 +105,9 @@ def __init__(self, config: LeRobotPolicyModuleConfig) -> None: ) from exc register_third_party_plugins() - policy_config = PreTrainedConfig.from_pretrained(config.policy_path) - if config.device is not None: - policy_config.device = config.device + policy_config = PreTrainedConfig.from_pretrained(policy.policy_path) + if policy.device is not None: + policy_config.device = policy.device if policy_config.device is None: raise RuntimeError("LeRobot did not resolve an inference device") @@ -107,10 +119,10 @@ def __init__(self, config: LeRobotPolicyModuleConfig) -> None: ) policy_class = get_policy_class(policy_config.type) - self._policy = policy_class.from_pretrained(config.policy_path, config=policy_config) + self._policy = policy_class.from_pretrained(policy.policy_path, config=policy_config) self._preprocessor, self._postprocessor = make_pre_post_processors( policy_cfg=policy_config, - pretrained_path=config.policy_path, + pretrained_path=policy.policy_path, preprocessor_overrides={"device_processor": {"device": str(self._device)}}, ) self._prepare_observation = prepare_observation_for_inference @@ -177,8 +189,11 @@ def predict( return np.asarray(action.squeeze(0).to("cpu").numpy(), dtype=np.float32) -def _load_policy_backend(config: LeRobotPolicyModuleConfig) -> PolicyBackend: - return _LeRobotBackend(config) +def _load_policy_backend( + config: LeRobotPolicyModuleConfig, + policy: LeRobotPolicyConfig, +) -> PolicyBackend: + return _LeRobotBackend(config, policy) class LeRobotPolicyModule(Module): @@ -195,22 +210,23 @@ def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) if len(set(self.config.joint_names)) != len(self.config.joint_names): raise ValueError("joint_names must not contain duplicates") + if any(not name.strip() for name in self.config.policies): + raise ValueError("policy names must not be empty") self._lock = RLock() - self._backend: PolicyBackend | None = None + self._backends: dict[str, PolicyBackend] = {} self._latest_image: tuple[NDArray[np.uint8], float] | None = None self._latest_joint_state: JointState | None = None self._stop_event = Event() self._thread: Thread | None = None self._commands_sent = 0 self._last_error: str | None = None - self._active_task = self.config.task + self._active_policy_name: str | None = None + self._active_task = "" + self._active_tool_name: str | None = None @rpc def build(self) -> None: - """Load the checkpoint before hardware modules are started.""" - if self._backend is None: - self._backend = _load_policy_backend(self.config) - logger.info("Loaded LeRobot policy from %s", self.config.policy_path) + """Build the module; checkpoints are loaded when first invoked.""" @rpc def start(self) -> None: @@ -226,40 +242,87 @@ def stop(self) -> None: super().stop() @skill(uses=[CAP_MOVEMENT], lifecycle="background") - def execute_learned_policy(self, duration: float = 10.0, task: str = "") -> str: - """Execute the loaded learned policy against the live camera and robot state. + def execute_learned_policy( + self, + policy_name: str, + duration: float | None = None, + ) -> str: + """Execute a configured learned policy against live camera and robot state. Args: - duration: Maximum execution time in seconds. - task: Optional task prompt; defaults to the module's configured task. + policy_name: Name of the policy in this module's policy catalog. + duration: Optional maximum execution time; uses the policy default when omitted. + """ + return self.start_configured_policy( + policy_name, + tool_name=_TOOL_NAME, + duration=duration, + ) + + def start_configured_policy( + self, + policy_name: str, + *, + tool_name: str, + duration: float | None = None, + ) -> str: + """Start a catalog policy from a user-defined background ``@skill`` method. + + This method must be called by a method decorated with + ``@skill(..., lifecycle="background")`` so DimOS can associate the + execution updates with the user-facing tool name. """ - self.start_tool(_TOOL_NAME) + self.start_tool(tool_name) background_launched = False try: - if duration <= 0: + policy = self.config.policies.get(policy_name) + if policy is None: + available = ", ".join(sorted(self.config.policies)) + return f"Unknown learned policy {policy_name!r}. Available policies: {available}." + execution_duration = policy.default_duration if duration is None else duration + if execution_duration <= 0: return "Duration must be greater than zero." with self._lock: if self._thread is not None and self._thread.is_alive(): - background_launched = True - return "The learned policy is already running." + if self._active_tool_name == tool_name: + background_launched = True + return f"Learned policy {self._active_policy_name!r} is already running." self._snapshot_observation(time.time()) - if self._backend is None: - return "The learned policy has not been loaded." - self._backend.reset() + backend = self._backends.get(policy_name) + + # Loading can take seconds. Keep observation callbacks flowing so + # inference starts from a fresh camera frame and joint state. + if backend is None: + loaded_backend = _load_policy_backend(self.config, policy) + with self._lock: + backend = self._backends.setdefault(policy_name, loaded_backend) + logger.info("Loaded LeRobot policy %s from %s", policy_name, policy.policy_path) + + with self._lock: + # Re-check after loading in case another direct caller started + # a policy while this checkpoint was being initialized. + if self._thread is not None and self._thread.is_alive(): + if self._active_tool_name == tool_name: + background_launched = True + return f"Learned policy {self._active_policy_name!r} is already running." + self._snapshot_observation(time.time()) + backend.reset() self._stop_event.clear() self._commands_sent = 0 self._last_error = None - self._active_task = task or self.config.task + self._active_policy_name = policy_name + self._active_task = policy.task + self._active_tool_name = tool_name self._thread = Thread( target=self._run_policy, - args=(duration, self._active_task), - name="lerobot-policy", + args=(backend, execution_duration, policy.task, tool_name), + name=f"lerobot-policy-{policy_name}", daemon=True, ) self._thread.start() background_launched = True return ( - f"Learned policy started for up to {duration:.1f}s. " + f"Learned policy {policy_name!r} started for up to {execution_duration:.1f}s. " "Use stop_learned_policy to stop early." ) except Exception as exc: @@ -268,7 +331,7 @@ def execute_learned_policy(self, duration: float = 10.0, task: str = "") -> str: return f"Learned policy did not start: {exc}" finally: if not background_launched: - self.stop_tool(_TOOL_NAME) + self.stop_tool(tool_name) @skill def stop_learned_policy(self) -> str: @@ -290,7 +353,13 @@ def policy_status(self) -> dict[str, Any]: "running": running, "observations_ready": observation_error is None, "observation_error": observation_error, - "policy_path": self.config.policy_path, + "active_policy": self._active_policy_name, + "policy_path": ( + self.config.policies[self._active_policy_name].policy_path + if self._active_policy_name is not None + else None + ), + "available_policies": sorted(self.config.policies), "task": self._active_task, "commands_sent": self._commands_sent, "last_error": self._last_error, @@ -337,14 +406,17 @@ def _snapshot_observation(self, now: float) -> tuple[NDArray[np.uint8], NDArray[ raise RuntimeError("joint state contains non-finite positions") return image.copy(), vector - def _run_policy(self, duration: float, task: str) -> None: + def _run_policy( + self, + backend: PolicyBackend, + duration: float, + task: str, + tool_name: str, + ) -> None: period = 1.0 / self.config.fps deadline = time.monotonic() + duration next_progress = time.monotonic() + 1.0 try: - backend = self._backend - if backend is None: - raise RuntimeError("policy backend is not loaded") while not self._stop_event.is_set() and time.monotonic() < deadline: tick_started = time.monotonic() with self._lock: @@ -379,24 +451,28 @@ def _run_policy(self, duration: float, task: str) -> None: commands_sent = self._commands_sent now = time.monotonic() if now >= next_progress: - self.tool_update(_TOOL_NAME, f"Executed {commands_sent} policy steps") + self.tool_update(tool_name, f"Executed {commands_sent} policy steps") next_progress = now + 1.0 self._stop_event.wait(max(0.0, period - (time.monotonic() - tick_started))) + if not self._stop_event.is_set(): + self.tool_update(tool_name, f"Policy completed after {self._commands_sent} steps") except Exception as exc: with self._lock: self._last_error = str(exc) logger.exception("LeRobot policy execution stopped: %s", exc) - self.tool_update(_TOOL_NAME, f"Policy stopped: {exc}") + self.tool_update(tool_name, f"Policy stopped: {exc}") finally: self._stop_event.set() - self.stop_tool(_TOOL_NAME) + self.stop_tool(tool_name) def _stop_policy(self) -> bool: with self._lock: thread = self._thread was_running = thread is not None and thread.is_alive() + tool_name = self._active_tool_name self._stop_event.set() if thread is not None and thread is not current_thread(): thread.join(timeout=DEFAULT_THREAD_JOIN_TIMEOUT) - self.stop_tool(_TOOL_NAME) + if tool_name is not None: + self.stop_tool(tool_name) return was_running diff --git a/dimos/learning/test_lerobot_policy.py b/dimos/learning/test_lerobot_policy.py index c91625c410..dbba092fb4 100644 --- a/dimos/learning/test_lerobot_policy.py +++ b/dimos/learning/test_lerobot_policy.py @@ -16,16 +16,18 @@ from __future__ import annotations -from collections.abc import Callable, Iterator +from collections.abc import Iterator from threading import Event import time -from typing import Any +from typing import Any, Protocol import numpy as np import pytest import pytest_mock -from dimos.learning.lerobot_policy import LeRobotPolicyModule +from dimos.agents.annotation import skill +from dimos.agents.capabilities import CAP_MOVEMENT +from dimos.learning.lerobot_policy import LeRobotPolicyConfig, LeRobotPolicyModule from dimos.msgs.sensor_msgs.Image import Image, ImageFormat from dimos.msgs.sensor_msgs.JointState import JointState from dimos.protocol.rpc.pubsubrpc import LCMRPC @@ -33,6 +35,13 @@ JOINTS = [f"arm/joint{i}" for i in range(1, 7)] + ["arm/gripper"] +class CupPolicyModule(LeRobotPolicyModule): + @skill(uses=[CAP_MOVEMENT], lifecycle="background") + def pick_up_cup(self) -> str: + """Pick up the wooden cup.""" + return self.start_configured_policy("pick_up_cup", tool_name="pick_up_cup") + + class FakeBackend: def __init__(self, action: np.ndarray[Any, Any]) -> None: self.action = action @@ -72,10 +81,19 @@ def publish(self, message: JointState) -> None: self.published.set() +class ModuleFactory(Protocol): + def __call__( + self, + backends: dict[str, FakeBackend], + *, + module_type: type[LeRobotPolicyModule] = LeRobotPolicyModule, + ) -> tuple[LeRobotPolicyModule, CapturingOutput, Event]: ... + + @pytest.fixture def make_module( mocker: pytest_mock.MockerFixture, -) -> Iterator[Callable[[FakeBackend], tuple[LeRobotPolicyModule, CapturingOutput, Event]]]: +) -> Iterator[ModuleFactory]: mocker.patch("dimos.core.module.get_loop", return_value=(mocker.MagicMock(), None)) mocker.patch.object(LCMRPC, "__init__", return_value=None) mocker.patch.object(LCMRPC, "serve_module_rpc", return_value=None) @@ -84,13 +102,27 @@ def make_module( built: list[LeRobotPolicyModule] = [] - def _make(backend: FakeBackend) -> tuple[LeRobotPolicyModule, CapturingOutput, Event]: - mocker.patch("dimos.learning.lerobot_policy._load_policy_backend", return_value=backend) - module = LeRobotPolicyModule( - policy_path="checkpoint", + def _make( + backends: dict[str, FakeBackend], + *, + module_type: type[LeRobotPolicyModule] = LeRobotPolicyModule, + ) -> tuple[LeRobotPolicyModule, CapturingOutput, Event]: + policies = { + name: LeRobotPolicyConfig( + policy_path=f"checkpoint/{name}", + task=f"task for {name}", + ) + for name in backends + } + + def _load(_config: Any, policy: LeRobotPolicyConfig) -> FakeBackend: + return backends[policy.policy_path.rsplit("/", maxsplit=1)[-1]] + + mocker.patch("dimos.learning.lerobot_policy._load_policy_backend", side_effect=_load) + module = module_type( + policies=policies, joint_names=JOINTS, fps=50.0, - task="default task", robot_type="galaxea_a1z", ) output = CapturingOutput() @@ -122,36 +154,37 @@ def _provide_observation(module: LeRobotPolicyModule) -> tuple[np.ndarray[Any, A def test_policy_observation_and_action_use_canonical_order( - make_module: Callable[[FakeBackend], tuple[LeRobotPolicyModule, CapturingOutput, Event]], + make_module: ModuleFactory, ) -> None: action = np.arange(len(JOINTS), dtype=np.float32) / 20 backend = FakeBackend(action) - module, output, _finished = make_module(backend) + module, output, _finished = make_module({"pick_up_cube": backend}) bgr, positions = _provide_observation(module) - result = module.execute_learned_policy(duration=1.0, task="pick up cube") + result = module.execute_learned_policy("pick_up_cube", duration=1.0) assert "started" in result.lower() assert output.published.wait(1.0), "policy did not publish a command" assert backend.reset_count == 1 - assert backend.task == "pick up cube" + assert backend.task == "task for pick_up_cube" assert backend.robot_type == "galaxea_a1z" assert backend.image is not None np.testing.assert_array_equal(backend.image, bgr[..., ::-1]) - np.testing.assert_allclose(backend.state, positions) + assert backend.state is not None + np.testing.assert_allclose(backend.state, np.asarray(positions)) assert output.messages[0].name == JOINTS np.testing.assert_allclose(output.messages[0].position, action) module.stop_learned_policy() def test_invalid_policy_action_stops_without_publishing( - make_module: Callable[[FakeBackend], tuple[LeRobotPolicyModule, CapturingOutput, Event]], + make_module: ModuleFactory, ) -> None: backend = FakeBackend(np.zeros(len(JOINTS) - 1, dtype=np.float32)) - module, output, finished = make_module(backend) + module, output, finished = make_module({"invalid": backend}) _provide_observation(module) - module.execute_learned_policy(duration=1.0) + module.execute_learned_policy("invalid", duration=1.0) assert backend.called.wait(1.0), "policy was not invoked" assert finished.wait(1.0), "policy thread did not stop after invalid output" @@ -162,14 +195,78 @@ def test_invalid_policy_action_stops_without_publishing( def test_policy_refuses_to_start_without_live_observations( - make_module: Callable[[FakeBackend], tuple[LeRobotPolicyModule, CapturingOutput, Event]], + make_module: ModuleFactory, ) -> None: backend = FakeBackend(np.zeros(len(JOINTS), dtype=np.float32)) - module, output, _finished = make_module(backend) + module, output, _finished = make_module({"default": backend}) - result = module.execute_learned_policy(duration=1.0) + result = module.execute_learned_policy("default", duration=1.0) assert "no camera image" in result assert backend.reset_count == 0 assert output.messages == [] assert module.policy_status()["running"] is False + + +def test_named_policies_load_lazily_and_are_cached( + make_module: ModuleFactory, + mocker: pytest_mock.MockerFixture, +) -> None: + cup = FakeBackend(np.zeros(len(JOINTS), dtype=np.float32)) + plate = FakeBackend(np.zeros(len(JOINTS), dtype=np.float32)) + module, _output, _finished = make_module({"cup": cup, "plate": plate}) + loader = mocker.patch( + "dimos.learning.lerobot_policy._load_policy_backend", + side_effect=lambda _config, policy: { + "checkpoint/cup": cup, + "checkpoint/plate": plate, + }[policy.policy_path], + ) + _provide_observation(module) + + assert loader.call_count == 0 + assert "started" in module.execute_learned_policy("cup", duration=1.0).lower() + assert cup.called.wait(1.0) + module.stop_learned_policy() + assert "started" in module.execute_learned_policy("plate", duration=1.0).lower() + assert plate.called.wait(1.0) + module.stop_learned_policy() + assert "started" in module.execute_learned_policy("cup", duration=1.0).lower() + module.stop_learned_policy() + + assert loader.call_count == 2 + assert module.policy_status()["available_policies"] == ["cup", "plate"] + assert module.policy_status()["active_policy"] == "cup" + + +def test_named_skill_uses_its_own_tool_lifecycle( + make_module: ModuleFactory, +) -> None: + backend = FakeBackend(np.zeros(len(JOINTS), dtype=np.float32)) + module, _output, _finished = make_module({"pick_up_cup": backend}, module_type=CupPolicyModule) + assert isinstance(module, CupPolicyModule) + _provide_observation(module) + + skill_names = {skill_info.func_name for skill_info in module.get_skills()} + result = module.pick_up_cup() + + assert "pick_up_cup" in skill_names + assert "started" in result.lower() + assert backend.called.wait(1.0) + module.stop_learned_policy() + module.start_tool.assert_called_with("pick_up_cup") # type: ignore[attr-defined] + module.stop_tool.assert_any_call("pick_up_cup") # type: ignore[attr-defined] + + +def test_unknown_policy_is_rejected_without_loading( + make_module: ModuleFactory, +) -> None: + backend = FakeBackend(np.zeros(len(JOINTS), dtype=np.float32)) + module, output, _finished = make_module({"cup": backend}) + _provide_observation(module) + + result = module.execute_learned_policy("missing") + + assert "unknown learned policy" in result.lower() + assert backend.reset_count == 0 + assert output.messages == [] diff --git a/dimos/robot/manipulators/galaxea_a1z/README.md b/dimos/robot/manipulators/galaxea_a1z/README.md index 7f4dbb8bd2..d92ddfd702 100644 --- a/dimos/robot/manipulators/galaxea_a1z/README.md +++ b/dimos/robot/manipulators/galaxea_a1z/README.md @@ -148,3 +148,92 @@ uv run dimos a1z run-policy \ --task "pick up the object" \ --duration 20 ``` + +This command is the one-checkpoint hardware test path. It installs that +checkpoint in the policy catalog under the name `default`, starts the same +camera/coordinator/policy module stack used by a full blueprint, and invokes +`execute_learned_policy("default")`. + +## Turn trained policies into an agentic robot + +Once individual checkpoints pass `run-policy`, put them in one catalog and +give the behaviors stable, meaningful skill names. The complete blueprint can +stay small: + +```python +from dimos.agents.annotation import skill +from dimos.agents.capabilities import CAP_MOVEMENT +from dimos.agents.mcp.mcp_client import McpClient +from dimos.agents.mcp.mcp_server import McpServer +from dimos.core.coordination.blueprints import autoconnect +from dimos.learning.lerobot_policy import LeRobotPolicyConfig, LeRobotPolicyModule +from dimos.robot.manipulators.galaxea_a1z.blueprints.basic import ( + make_a1z_learned_policy_blueprint, +) + + +class HackathonPolicies(LeRobotPolicyModule): + @skill(uses=[CAP_MOVEMENT], lifecycle="background") + def pick_up_cup(self) -> str: + """Pick up the wooden cup from the table.""" + return self.start_configured_policy("pick_up_cup", tool_name="pick_up_cup") + + @skill(uses=[CAP_MOVEMENT], lifecycle="background") + def place_cup(self) -> str: + """Place the held wooden cup on the table.""" + return self.start_configured_policy("place_cup", tool_name="place_cup") + + +a1z_policies = make_a1z_learned_policy_blueprint( + policies={ + "pick_up_cup": LeRobotPolicyConfig( + policy_path="outputs/pick_up_cup/checkpoints/last/pretrained_model", + task="pick up the wooden cup", + device="mps", + default_duration=20.0, + ), + "place_cup": LeRobotPolicyConfig( + policy_path="outputs/place_cup/checkpoints/last/pretrained_model", + task="place the wooden cup on the table", + device="mps", + default_duration=20.0, + ), + }, + policy_module=HackathonPolicies, + camera_index=0, +) + +A1Z_AGENT_PROMPT = """You control a Galaxea A1Z manipulation arm. +Use the available learned manipulation skills to carry out the user's request. +Call only one movement skill at a time and report failures clearly. +""" + +a1z_learned_agent = autoconnect( + a1z_policies, + McpServer.blueprint(), + McpClient.blueprint(system_prompt=A1Z_AGENT_PROMPT), +) +``` + +Expose `a1z_learned_agent` as a runnable blueprint using the normal DimOS +blueprint registration process, then start it like any other stack: + +```bash +uv run dimos run a1z-learned-agent --daemon +uv run dimos humancli +``` + +The same running blueprint can be driven without the interactive terminal: + +```bash +uv run dimos agent-send "pick up the wooden cup, then place it back down" +uv run dimos mcp list-tools +uv run dimos mcp call pick_up_cup +``` + +The composition is now standard DimOS: the A1Z helper supplies the hardware, +servo coordinator, camera, and one multi-policy module; `McpServer` exposes the +named skills; and `McpClient` lets the language agent select and sequence those +skills. Adding a trained behavior means adding one catalog entry and one small +documented `@skill` wrapper. It does not require a new executor module or any +changes to DimOS core. diff --git a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py index e9833991ec..57a38c5c50 100644 --- a/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py +++ b/dimos/robot/manipulators/galaxea_a1z/blueprints/basic.py @@ -25,7 +25,7 @@ from dimos.hardware.sensors.camera.webcam import Webcam from dimos.learning.collection.episode_monitor import EpisodeMonitorModule from dimos.learning.collection.recorder import CollectionRecorder -from dimos.learning.lerobot_policy import LeRobotPolicyModule +from dimos.learning.lerobot_policy import LeRobotPolicyConfig, LeRobotPolicyModule from dimos.memory2.module import OnExisting from dimos.msgs.geometry_msgs.Transform import Transform from dimos.robot.manipulators.a1z.config import A1Z_G1Z_MODEL_PATH @@ -146,6 +146,32 @@ def make_a1z_policy_blueprint( fps: float = A1Z_TEACH_CAMERA_FPS, ) -> Blueprint: """Run one trained LeRobot policy against the live A1Z camera and state.""" + return make_a1z_learned_policy_blueprint( + policies={ + "default": LeRobotPolicyConfig( + policy_path=policy_path, + task=task, + device=device, + ) + }, + camera_index=camera_index, + fps=fps, + ) + + +def make_a1z_learned_policy_blueprint( + policies: dict[str, LeRobotPolicyConfig], + *, + policy_module: type[LeRobotPolicyModule] = LeRobotPolicyModule, + camera_index: int = 0, + fps: float = A1Z_TEACH_CAMERA_FPS, +) -> Blueprint: + """Compose an A1Z stack exposing a catalog of trained LeRobot policies. + + Pass a ``LeRobotPolicyModule`` subclass with named ``@skill`` methods to + expose task-specific tools to an agent. The default module instead exposes + the generic ``execute_learned_policy(policy_name, duration)`` skill. + """ hardware = galaxea_a1z_hardware( "arm", gripper=True, @@ -164,13 +190,11 @@ def make_a1z_policy_blueprint( ) ], ), - LeRobotPolicyModule.blueprint( - policy_path=policy_path, + policy_module.blueprint( + policies=policies, joint_names=hardware.all_joints, fps=fps, - task=task, robot_type="galaxea_a1z", - device=device, ), _a1z_camera(camera_index), ) diff --git a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py index add4cd7216..d4f77f90b2 100644 --- a/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py +++ b/dimos/robot/manipulators/galaxea_a1z/teach_replay_cli.py @@ -409,7 +409,7 @@ def run_policy( raise RuntimeError( f"live policy observations did not become ready: {status['observation_error']}" ) - result = policy.execute_learned_policy(duration, task) + result = policy.execute_learned_policy("default", duration) typer.echo(result) if "started" not in result.lower(): raise RuntimeError(result) diff --git a/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py b/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py index 25cd4ef712..9ef73c8180 100644 --- a/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py +++ b/dimos/robot/manipulators/galaxea_a1z/test_teach_replay.py @@ -25,16 +25,23 @@ from dimos.learning.collection.episode_monitor import EpisodeStatus from dimos.learning.collection.recorder import CollectionRecorder from dimos.learning.dataprep.core import Episode -from dimos.learning.lerobot_policy import LeRobotPolicyModule +from dimos.learning.lerobot_policy import LeRobotPolicyConfig, LeRobotPolicyModule from dimos.memory2.store.sqlite import SqliteStore from dimos.msgs.sensor_msgs.JointState import JointState from dimos.robot.manipulators.galaxea_a1z.blueprints.basic import ( A1Z_TEACH_CAMERA_FPS, A1Z_TEACH_CAMERA_HEIGHT, A1Z_TEACH_CAMERA_WIDTH, + make_a1z_learned_policy_blueprint, make_a1z_policy_blueprint, make_a1z_teach_blueprint, ) + + +class CustomPolicyModule(LeRobotPolicyModule): + pass + + from dimos.robot.manipulators.galaxea_a1z.teach_replay import ( _REPLAY_VELOCITY_MAX, A1Z_JOINT_NAMES, @@ -97,14 +104,31 @@ def test_policy_blueprint_wires_camera_policy_and_seven_joint_servo() -> None: servo = control_atom.kwargs["tasks"][0] assert camera.config.camera_index == 2 - assert policy_atom.kwargs["policy_path"] == "checkpoints/a1z" + policy = policy_atom.kwargs["policies"]["default"] + assert policy.policy_path == "checkpoints/a1z" assert policy_atom.kwargs["joint_names"] == hardware.all_joints == list(A1Z_JOINT_NAMES) - assert policy_atom.kwargs["task"] == "pick up cube" - assert policy_atom.kwargs["device"] == "cuda" + assert policy.task == "pick up cube" + assert policy.device == "cuda" assert servo.type == "servo" assert servo.joint_names == list(A1Z_JOINT_NAMES) +def test_multi_policy_blueprint_uses_custom_skill_module() -> None: + policies = { + "pick_up_cup": LeRobotPolicyConfig(policy_path="checkpoints/cup"), + "place_cup": LeRobotPolicyConfig(policy_path="checkpoints/place"), + } + + blueprint = make_a1z_learned_policy_blueprint( + policies, + policy_module=CustomPolicyModule, + ) + + policy_atom = next(atom for atom in blueprint.blueprints if atom.module is CustomPolicyModule) + assert policy_atom.kwargs["policies"] == policies + assert policy_atom.kwargs["robot_type"] == "galaxea_a1z" + + def test_loads_saved_memory2_episode_and_orders_joints(tmp_path: Path) -> None: path = tmp_path / "teach.db" store = SqliteStore(path=path) From e20b65767c3d15d0b574c9883d08618cc8b494db Mon Sep 17 00:00:00 2001 From: ruthwikdasyam <63036454+ruthwikdasyam@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:50:38 -0700 Subject: [PATCH 33/51] fix(unitree): marshal stop_movement zero-twist onto the loop thread (#2977) --- dimos/robot/unitree/connection.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/dimos/robot/unitree/connection.py b/dimos/robot/unitree/connection.py index 39b8f707f2..1ab507c4ae 100644 --- a/dimos/robot/unitree/connection.py +++ b/dimos/robot/unitree/connection.py @@ -471,11 +471,21 @@ def get_video_stream(self, fps: int = 30) -> Observable[Image]: return self.video_stream() def stop_movement(self) -> None: - """Cancel the auto-stop timer (used by move() for continuous commands).""" + """Halt the base: publish a zero twist and cancel the auto-stop timer.""" if self.stop_timer: self.stop_timer.cancel() self.stop_timer = None + async def async_stop() -> None: + self._publish_movement(0, 0, 0) + + if not self.loop.is_running(): + return + try: + asyncio.run_coroutine_threadsafe(async_stop(), self.loop).result(timeout=1.0) + except Exception as e: + logger.warning("Failed to publish stop twist: %s", e) + def disconnect(self) -> None: """Disconnect from the robot and clean up resources.""" # Cancel timer From 61c22953e33c18fa30409d9adea7d1eeaf5c52ce Mon Sep 17 00:00:00 2001 From: Nabla7 Date: Tue, 21 Jul 2026 07:07:35 -0400 Subject: [PATCH 34/51] Revert "fix(unitree): marshal stop_movement zero-twist onto the loop thread (#2977)" This reverts commit e20b65767c3d15d0b574c9883d08618cc8b494db. --- dimos/robot/unitree/connection.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/dimos/robot/unitree/connection.py b/dimos/robot/unitree/connection.py index 1ab507c4ae..39b8f707f2 100644 --- a/dimos/robot/unitree/connection.py +++ b/dimos/robot/unitree/connection.py @@ -471,21 +471,11 @@ def get_video_stream(self, fps: int = 30) -> Observable[Image]: return self.video_stream() def stop_movement(self) -> None: - """Halt the base: publish a zero twist and cancel the auto-stop timer.""" + """Cancel the auto-stop timer (used by move() for continuous commands).""" if self.stop_timer: self.stop_timer.cancel() self.stop_timer = None - async def async_stop() -> None: - self._publish_movement(0, 0, 0) - - if not self.loop.is_running(): - return - try: - asyncio.run_coroutine_threadsafe(async_stop(), self.loop).result(timeout=1.0) - except Exception as e: - logger.warning("Failed to publish stop twist: %s", e) - def disconnect(self) -> None: """Disconnect from the robot and clean up resources.""" # Cancel timer From a951ec06fd82351ff0778c1c443322265b73c12c Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Mon, 20 Jul 2026 23:16:46 -0700 Subject: [PATCH 35/51] fix(zenoh): isolate sessions to loopback by default Add local_only (default true) so multicast scouting is pinned to the loopback interface and listen endpoints stay on localhost. Prevents two machines on the same LAN from discovering each other and fighting over shared topic names. --- dimos/protocol/service/zenohservice.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/dimos/protocol/service/zenohservice.py b/dimos/protocol/service/zenohservice.py index 2cc491f9ae..f09163c8f8 100644 --- a/dimos/protocol/service/zenohservice.py +++ b/dimos/protocol/service/zenohservice.py @@ -15,6 +15,7 @@ from __future__ import annotations import json +import sys import threading from typing import Any @@ -27,15 +28,19 @@ logger = setup_logger() +LOOPBACK_INTERFACE = "lo0" if sys.platform == "darwin" else "lo" +LOCALHOST_LISTEN_ENDPOINT = "tcp/127.0.0.1:0" + class ZenohConfig(BaseConfig): mode: str = "peer" connect: list[str] = [] listen: list[str] = [] + local_only: bool = True @property def session_key(self) -> str: - return f"{self.mode}|{json.dumps(sorted(self.connect))}|{json.dumps(sorted(self.listen))}" + return f"{self.mode}|{self.local_only}|{json.dumps(sorted(self.connect))}|{json.dumps(sorted(self.listen))}" class ZenohSessionPool: @@ -50,12 +55,19 @@ def acquire(self, config: ZenohConfig) -> zenoh.Session: if key not in self._sessions: zconfig = zenoh.Config() zconfig.insert_json5("mode", json.dumps(config.mode)) + if config.local_only: + zconfig.insert_json5( + "scouting/multicast/interface", json.dumps(LOOPBACK_INTERFACE) + ) + listen = config.listen or ([LOCALHOST_LISTEN_ENDPOINT] if config.local_only else []) if config.connect: zconfig.insert_json5("connect/endpoints", json.dumps(config.connect)) - if config.listen: - zconfig.insert_json5("listen/endpoints", json.dumps(config.listen)) + if listen: + zconfig.insert_json5("listen/endpoints", json.dumps(listen)) self._sessions[key] = zenoh.open(zconfig) - logger.debug(f"Zenoh session opened in {config.mode} mode") + logger.debug( + f"Zenoh session opened in {config.mode} mode (local_only={config.local_only})" + ) return self._sessions[key] def close_all(self) -> None: From 450663b81038899d7cd9111b95851d941f10ddaa Mon Sep 17 00:00:00 2001 From: Nabla7 Date: Tue, 21 Jul 2026 07:15:01 -0400 Subject: [PATCH 36/51] Regenerate blueprint registry after main merge --- dimos/robot/all_blueprints.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dimos/robot/all_blueprints.py b/dimos/robot/all_blueprints.py index 32ff908ff5..40745e39c3 100644 --- a/dimos/robot/all_blueprints.py +++ b/dimos/robot/all_blueprints.py @@ -209,6 +209,7 @@ "joystick-module": "dimos.robot.unitree.b1.joystick_module.JoystickModule", "keyboard-teleop": "dimos.robot.unitree.keyboard_teleop.KeyboardTeleop", "keyboard-teleop-module": "dimos.teleop.keyboard.keyboard_teleop_module.KeyboardTeleopModule", + "le-robot-policy-module": "dimos.learning.lerobot_policy.LeRobotPolicyModule", "local-planner": "dimos.navigation.cmu_nav.modules.local_planner.local_planner.LocalPlanner", "manipulation-module": "dimos.manipulation.manipulation_module.ManipulationModule", "map": "dimos.robot.unitree.type.map.Map", From da5e540a2e1c2da1d5d830ceddf8dd4dbc6425e4 Mon Sep 17 00:00:00 2001 From: Nabla7 Date: Tue, 21 Jul 2026 07:49:59 -0400 Subject: [PATCH 37/51] Consolidate A1Z learning documentation --- dimos/learning/README.md | 66 +------------------ .../robot/manipulators/galaxea_a1z/README.md | 10 ++- 2 files changed, 9 insertions(+), 67 deletions(-) diff --git a/dimos/learning/README.md b/dimos/learning/README.md index 80b52e4572..be40ff2d9b 100644 --- a/dimos/learning/README.md +++ b/dimos/learning/README.md @@ -4,7 +4,7 @@ End-to-end: teleoperate an arm, record episodes to a session DB, then convert that DB into a LeRobot or HDF5 dataset for imitation learning. ``` -teleop / hand-teach ─▶ CollectionRecorder ─▶ session.db ─▶ dimos dataprep ─▶ dataset +teleop (Quest) ─▶ CollectionRecorder ─▶ session__.db ─▶ dimos dataprep ─▶ dataset ``` --- @@ -119,67 +119,3 @@ working example. The fields that matter: true commanded actions you'd record `joint_command` and map `action` to it. - **Old vs new sessions** — recordings made before the `coordinator_joint_state` rename use the old stream name; point a matching config at them, or re-record. - ---- - -## 4. Train and execute a policy - -Install the optional LeRobot integration. DimOS pins LeRobot 0.4.4 because it -is the newest release compatible with the repository's Transformers 4.53 -stack; both the trainer and live module use LeRobot's upstream policy and -processor APIs. - -```bash -uv sync --extra lerobot -``` - -Train ACT directly from a local DataPrep output directory: - -```bash -uv run lerobot-train \ - --dataset.repo_id=galaxea_a1z \ - --dataset.root=./a1z_lerobot_dataset \ - --policy.type=act \ - --policy.device=cuda \ - --policy.push_to_hub=false \ - --output_dir=outputs/a1z_act \ - --job_name=a1z_act \ - --wandb.enable=false -``` - -The deployable checkpoint is written under -`outputs/a1z_act/checkpoints/last/pretrained_model`. ACT is the currently -tested A1Z path, but the runtime loads policies through LeRobot's policy -factory rather than hard-coding ACT. Another LeRobot policy type can be used -when its saved configuration has the same single RGB image input, seven-value -state input, and seven-value action output. - -`LeRobotPolicyModule` owns a named catalog of checkpoints. It loads each -checkpoint on first use and caches it, so one running robot stack can execute -several trained behaviors without restarting. The generic -`execute_learned_policy(policy_name, duration)` skill is useful for testing. -For an agent-facing robot, expose each behavior as a normal, descriptive -DimOS skill instead: - -```python -from dimos.agents.annotation import skill -from dimos.agents.capabilities import CAP_MOVEMENT -from dimos.learning.lerobot_policy import LeRobotPolicyModule - - -class HackathonPolicies(LeRobotPolicyModule): - @skill(uses=[CAP_MOVEMENT], lifecycle="background") - def pick_up_cup(self) -> str: - """Pick up the wooden cup from the table.""" - return self.start_configured_policy("pick_up_cup", tool_name="pick_up_cup") - - @skill(uses=[CAP_MOVEMENT], lifecycle="background") - def place_cup(self) -> str: - """Place the held wooden cup on the table.""" - return self.start_configured_policy("place_cup", tool_name="place_cup") -``` - -These wrappers are deliberately ordinary `@skill` methods. Their names, -docstrings, movement-capability locking, background progress, and stop events -therefore pass through the existing DimOS agent and MCP machinery unchanged. -The shared parent module only selects and executes checkpoints. diff --git a/dimos/robot/manipulators/galaxea_a1z/README.md b/dimos/robot/manipulators/galaxea_a1z/README.md index d92ddfd702..22ce8ea5ab 100644 --- a/dimos/robot/manipulators/galaxea_a1z/README.md +++ b/dimos/robot/manipulators/galaxea_a1z/README.md @@ -136,6 +136,11 @@ uv run lerobot-train \ Use `--policy.device=cuda` on an NVIDIA training host. Apple-silicon Macs use `mps`; CPU-only hosts can use `cpu` for a slow smoke test. +ACT is the tested A1Z policy type, but the runtime uses LeRobot's policy +factory rather than hard-coding ACT. Another LeRobot policy type can be used +when its checkpoint exposes the same single RGB image input, seven-value state +input, and seven-value action output. + After the host setup passes, run the trained policy. Loading and hardware initialization require confirmation, and inference starts only after live RGB and seven-joint observations are ready: @@ -157,8 +162,9 @@ camera/coordinator/policy module stack used by a full blueprint, and invokes ## Turn trained policies into an agentic robot Once individual checkpoints pass `run-policy`, put them in one catalog and -give the behaviors stable, meaningful skill names. The complete blueprint can -stay small: +give the behaviors stable, meaningful skill names. Checkpoints are loaded on +first use and cached, so the running robot can execute several trained +behaviors without restarting. The complete blueprint can stay small: ```python from dimos.agents.annotation import skill From 413b7712a86841c345557f7811ac7246e9097406 Mon Sep 17 00:00:00 2001 From: Nabla7 Date: Tue, 21 Jul 2026 08:12:17 -0400 Subject: [PATCH 38/51] Revert "fix(zenoh): isolate sessions to loopback by default" This reverts commit a951ec06fd82351ff0778c1c443322265b73c12c. --- dimos/protocol/service/zenohservice.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/dimos/protocol/service/zenohservice.py b/dimos/protocol/service/zenohservice.py index f09163c8f8..2cc491f9ae 100644 --- a/dimos/protocol/service/zenohservice.py +++ b/dimos/protocol/service/zenohservice.py @@ -15,7 +15,6 @@ from __future__ import annotations import json -import sys import threading from typing import Any @@ -28,19 +27,15 @@ logger = setup_logger() -LOOPBACK_INTERFACE = "lo0" if sys.platform == "darwin" else "lo" -LOCALHOST_LISTEN_ENDPOINT = "tcp/127.0.0.1:0" - class ZenohConfig(BaseConfig): mode: str = "peer" connect: list[str] = [] listen: list[str] = [] - local_only: bool = True @property def session_key(self) -> str: - return f"{self.mode}|{self.local_only}|{json.dumps(sorted(self.connect))}|{json.dumps(sorted(self.listen))}" + return f"{self.mode}|{json.dumps(sorted(self.connect))}|{json.dumps(sorted(self.listen))}" class ZenohSessionPool: @@ -55,19 +50,12 @@ def acquire(self, config: ZenohConfig) -> zenoh.Session: if key not in self._sessions: zconfig = zenoh.Config() zconfig.insert_json5("mode", json.dumps(config.mode)) - if config.local_only: - zconfig.insert_json5( - "scouting/multicast/interface", json.dumps(LOOPBACK_INTERFACE) - ) - listen = config.listen or ([LOCALHOST_LISTEN_ENDPOINT] if config.local_only else []) if config.connect: zconfig.insert_json5("connect/endpoints", json.dumps(config.connect)) - if listen: - zconfig.insert_json5("listen/endpoints", json.dumps(listen)) + if config.listen: + zconfig.insert_json5("listen/endpoints", json.dumps(config.listen)) self._sessions[key] = zenoh.open(zconfig) - logger.debug( - f"Zenoh session opened in {config.mode} mode (local_only={config.local_only})" - ) + logger.debug(f"Zenoh session opened in {config.mode} mode") return self._sessions[key] def close_all(self) -> None: From 2537596bb20717b59d1b10e7bfb772532a527b12 Mon Sep 17 00:00:00 2001 From: mustafab0 Date: Mon, 20 Jul 2026 23:08:50 -0700 Subject: [PATCH 39/51] =?UTF-8?q?docs(manipulation):=20tell=20the=20real?= =?UTF-8?q?=20story=20=E2=80=94=20planning,=20learning,=20platforms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the manipulation docs: a new developer cannot tell what DimOS offers for manipulation or how to build an application with it. The index was a feature braindump leading with keyboard teleop; the learning stack (the meat) was absent from Mintlify entirely; per-arm integration write-ups sat in the capability section. Restructured around what a developer wants to do: - index.md — rewritten. What the stack is, a choose-your-workflow table, the three layers, and why the planning layer is where your time goes. Two-minute quickstart against the mock arm. - planning.md (new) — the planning stack: how a pose target becomes motion, the plan/preview/execute API, backend selection, and how to plug in your own planner or IK solver (protocol + factory, no registry). Ends with where learned policies fit: a policy replaces the planner, feeding a servo task instead of a trajectory task. - learning.md (new) — the full loop on one page: demonstrate (Quest VR collect or A1Z hand-teach) → session.db → dataprep (LeRobot v3/HDF5) → lerobot-train → run the policy on the robot. Every command verified against this branch, including the new a1z teach/replay/run-policy CLI and undo-last-episode. - a750.md and openarm_integration.md moved to platforms/arms/ — they are platform setup docs, not capability docs. Redirects added. VR teleop and data collection are now first-class in the manipulation docs (learning.md) instead of hidden in dimos/teleop/quest/README.md. --- docs/capabilities/manipulation/index.md | 290 +++--------------- docs/capabilities/manipulation/learning.md | 139 +++++++++ docs/capabilities/manipulation/planning.md | 112 +++++++ docs/docs.json | 33 +- .../manipulation => platforms/arms}/a750.md | 0 .../arms/openarm.md} | 0 6 files changed, 317 insertions(+), 257 deletions(-) create mode 100644 docs/capabilities/manipulation/learning.md create mode 100644 docs/capabilities/manipulation/planning.md rename docs/{capabilities/manipulation => platforms/arms}/a750.md (100%) rename docs/{capabilities/manipulation/openarm_integration.md => platforms/arms/openarm.md} (100%) diff --git a/docs/capabilities/manipulation/index.md b/docs/capabilities/manipulation/index.md index c4f715aa17..8fa4497409 100644 --- a/docs/capabilities/manipulation/index.md +++ b/docs/capabilities/manipulation/index.md @@ -1,276 +1,62 @@ --- title: "Manipulation" +description: "Plan collision-free motion, teleoperate, collect demonstrations, and train policies — on any supported arm." --- -Motion planning and teleoperation for robotic manipulators. Drake remains the default -world backend, RoboPlan is available as an optional planning backend, and -manipulation visualization supports Meshcat or Viser. +DimOS manipulation gets a robot arm from "plugged in" to "doing useful work". You can plan collision-free motion, teleoperate with a VR headset or keyboard, record demonstrations, and train a policy that runs back on the same robot. -## Quick Start +## Choose your workflow -Recent addition: the A-750 keyboard teleop blueprint is now available via: +| I want to... | Start here | +|--------------|-----------| +| **Move an arm right now** | `dimos run keyboard-teleop-xarm7` — no hardware needed, runs against a mock arm | +| **Plan collision-free motion** | [The planning stack](/docs/capabilities/manipulation/planning.md) | +| **Collect demos and train a policy** | [The learning loop](/docs/capabilities/manipulation/learning.md) | +| **Let an agent compose manipulation skills** | [Agentic xArm simulation](/docs/capabilities/manipulation/agentic.md) | +| **Connect my own arm** | [Adding a custom arm](/docs/capabilities/manipulation/adding_a_custom_arm.md) | -```bash -dimos run keyboard-teleop-a750 -``` - -### Keyboard Teleop (single command) +## How the stack fits together -Each blueprint launches the full stack — keyboard UI, mock controller, IK solver, and Drake visualization: +Three layers, top to bottom: -```bash -dimos run keyboard-teleop-a750 # A-750 6-DOF -dimos run keyboard-teleop-piper # Piper 6-DOF -dimos run keyboard-teleop-xarm6 # XArm6 6-DOF -dimos run keyboard-teleop-xarm7 # XArm7 7-DOF ``` - -Open the Meshcat URL printed in the terminal (default `http://localhost:7000`) to see the robot. - -Keyboard controls: - -| Key | Action | -|-----|--------| -| W/S | +X/-X (forward/back) | -| A/D | +Y/-Y (left/right) | -| Q/E | +Z/-Z (up/down) | -| R/F | +Roll/-Roll | -| T/G | +Pitch/-Pitch | -| Y/H | +Yaw/-Yaw | -| ESC | Quit | - -### Motion Planning (two terminals) - -```bash -# Terminal 1: Mock coordinator -dimos run coordinator-mock - -# Terminal 2: Planner with Drake visualization -dimos run xarm7-planner-coordinator +ManipulationModule "move the gripper to this pose" + planning + kinematics plans a collision-free joint path (RRT over a + Drake world), solving IK along the way + │ trajectory, via RPC + ▼ +ControlCoordinator 100Hz loop. Runs a trajectory task that streams + trajectory execution the plan, arbitrates who owns which joint + │ joint commands + ▼ +Hardware adapter one small class per arm; wraps the vendor SDK ``` -Pink IK is the default solver. Tune it with nested module config overrides: +**The planning layer is where you should spend your time.** It is where "do the task" turns into motion — and it is deliberately pluggable. The planner, the IK solver, and the world model are all protocol-based: you can swap any of them, or bypass planning entirely and drive the coordinator from a learned policy. The layers below rarely need touching once your arm is integrated. -```bash -dimos run xarm7-planner-coordinator \ - -o manipulationmodule.kinematics.backend=pink \ - -o manipulationmodule.kinematics.max_iterations=100 \ - -o manipulationmodule.kinematics.dt=0.02 -``` +Teleop slots in beside planning, not above it: a Quest headset or keyboard streams end-effector targets to the coordinator, which solves IK in-loop. That path is what makes demonstration collection feel direct. -For blueprints that instantiate `PickAndPlaceModule`, use the corresponding -module prefix: +## What runs today -```bash -dimos run xarm-perception-sim \ - -o pickandplacemodule.kinematics.backend=pink -``` +| Arm | Teleop | Planning | Perception | Learning | +|-----|--------|----------|------------|----------| +| XArm 6 / 7 | keyboard, VR | ✓ | ✓ (RealSense) | VR collect | +| Piper | keyboard, VR | ✓ | — | VR collect | +| Galaxea A1Z | hand-teach | — | UVC camera | hand-teach → policy | +| A-750 | keyboard | ✓ | — | — | +| OpenArm (bimanual) | keyboard | ✓ | — | — | -Then use the IPython client: +Several workflows also run without hardware through a mock arm or MuJoCo, including the quick start below. A1Z hand-teach and learned-policy execution require the real arm. -```bash -python -m dimos.manipulation.planning.examples.manipulation_client -``` - -```python skip -joints() # Get current joints -plan([0.1] * 7) # Plan to target -preview() # Preview in Meshcat -execute() # Execute via coordinator -``` +Per-arm setup lives with the platform, not here: [A-750](/docs/platforms/arms/a750.md), [OpenArm](/docs/platforms/arms/openarm.md), and the [Galaxea A1Z hardware and learning guide](/dimos/robot/manipulators/galaxea_a1z/README.md). -### Planning backend selection - -Manipulation planning separates the world backend from the planner algorithm: - -- `world_backend` selects the robot/world/collision representation. -- `planner_name` selects the path-planning algorithm. -- `kinematics.backend` selects the IK backend. The legacy `kinematics_name` - field remains available as a compatibility shim. - -Drake remains the default: - -```bash -dimos run xarm7-planner-coordinator -``` - -RoboPlan is available as an optional backend for evaluating a non-Drake world -implementation. Select it explicitly with module options: - -```bash -dimos run xarm7-planner-coordinator \ - -o manipulationmodule.world_backend=roboplan \ - -o manipulationmodule.planner_name=rrt_connect -``` - -Valid combinations: - -| `world_backend` | `planner_name` | `kinematics.backend` | Status | -|-----------------|----------------|-------------------|--------| -| `drake` | `rrt_connect` | `pink` | Default path | -| `drake` | `rrt_connect` | `jacobian` | Legacy Jacobian IK | -| `drake` | `rrt_connect` | `drake_optimization` | Drake-only IK | -| `roboplan` | `rrt_connect` | `pink` or `jacobian` | Generic RRT over RoboPlan collision checks | -| `roboplan` | `roboplan` | `pink` or `jacobian` | RoboPlan-native planner, using the RoboPlan world object | - -Invalid combinations fail during startup instead of waiting for the first plan -request. For example, `planner_name=roboplan` requires -`world_backend=roboplan`, and `kinematics.backend=drake_optimization` requires -`world_backend=drake`. - -Install the manipulation dependencies: +## Try it in two minutes ```bash uv sync --extra manipulation --inexact +dimos run keyboard-teleop-xarm7 ``` -The `manipulation` extra includes RoboPlan via `roboplan` from PyPI. -The `--inexact` flag preserves other extras already installed in your current -environment. - -Safety behavior for unsupported RoboPlan features: - -- Planning-critical unsupported inputs fail loudly before planning. Examples - include unsupported obstacle geometry, unavailable robot loading APIs, or - unavailable collision query APIs. RoboPlan worlds generate a minimal SRDF from - the DimOS robot config, including configured collision-exclusion pairs. -- Unverified non-critical query methods raise explicit `NotImplementedError`. - In particular, signed minimum-distance semantics are not implemented for - RoboPlan until a safe equivalent is verified. -- Embedded Meshcat visualization requires a world implementing `VisualizationSpec`; - use Viser or `none` with the RoboPlan backend. - -### Planning Visualization - -Manipulation visualization is configured on `ManipulationModuleConfig.visualization`. -It is independent from the global Rerun stream viewer in `docs/usage/visualization.md`. - -Backend choices: - -- `meshcat`: embedded Drake/Meshcat visualizer. The planning world must be created with - embedded visualization enabled, so this is selected through the visualization config. -- `viser`: in-process Viser visualizer. It renders current robot state, target controls, - transient preview ghosts, planned path previews, and optional panel controls. -- `none`: no manipulation planning visualization. - -CLI example: - -```bash -uv run dimos run xarm7-planner-coordinator \ - -o manipulationmodule.visualization.backend=viser -``` - -Blueprint example: - -```python skip -from dimos.manipulation.manipulation_module import ManipulationModule, ManipulationModuleConfig - -manipulation = ManipulationModule.blueprint( - config=ManipulationModuleConfig( - robots=[...], - visualization={ - "backend": "viser", - "host": "127.0.0.1", - "port": 8095, - "open_browser": True, - "panel_enabled": True, # default; set False for scene-only Viser - }, - ) -) -``` - -Viser support is included in the `manipulation` extra: - -```bash -uv sync --extra manipulation --inexact -``` - -The Viser panel uses existing manipulation planning, preview, execute, cancel, and clear-plan -RPC methods through a small in-process adapter. GUI callbacks enqueue operations instead of -touching `WorldSpec`, IK, planner objects, or live Drake contexts directly. Rendering copies -mutable joint state/path containers at the read boundary, then updates the Viser scene after -manipulation/world accessors have returned. - -External manipulation visualizers are initialized from a backend-neutral planning-scene snapshot -after the planning world has added its robots. This snapshot maps world robot IDs to -`RobotModelConfig` metadata so Viser can prepare current, target, and transient preview robot -visuals without `WorldMonitor` depending on Viser-specific hooks. Embedded Meshcat visualization -does not need extra setup because it observes the Drake world directly. - -When the Viser panel is enabled, it can call the existing manipulation execution path after a -fresh feasible plan is available and the current robot joints still match the plan start. - -### Perception + Agent - -```bash -# Coordinator + perception + manipulation + LLM agent (single command) -XARM7_IP= dimos run coordinator-xarm7 xarm-perception-agent -``` - -For a simulation walkthrough, see [Agentic xArm simulation](/docs/capabilities/manipulation/agentic.md). - -## Architecture - -``` -KeyboardTeleopModule ──→ ControlCoordinator ──→ ManipulationModule - (pygame UI) (100Hz tick loop) (WorldSpec backend) - │ │ │ - TwistStamped EEFTwistTask RRT planner - spatial EEF twist (Pinocchio FK/IK) JacobianIK - │ DrakeWorld - JointState ────────────→ (visualization) -``` - -- **KeyboardTeleopModule** — Pygame UI publishing routed spatial EEF twist intent -- **ControlCoordinator** — 100Hz control loop with mock or real hardware adapters -- **ManipulationModule** — world backend, optional visualization, RRT motion planning, obstacle management - -Internally, planning code depends on `WorldSpec` for world, collision, and -kinematics behavior. Meshcat preview and publishing are exposed separately -through `VisualizationSpec`, so non-visual planning paths do not require a -visualization backend. - -## Blueprints - -| Blueprint | Description | -|-----------|-------------| -| `keyboard-teleop-a750` | A750 6-DOF keyboard teleop with Drake viz | -| `keyboard-teleop-piper` | Piper 6-DOF keyboard teleop with Drake viz | -| `keyboard-teleop-xarm6` | XArm6 6-DOF keyboard teleop with Drake viz | -| `keyboard-teleop-xarm7` | XArm7 7-DOF keyboard teleop with Drake viz | -| `xarm6-planner-only` | XArm6 standalone planner (no coordinator) | -| `xarm7-planner-coordinator` | XArm7 planner with coordinator integration | -| `dual-xarm6-planner` | Dual XArm6 planning | -| `xarm-perception` | XArm7 + RealSense camera for perception | -| `xarm-perception-agent` | XArm7 perception + LLM agent | -| `xarm-perception-sim` | XArm7 simulation perception stack | -| [`xarm-perception-sim-agent`](/docs/capabilities/manipulation/agentic.md) | XArm7 simulation perception stack + LLM agent | - -## Supported Robots - -| Robot | DOF | Teleop | Planning | Perception | -|-------|-----|--------|----------|------------| -| [A-750](/docs/capabilities/manipulation/a750.md) | 6 | Y | Y | — | -| Piper | 6 | Y | Y | — | -| XArm6 | 6 | Y | Y | — | -| XArm7 | 7 | Y | Y | Y | - -## Adding a Custom Arm - -[guide is here](/docs/capabilities/manipulation/adding_a_custom_arm.md) - -## Key Files +A Meshcat window opens with the arm. Drive the end-effector with `W/A/S/D` (XY), `Q/E` (Z), `R/F/T/G/Y/H` (roll/pitch/yaw). Everything you see — the IK, the 100Hz loop, the visualization — is the same stack that runs on real hardware; only the adapter is fake. -| File | Description | -|------|-------------| -| [`manipulation_module.py`](/dimos/manipulation/manipulation_module.py) | Main module (RPC interface, state machine) | -| [`robot/manipulators/common/blueprints.py`](/dimos/robot/manipulators/common/blueprints.py) | Shared coordinator, planner, and task helpers | -| [`robot/manipulators/a750/config.py`](/dimos/robot/manipulators/a750/config.py) | A-750 model and hardware config | -| [`robot/manipulators/a750/blueprints/teleop.py`](/dimos/robot/manipulators/a750/blueprints/teleop.py) | A-750 keyboard teleop blueprint | -| [`robot/manipulators/piper/blueprints/basic.py`](/dimos/robot/manipulators/piper/blueprints/basic.py) | Piper coordinator blueprint | -| [`robot/manipulators/piper/blueprints/teleop.py`](/dimos/robot/manipulators/piper/blueprints/teleop.py) | Piper teleop blueprints | -| [`robot/manipulators/xarm/blueprints/basic.py`](/dimos/robot/manipulators/xarm/blueprints/basic.py) | XArm coordinator and planner blueprints | -| [`robot/manipulators/xarm/blueprints/perception.py`](/dimos/robot/manipulators/xarm/blueprints/perception.py) | XArm perception blueprint | -| [`teleop/keyboard/keyboard_teleop_module.py`](/dimos/teleop/keyboard/keyboard_teleop_module.py) | Keyboard teleop module | -| [`planning/world/drake_world.py`](/dimos/manipulation/planning/world/drake_world.py) | Drake physics backend | -| [`planning/planners/rrt_planner.py`](/dimos/manipulation/planning/planners/rrt_planner.py) | RRT-Connect motion planner | +Then pick your workflow from the table above. diff --git a/docs/capabilities/manipulation/learning.md b/docs/capabilities/manipulation/learning.md new file mode 100644 index 0000000000..33fc6bd5d6 --- /dev/null +++ b/docs/capabilities/manipulation/learning.md @@ -0,0 +1,139 @@ +--- +title: "The Learning Loop" +description: "Collect demonstrations, build a dataset, train a policy with LeRobot, and run it back on the robot." +--- + +This is the full loop, on one page: + +``` +demonstrate ──▶ session.db ──▶ dataset ──▶ train ──▶ run on the robot + (VR / teach) (recorded) (LeRobot (ACT) (policy module) + or HDF5) +``` + +Each arrow is one command. By the end you will have taught a robot a task by showing it, not programming it. + +## 1. Demonstrate + +Two ways to produce demonstrations. Both record the same thing — camera frames, joint states, and episode markers — into a timestamped session database. + +### With a VR headset + +Best when you have a Quest 3. You see what you are doing, and the arm tracks your hand. + +```bash +dimos --simulation run learning-collect-quest-xarm7 # MuJoCo, no hardware +dimos run learning-collect-quest-piper # real Piper + RealSense +``` + +Open `https://:8443/teleop` in the Quest browser, accept the certificate, tap Connect. Then: + +| Button | Action | +|--------|--------| +| **A** (hold) | Engage — the arm tracks your controller only while held | +| **B** | Start recording / save the episode | +| **Y** | Discard the episode in progress | + +A take is: hold **A**, move into place, press **B**, do the task, press **B** again. The terminal confirms every save: + +``` +[collect] ▶ RECORDING episode (state=recording saved=0 discarded=0) +[collect] ✓ SAVED episode (state=idle saved=1 discarded=0) +``` + +Save each good take with **B** before quitting — an episode still recording at shutdown is dropped. + +### By hand-teaching + +Best for arms you can move by hand. The Galaxea A1Z runs gravity compensation while you guide it through the task: + +```bash +uv run dimos a1z teach --camera-index 0 --task "pick up the object" +``` + +`SPACE` starts an episode and saves it, `g` toggles the gripper, `d` discards — and pressing `d` between episodes undoes the last save. Real hardware only for now; there is no A1Z simulator yet. Replay any episode to check what you captured: + +```bash +uv run dimos a1z replay ~/.local/state/dimos/recordings/a1z_teach_.db +``` + +### What you end up with + +Either way, the recorder prints the path of a session database: + +``` +~/.local/state/dimos/recordings/session_xarm7_20260622_120000.db +``` + +One file per run, never overwritten. It holds three streams: `color_image`, `coordinator_joint_state`, and the episode start/save/discard markers. Record enough successful episodes to cover the variation the policy will encounter, including camera pose, lighting, object pose, and motion timing. + +## 2. Build a dataset + +DataPrep reads the session database, aligns camera and joint streams onto one clock, splits at the episode markers, and writes a training dataset: + +```bash +dimos dataprep build \ + --source ~/.local/state/dimos/recordings/session_xarm7_20260622_120000.db \ + --config dimos/learning/dataprep/example_config.json +``` + +The config maps recorded streams to dataset features — which stream is the observation image, which is the state, what rate to resample at. Copy `example_config.json` and adjust; the A1Z ships its own at `dataprep/galaxea_a1z_state_config.json`. By default the **action** for each frame is the *next* frame's measured joint state, which is what next-state behavioral cloning expects. + +Output is a LeRobot v3 dataset by default; pass `-f hdf5` for HDF5. Check what you built: + +```bash +dimos dataprep inspect data/datasets/session +``` + +You get features, shapes, dtypes, and episode counts — worth a look before spending GPU hours. Each dataset also carries a `dimos_meta.json` recording exactly how it was built. + +## 3. Train + +Training happens in [LeRobot](https://github.com/huggingface/lerobot), pointed straight at your DataPrep output. No upload step, no format shuffling: + +```bash +uv sync --extra lerobot # once + +uv run lerobot-train \ + --dataset.repo_id=my_task \ + --dataset.root=./data/datasets/session \ + --policy.type=act \ + --policy.device=cuda \ + --policy.push_to_hub=false \ + --output_dir=outputs/my_task_act \ + --job_name=my_task_act \ + --wandb.enable=false +``` + +Use `--policy.device=mps` on Apple silicon or `cpu` for a slow smoke test. Policy quality depends on demonstration count, consistency, and coverage; a completed training run does not by itself mean the policy will generalize. The checkpoint you deploy lands at: + +``` +outputs/my_task_act/checkpoints/last/pretrained_model +``` + +## 4. Run it on the robot + +`LeRobotPolicyModule` wraps the checkpoint as a DimOS module: camera frames and joint states in, joint commands out, at the policy's control rate. + +On the A1Z it is one command: + +```bash +uv run dimos a1z run-policy \ + outputs/my_task_act/checkpoints/last/pretrained_model \ + --task "pick up the object" \ + --duration 20 +``` + +It will not surprise you: loading and hardware initialization ask for confirmation, and inference starts only once live camera and joint observations are flowing. + +For other arms, compose `LeRobotPolicyModule` into a blueprint next to a camera and the coordinator. The module never moves hardware at startup — its `execute_learned_policy` skill starts inference explicitly, and `stop_learned_policy` halts it with the robot holding position. + +For the complete A1Z dependency setup, exact dataset conversion command, camera selection, safety notes, and multi-policy agent blueprint, use the [Galaxea A1Z hardware and learning guide](/dimos/robot/manipulators/galaxea_a1z/README.md). + +## When something looks wrong + +Work backwards through the loop. A policy behaving strangely is usually a dataset problem; a dataset problem is usually a recording problem. + +- `dimos dataprep inspect` — do shapes, rates, and episode counts match what you expect? +- `dimos a1z replay` — does the recorded motion look like what you demonstrated? +- Recordings from before the `coordinator_joint_state` stream rename need a config pointing at the old name — or just re-record. diff --git a/docs/capabilities/manipulation/planning.md b/docs/capabilities/manipulation/planning.md new file mode 100644 index 0000000000..3a0073a2e7 --- /dev/null +++ b/docs/capabilities/manipulation/planning.md @@ -0,0 +1,112 @@ +--- +title: "The Planning Stack" +description: "How a pose target becomes motion — and where to plug in your own planner, IK solver, or learned policy." +--- + +Ask the arm to move its gripper somewhere, and three things happen in order: + +``` +"put the gripper here" + │ + IK solver which joint angles reach that pose? + │ + motion planner which path gets there without hitting anything? + │ + trajectory task stream it to the motors, 100 times a second +``` + +The first two live in `ManipulationModule` — that is the planning stack. The third lives in the `ControlCoordinator` and you will rarely touch it. + +This layer is where your time goes. It is deliberately pluggable: the planner, the IK solver, and the collision world are all protocols you can swap — and a learned policy can replace the whole thing. + +## Your first plan + +Start the planner and a mock arm, then drive it from Python: + +```bash +dimos run xarm7-planner-coordinator # planner + coordinator + Meshcat +python -m dimos.manipulation.planning.examples.manipulation_client +``` + +```python skip +joints() # where is the arm? +plan([0.1] * 7) # plan a collision-free path to these joint angles +preview() # ghost animation in Meshcat — nothing moves yet +execute() # now it moves +``` + +That `plan → preview → execute` rhythm is the whole API in miniature. Preview is cheap and execute is explicit, so you always see a motion before the robot performs it. + +## The API + +Everything below is an RPC on `ManipulationModule`. The building blocks: + +| Call | What it does | +|------|--------------| +| `solve_ik(pose)` | Pose in, `IKResult` out. No planning, nothing moves. | +| `plan_to_pose(pose)` / `plan_to_joints(joints)` | Solve, then plan a collision-free path. Stores it. | +| `preview_path()` | Animate the stored path in the visualizer. | +| `execute()` | Send the stored path to the coordinator for real. | +| `add_obstacle(name, pose, shape, dimensions)` | Box, sphere, cylinder, or mesh the planner must avoid. | +| `is_collision_free(joints)` | Check a configuration without planning. | + +And the one-shot skills that agents call — `move_to_pose(x, y, z)`, `move_to_joints("0.1, -0.5, ...")`, `go_home()`, `open_gripper()` — each wrap plan-and-execute into a single step. + +Poses land in IK, IK lands in the planner, and the planner only ever talks to the world through `WorldSpec` — which is what makes the backends swappable. + +## Picking backends + +Three independent choices, set on `ManipulationModuleConfig` or with `-o` overrides: + +```bash +dimos run xarm7-planner-coordinator \ + -o manipulationmodule.world_backend=drake \ + -o manipulationmodule.planner_name=rrt_connect \ + -o manipulationmodule.kinematics.backend=pink +``` + +| Choice | Options | Default | +|--------|---------|---------| +| `world_backend` — collision & FK | `drake`, `roboplan` | `drake` | +| `planner_name` — path search | `rrt_connect`, `roboplan` | `rrt_connect` | +| `kinematics.backend` — IK | `pink`, `jacobian`, `drake_optimization` | `pink` | + +Two rules, enforced at startup rather than at your first plan: the `roboplan` planner needs the `roboplan` world, and `drake_optimization` IK needs the `drake` world. Everything else combines freely. + +If you are not sure, the defaults are right: Drake world, RRT-Connect, Pink IK. + +## Plugging in your own + +The three protocols live in `dimos/manipulation/planning/spec/protocols.py`, and they are duck-typed — no base class, just the methods. + +A planner is one method: + +```python skip +class MyPlanner: + def plan_joint_path(self, world, robot_id, start, goal, timeout=10.0) -> PlanningResult: + ... # talk to the world only via WorldSpec: check_edge_collision_free, joint limits, FK + def get_name(self) -> str: + return "MyPlanner" +``` + +An IK solver is one method too — `solve(world, robot_id, target_pose, seed, ...) -> IKResult`. + +Wiring it up is a two-line change in `dimos/manipulation/planning/factory.py`: add your name to the `PlannerName` (or `KinematicsName`) literal, and add a branch in `create_planner` (or `create_kinematics`) that constructs your class. There is no plugin registry to learn — the factory is the registry. + +Stay on `WorldSpec` methods inside your implementation and it will work with every world backend, current and future. `RRTConnectPlanner` (`planning/planners/rrt_planner.py`) is a readable reference — pure Python, backend-agnostic. + +One naming trap: `PinocchioIK` exists in the kinematics folder but is **not** a planning backend — it is the fast in-loop IK used by the teleop and servo control tasks. You cannot select it via `kinematics.backend`. + +## Where learned policies fit + +A learned policy does not extend the planner — it **replaces** it. Both are just producers of joint targets, and the coordinator accepts either: + +``` +planner ── JointTrajectory ──▶ trajectory task ─┐ + ├─▶ arm +policy ── joint commands ──▶ servo task ──────┘ +``` + +`LeRobotPolicyModule` streams `joint_command` at the policy's rate into a coordinator servo task — no IK, no collision world, no planner in the loop. Which path to use is a per-task decision: structured, obstacle-aware motion suits planning; contact-rich or hard-to-model skills suit a policy trained from demonstrations. + +Collecting those demonstrations and training that policy is [the learning loop](/docs/capabilities/manipulation/learning.md). diff --git a/docs/docs.json b/docs/docs.json index 7074b3e52a..b9ae0790f9 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -99,9 +99,9 @@ "pages": [ "capabilities/manipulation/index", "capabilities/manipulation/agentic", - "capabilities/manipulation/adding_a_custom_arm", - "capabilities/manipulation/openarm_integration", - "capabilities/manipulation/a750" + "capabilities/manipulation/planning", + "capabilities/manipulation/learning", + "capabilities/manipulation/adding_a_custom_arm" ] }, { @@ -155,6 +155,13 @@ "pages": [ "platforms/humanoid/g1/index" ] + }, + { + "group": "Arms", + "pages": [ + "platforms/arms/a750", + "platforms/arms/openarm" + ] } ] }, @@ -418,11 +425,27 @@ }, { "source": "/docs/capabilities/manipulation/openarm_integration.md", - "destination": "/capabilities/manipulation/openarm_integration" + "destination": "/platforms/arms/openarm" }, { "source": "/docs/capabilities/manipulation/a750.md", - "destination": "/capabilities/manipulation/a750" + "destination": "/platforms/arms/a750" + }, + { + "source": "/docs/platforms/arms/a750.md", + "destination": "/platforms/arms/a750" + }, + { + "source": "/docs/platforms/arms/openarm.md", + "destination": "/platforms/arms/openarm" + }, + { + "source": "/docs/capabilities/manipulation/planning.md", + "destination": "/capabilities/manipulation/planning" + }, + { + "source": "/docs/capabilities/manipulation/learning.md", + "destination": "/capabilities/manipulation/learning" }, { "source": "/docs/capabilities/memory/index.md", diff --git a/docs/capabilities/manipulation/a750.md b/docs/platforms/arms/a750.md similarity index 100% rename from docs/capabilities/manipulation/a750.md rename to docs/platforms/arms/a750.md diff --git a/docs/capabilities/manipulation/openarm_integration.md b/docs/platforms/arms/openarm.md similarity index 100% rename from docs/capabilities/manipulation/openarm_integration.md rename to docs/platforms/arms/openarm.md From 6f27e91125d7271f1df88087a3092abb46256d9b Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Tue, 21 Jul 2026 10:51:08 -0700 Subject: [PATCH 40/51] feat(zenoh): partition multicast discovery per machine Zenoh peers discover each other via multicast scouting, which is bidirectional and (with a tailscale 224.0.0/4 route) leaks across the tailnet, so separate machines collide on shared topics. Hash a per-machine domain (default: hostname) to a multicast port so each machine lands on its own discovery port. Same-machine processes share the port and still auto-discover; different machines never see each other's traffic. Set DIMOS_ZENOH_DOMAIN to the same value on machines that should share (like ROS_DOMAIN_ID). Multicast stays on; no coordinator changes. --- dimos/core/global_config.py | 21 +++++++++++++++++++++ dimos/protocol/service/zenohservice.py | 14 +++++++++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/dimos/core/global_config.py b/dimos/core/global_config.py index ec150c9bc9..f201f79145 100644 --- a/dimos/core/global_config.py +++ b/dimos/core/global_config.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import hashlib import os import platform import re @@ -31,6 +32,13 @@ TransportBackend: TypeAlias = Literal["lcm", "zenoh"] +# Zenoh multicast discovery is partitioned by port: the domain string hashes to a +# port in [BASE, BASE+SPAN). Different domains land on different ports and never +# discover each other, so machines with different domains don't collide. +ZENOH_MULTICAST_GROUP = "224.0.0.224" +ZENOH_MULTICAST_PORT_BASE = 10000 +ZENOH_MULTICAST_PORT_SPAN = 50000 + def _get_all_numbers(s: str) -> list[float]: return [float(x) for x in re.findall(r"-?\d+\.?\d*", s)] @@ -84,6 +92,13 @@ class GlobalConfig(BaseSettings): default_factory=_default_transport, validation_alias=AliasChoices("DIMOS_TRANSPORT", "transport"), ) + # Partitions zenoh's multicast discovery so machines don't collide on shared + # topics. Defaults to this machine's hostname, so each machine gets its own + # partition. Set the same DIMOS_ZENOH_DOMAIN on machines that SHOULD share. + zenoh_domain: str = Field( + default_factory=platform.node, + validation_alias=AliasChoices("DIMOS_ZENOH_DOMAIN", "zenoh_domain"), + ) build_native: bool = DEFAULT_BUILD_NATIVE dtop: bool = False obstacle_avoidance: bool = True @@ -107,6 +122,12 @@ def update(self, **kwargs: object) -> None: raise AttributeError(f"GlobalConfig has no field '{key}'") setattr(self, key, value) + @property + def zenoh_multicast_address(self) -> str: + digest = hashlib.sha256(self.zenoh_domain.encode()).digest() + offset = int.from_bytes(digest[:4], "big") % ZENOH_MULTICAST_PORT_SPAN + return f"{ZENOH_MULTICAST_GROUP}:{ZENOH_MULTICAST_PORT_BASE + offset}" + @property def unitree_connection_type(self) -> str: if self.replay: diff --git a/dimos/protocol/service/zenohservice.py b/dimos/protocol/service/zenohservice.py index 2cc491f9ae..e7cae68e85 100644 --- a/dimos/protocol/service/zenohservice.py +++ b/dimos/protocol/service/zenohservice.py @@ -18,8 +18,10 @@ import threading from typing import Any +from pydantic import Field import zenoh +from dimos.core.global_config import global_config from dimos.protocol.service.spec import BaseConfig, Service from dimos.utils.logging_config import setup_logger @@ -28,14 +30,21 @@ logger = setup_logger() +def _default_multicast_address() -> str: + return global_config.zenoh_multicast_address + + class ZenohConfig(BaseConfig): mode: str = "peer" + # Multicast discovery group+port. Partitioned per zenoh_domain so machines + # on different domains never discover each other (see GlobalConfig). + multicast_address: str = Field(default_factory=_default_multicast_address) connect: list[str] = [] listen: list[str] = [] @property def session_key(self) -> str: - return f"{self.mode}|{json.dumps(sorted(self.connect))}|{json.dumps(sorted(self.listen))}" + return f"{self.mode}|{self.multicast_address}|{json.dumps(sorted(self.connect))}|{json.dumps(sorted(self.listen))}" class ZenohSessionPool: @@ -50,6 +59,9 @@ def acquire(self, config: ZenohConfig) -> zenoh.Session: if key not in self._sessions: zconfig = zenoh.Config() zconfig.insert_json5("mode", json.dumps(config.mode)) + zconfig.insert_json5( + "scouting/multicast/address", json.dumps(config.multicast_address) + ) if config.connect: zconfig.insert_json5("connect/endpoints", json.dumps(config.connect)) if config.listen: From 135c3046e65d4b3fae05206911a1da34a6b437af Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Tue, 21 Jul 2026 11:45:32 -0700 Subject: [PATCH 41/51] Revert "feat(zenoh): partition multicast discovery per machine" This reverts commit 6f27e91125d7271f1df88087a3092abb46256d9b. --- dimos/core/global_config.py | 21 --------------------- dimos/protocol/service/zenohservice.py | 14 +------------- 2 files changed, 1 insertion(+), 34 deletions(-) diff --git a/dimos/core/global_config.py b/dimos/core/global_config.py index f201f79145..ec150c9bc9 100644 --- a/dimos/core/global_config.py +++ b/dimos/core/global_config.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import hashlib import os import platform import re @@ -32,13 +31,6 @@ TransportBackend: TypeAlias = Literal["lcm", "zenoh"] -# Zenoh multicast discovery is partitioned by port: the domain string hashes to a -# port in [BASE, BASE+SPAN). Different domains land on different ports and never -# discover each other, so machines with different domains don't collide. -ZENOH_MULTICAST_GROUP = "224.0.0.224" -ZENOH_MULTICAST_PORT_BASE = 10000 -ZENOH_MULTICAST_PORT_SPAN = 50000 - def _get_all_numbers(s: str) -> list[float]: return [float(x) for x in re.findall(r"-?\d+\.?\d*", s)] @@ -92,13 +84,6 @@ class GlobalConfig(BaseSettings): default_factory=_default_transport, validation_alias=AliasChoices("DIMOS_TRANSPORT", "transport"), ) - # Partitions zenoh's multicast discovery so machines don't collide on shared - # topics. Defaults to this machine's hostname, so each machine gets its own - # partition. Set the same DIMOS_ZENOH_DOMAIN on machines that SHOULD share. - zenoh_domain: str = Field( - default_factory=platform.node, - validation_alias=AliasChoices("DIMOS_ZENOH_DOMAIN", "zenoh_domain"), - ) build_native: bool = DEFAULT_BUILD_NATIVE dtop: bool = False obstacle_avoidance: bool = True @@ -122,12 +107,6 @@ def update(self, **kwargs: object) -> None: raise AttributeError(f"GlobalConfig has no field '{key}'") setattr(self, key, value) - @property - def zenoh_multicast_address(self) -> str: - digest = hashlib.sha256(self.zenoh_domain.encode()).digest() - offset = int.from_bytes(digest[:4], "big") % ZENOH_MULTICAST_PORT_SPAN - return f"{ZENOH_MULTICAST_GROUP}:{ZENOH_MULTICAST_PORT_BASE + offset}" - @property def unitree_connection_type(self) -> str: if self.replay: diff --git a/dimos/protocol/service/zenohservice.py b/dimos/protocol/service/zenohservice.py index e7cae68e85..2cc491f9ae 100644 --- a/dimos/protocol/service/zenohservice.py +++ b/dimos/protocol/service/zenohservice.py @@ -18,10 +18,8 @@ import threading from typing import Any -from pydantic import Field import zenoh -from dimos.core.global_config import global_config from dimos.protocol.service.spec import BaseConfig, Service from dimos.utils.logging_config import setup_logger @@ -30,21 +28,14 @@ logger = setup_logger() -def _default_multicast_address() -> str: - return global_config.zenoh_multicast_address - - class ZenohConfig(BaseConfig): mode: str = "peer" - # Multicast discovery group+port. Partitioned per zenoh_domain so machines - # on different domains never discover each other (see GlobalConfig). - multicast_address: str = Field(default_factory=_default_multicast_address) connect: list[str] = [] listen: list[str] = [] @property def session_key(self) -> str: - return f"{self.mode}|{self.multicast_address}|{json.dumps(sorted(self.connect))}|{json.dumps(sorted(self.listen))}" + return f"{self.mode}|{json.dumps(sorted(self.connect))}|{json.dumps(sorted(self.listen))}" class ZenohSessionPool: @@ -59,9 +50,6 @@ def acquire(self, config: ZenohConfig) -> zenoh.Session: if key not in self._sessions: zconfig = zenoh.Config() zconfig.insert_json5("mode", json.dumps(config.mode)) - zconfig.insert_json5( - "scouting/multicast/address", json.dumps(config.multicast_address) - ) if config.connect: zconfig.insert_json5("connect/endpoints", json.dumps(config.connect)) if config.listen: From 4b3475bd60ef692e01d95f8171451ae2d9e28935 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 21 Jul 2026 11:50:34 -0700 Subject: [PATCH 42/51] doc: piper integration --- .../manipulators/piper/blueprints/teleop.py | 12 +- .../robot/manipulators/piper/scripts/LICENSE | 21 +++ .../piper/scripts/can_activate.sh | 144 ++++++++++++++++++ dimos/robot/manipulators/test_blueprints.py | 26 +++- .../manipulation/piper_integration.md | 34 ++++- 5 files changed, 230 insertions(+), 7 deletions(-) create mode 100644 dimos/robot/manipulators/piper/scripts/LICENSE create mode 100755 dimos/robot/manipulators/piper/scripts/can_activate.sh diff --git a/dimos/robot/manipulators/piper/blueprints/teleop.py b/dimos/robot/manipulators/piper/blueprints/teleop.py index 9c68730fb9..684d06749b 100644 --- a/dimos/robot/manipulators/piper/blueprints/teleop.py +++ b/dimos/robot/manipulators/piper/blueprints/teleop.py @@ -25,6 +25,7 @@ cartesian_ik_task, eef_twist_task, teleop_ik_task, + trajectory_task, ) from dimos.robot.manipulators.common.sim import mujoco_if_sim from dimos.robot.manipulators.piper.config import ( @@ -44,6 +45,7 @@ gripper_open_position=0.07, gripper_closed_position=0.0, ) +_piper_model = make_piper_model_config() keyboard_teleop_piper = autoconnect( KeyboardTeleopModule.blueprint(), @@ -61,11 +63,12 @@ priority=20, params={"timeout": 0.0, "default_positions": [0.0]}, ), + trajectory_task(_piper_keyboard_hw, name=_piper_model.coordinator_task_name), ], ), ManipulationModule.blueprint( - robots=[make_piper_model_config()], - visualization={"backend": "meshcat"}, + robots=[_piper_model], + visualization={"backend": "viser"}, ), ) @@ -97,8 +100,13 @@ "gripper_closed_pos": 0.0, }, ), + trajectory_task(_piper_teleop_hw, name=_piper_model.coordinator_task_name), ], ), + ManipulationModule.blueprint( + robots=[_piper_model], + visualization={"backend": "viser"}, + ), *mujoco_if_sim(PIPER_SIM_PATH, len(_piper_teleop_hw.joints)), ) diff --git a/dimos/robot/manipulators/piper/scripts/LICENSE b/dimos/robot/manipulators/piper/scripts/LICENSE new file mode 100644 index 0000000000..cc8ae3326e --- /dev/null +++ b/dimos/robot/manipulators/piper/scripts/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Agilex Robotice Co., Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/dimos/robot/manipulators/piper/scripts/can_activate.sh b/dimos/robot/manipulators/piper/scripts/can_activate.sh new file mode 100755 index 0000000000..3fd5a64601 --- /dev/null +++ b/dimos/robot/manipulators/piper/scripts/can_activate.sh @@ -0,0 +1,144 @@ +#!/bin/bash +# Vendored helper; functional body retained unchanged from upstream. +# Copyright (c) Agilex Robotice Co., Ltd. +# SPDX-License-Identifier: MIT +# See LICENSE in this directory for the full license text. +# Source: https://raw.githubusercontent.com/agilexrobotics/piper_sdk/4eddfcf8/can_activate.sh +# Upstream commit: 4eddfcf8 (blob SHA-1: 60cc95e7ea51da662884a7400f563bcdef5af9b8) + +# The default CAN name can be set by the user via command-line parameters. +DEFAULT_CAN_NAME="${1:-can0}" + +# The default bitrate for a single CAN module can be set by the user via command-line parameters. +DEFAULT_BITRATE="${2:-1000000}" + +# USB hardware address (optional parameter) +USB_ADDRESS="${3}" +echo "-------------------START-----------------------" +# Check if ethtool is installed. +if ! dpkg -l | grep -q "ethtool"; then + echo "\e[31mError: ethtool not detected in the system.\e[0m" + echo "Please use the following command to install ethtool:" + echo "sudo apt update && sudo apt install ethtool" + exit 1 +fi + +# Check if can-utils is installed. +if ! dpkg -l | grep -q "can-utils"; then + echo "\e[31mError: can-utils not detected in the system.\e[0m" + echo "Please use the following command to install ethtool:" + echo "sudo apt update && sudo apt install can-utils" + exit 1 +fi + +echo "Both ethtool and can-utils are installed." + +# Retrieve the number of CAN modules in the current system. +CURRENT_CAN_COUNT=$(ip link show type can | grep -c "link/can") + +# Verify if the number of CAN modules in the current system matches the expected value. +if [ "$CURRENT_CAN_COUNT" -ne "1" ]; then + if [ -z "$USB_ADDRESS" ]; then + # Iterate through all CAN interfaces. + for iface in $(ip -br link show type can | awk '{print $1}'); do + # Use ethtool to retrieve bus-info. + BUS_INFO=$(sudo ethtool -i "$iface" | grep "bus-info" | awk '{print $2}') + + if [ -z "$BUS_INFO" ];then + echo "Error: Unable to retrieve bus-info for interface $iface." + continue + fi + + echo "Interface $iface is inserted into USB port $BUS_INFO" + done + echo -e " \e[31m Error: The number of CAN modules detected by the system ($CURRENT_CAN_COUNT) does not match the expected number (1). \e[0m" + echo -e " \e[31m Please add the USB hardware address parameter, such as: \e[0m" + echo -e " bash can_activate.sh can0 1000000 1-2:1.0" + echo "-------------------ERROR-----------------------" + exit 1 + fi +fi + +# Load the gs_usb module. +# sudo modprobe gs_usb +# if [ $? -ne 0 ]; then +# echo "Error: Unable to load the gs_usb module." +# exit 1 +# fi + +if [ -n "$USB_ADDRESS" ]; then + echo "Detected USB hardware address parameter: $USB_ADDRESS" + + # Use ethtool to find the CAN interface corresponding to the USB hardware address. + INTERFACE_NAME="" + for iface in $(ip -br link show type can | awk '{print $1}'); do + BUS_INFO=$(sudo ethtool -i "$iface" | grep "bus-info" | awk '{print $2}') + if [ "$BUS_INFO" = "$USB_ADDRESS" ]; then + INTERFACE_NAME="$iface" + break + fi + done + + if [ -z "$INTERFACE_NAME" ]; then + echo "Error: Unable to find CAN interface corresponding to USB hardware address $USB_ADDRESS." + exit 1 + else + echo "Found the interface corresponding to USB hardware address $USB_ADDRESS: $INTERFACE_NAME." + fi +else + # Retrieve the unique CAN interface. + INTERFACE_NAME=$(ip -br link show type can | awk '{print $1}') + + # Check if the interface name has been retrieved. + if [ -z "$INTERFACE_NAME" ]; then + echo "Error: Unable to detect CAN interface." + exit 1 + fi + BUS_INFO=$(sudo ethtool -i "$INTERFACE_NAME" | grep "bus-info" | awk '{print $2}') + echo "Expected to configure a single CAN module, detected interface $INTERFACE_NAME with corresponding USB address $BUS_INFO." +fi + +# Check if the current interface is already activated. +IS_LINK_UP=$(ip link show "$INTERFACE_NAME" | grep -q "UP" && echo "yes" || echo "no") + +# Retrieve the bitrate of the current interface. +CURRENT_BITRATE=$(ip -details link show "$INTERFACE_NAME" | grep -oP 'bitrate \K\d+') + +if [ "$IS_LINK_UP" = "yes" ] && [ "$CURRENT_BITRATE" -eq "$DEFAULT_BITRATE" ]; then + echo "Interface $INTERFACE_NAME is already activated with a bitrate of $DEFAULT_BITRATE." + + # Check if the interface name matches the default name. + if [ "$INTERFACE_NAME" != "$DEFAULT_CAN_NAME" ]; then + echo "Rename interface $INTERFACE_NAME to $DEFAULT_CAN_NAME." + sudo ip link set "$INTERFACE_NAME" down + sudo ip link set "$INTERFACE_NAME" name "$DEFAULT_CAN_NAME" + sudo ip link set "$DEFAULT_CAN_NAME" up + echo "The interface has been renamed to $DEFAULT_CAN_NAME and reactivated." + else + echo "The interface name is already $DEFAULT_CAN_NAME." + fi +else + # If the interface is not activated or the bitrate is different, configure it. + if [ "$IS_LINK_UP" = "yes" ]; then + echo "Interface $INTERFACE_NAME is already activated, but the bitrate is $CURRENT_BITRATE, which does not match the set value of $DEFAULT_BITRATE." + else + echo "Interface $INTERFACE_NAME is not activated or bitrate is not set." + fi + + # Set the interface bitrate and activate it. + sudo ip link set "$INTERFACE_NAME" down + sudo ip link set "$INTERFACE_NAME" type can bitrate $DEFAULT_BITRATE + sudo ip link set "$INTERFACE_NAME" up + echo "Interface $INTERFACE_NAME has been reset to bitrate $DEFAULT_BITRATE and activated." + + # Rename the interface to the default name. + if [ "$INTERFACE_NAME" != "$DEFAULT_CAN_NAME" ]; then + echo "Rename interface $INTERFACE_NAME to $DEFAULT_CAN_NAME." + sudo ip link set "$INTERFACE_NAME" down + sudo ip link set "$INTERFACE_NAME" name "$DEFAULT_CAN_NAME" + sudo ip link set "$DEFAULT_CAN_NAME" up + echo "The interface has been renamed to $DEFAULT_CAN_NAME and reactivated." + fi +fi + +echo "-------------------OVER------------------------" diff --git a/dimos/robot/manipulators/test_blueprints.py b/dimos/robot/manipulators/test_blueprints.py index 69eabf09ba..672f9474f6 100644 --- a/dimos/robot/manipulators/test_blueprints.py +++ b/dimos/robot/manipulators/test_blueprints.py @@ -76,19 +76,41 @@ def test_quest_piper_teleop_routes_to_declarative_teleop_task() -> None: assert "coordinator_cartesian_command" in teleop_quest_piper.remapping_map.values() +def test_piper_teleop_blueprints_declare_viser_manipulation() -> None: + for blueprint in (keyboard_teleop_piper, coordinator_teleop_piper): + kwargs = _manipulation_kwargs(blueprint) + assert kwargs["robots"][0].coordinator_task_name == "traj_arm" + assert kwargs["visualization"] == {"backend": "viser"} + + +def test_quest_piper_composes_planner_with_trajectory_coordinator() -> None: + assert _module_kwargs(coordinator_teleop_piper, ControlCoordinator) + assert _module_kwargs(coordinator_teleop_piper, ManipulationModule) + coordinator_planner = next( + atom for atom in coordinator_teleop_piper.blueprints if atom.module is ManipulationModule + ) + quest_planners = [ + atom for atom in teleop_quest_piper.blueprints if atom.module is ManipulationModule + ] + assert quest_planners == [coordinator_planner] + + def test_piper_teleop_declares_teleop_task() -> None: tasks = _coordinator_tasks(coordinator_teleop_piper) assert [(task.name, task.type) for task in tasks] == [ ("teleop_piper", "teleop_ik"), + ("traj_arm", "trajectory"), ] def test_piper_keyboard_declares_high_priority_gripper_servo() -> None: tasks = _coordinator_tasks(keyboard_teleop_piper) servo = next(task for task in tasks if task.name == "servo_gripper") + trajectory = next(task for task in tasks if task.name == "traj_arm") assert servo.type == "servo" assert servo.joint_names == ["arm/gripper"] assert servo.priority > next(task.priority for task in tasks if task.type == "eef_twist") + assert trajectory.type == "trajectory" def test_piper_keyboard_declares_gripper_endpoints_and_light_keyboard_kwargs() -> None: @@ -101,7 +123,9 @@ def test_piper_keyboard_declares_gripper_endpoints_and_light_keyboard_kwargs() - def test_piper_quest_declares_normalized_gripper_endpoints() -> None: hardware = _module_kwargs(coordinator_teleop_piper, ControlCoordinator)["hardware"][0] - task = _coordinator_tasks(coordinator_teleop_piper)[0] + task = next( + task for task in _coordinator_tasks(coordinator_teleop_piper) if task.name == "teleop_piper" + ) assert (hardware.gripper_closed_position, hardware.gripper_open_position) == (0.0, 0.07) assert task.params["gripper_open_pos"] == 1.0 diff --git a/docs/capabilities/manipulation/piper_integration.md b/docs/capabilities/manipulation/piper_integration.md index 7037ab62ff..30ae14d60f 100644 --- a/docs/capabilities/manipulation/piper_integration.md +++ b/docs/capabilities/manipulation/piper_integration.md @@ -13,8 +13,35 @@ The manipulation extra provides the verified software prerequisites: Drake and For real hardware, set `GlobalConfig.can_port` to the CAN interface used by the arm. The default hardware address is `can0`; the standard Piper configuration -uses the mock adapter when no CAN port is configured. No separate DimOS hardware -bring-up command is documented here. +uses the mock adapter when no CAN port is configured. Prepare the CAN interface +before running a hardware blueprint. + +## Bring up the Piper CAN interface + +Piper uses SocketCAN at 1,000,000 bit/s. For the default vendor setup, use +DimOS's vendored copy of the upstream activation helper, pinned to upstream +revision `4eddfcf8`: + +```bash +bash dimos/robot/manipulators/piper/scripts/can_activate.sh can0 1000000 +``` + +If the device already exposes `can0`, run the vendored helper directly. Verify +the interface before starting a blueprint: + +```bash +ip link show can0 +``` + +### Optional SLCAN setup + +Use this separate path only with a serial-CAN adapter, such as `/dev/ttyACM0`; +it is not the default/vendor Piper setup: + +```bash +sudo slcand -o -c -s8 /dev/ttyACM0 can0 +sudo ip link set can0 up +``` ## Run a Piper blueprint @@ -51,5 +78,4 @@ The Piper blueprints are defined in: The manipulation requirements include Drake and `piper-sdk`. Follow the hardware vendor's documentation for connecting and powering the arm; DimOS -provides the integration and blueprint compositions, not a separate hardware -bring-up procedure. +provides the integration and blueprint compositions. From b9a07cb01a42d70bb1f821f0e000cfa50a281301 Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Tue, 21 Jul 2026 12:26:53 -0700 Subject: [PATCH 43/51] feat(zenoh): add usrpwd password auth defaulting to machine id Peers only form a transport link when they share the secret, so other machines on the network can't see or collide with this host's zenoh traffic. The password defaults to the machine id (derivable by any on-machine process, so local peers auto-share) and can be overridden with DIMOS_ZENOH_PASSWORD. --- dimos/constants.py | 12 +++++++++++ dimos/core/global_config.py | 6 +++++- dimos/protocol/service/zenohservice.py | 28 +++++++++++++++++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/dimos/constants.py b/dimos/constants.py index 3b0fc385c8..279d063f1b 100644 --- a/dimos/constants.py +++ b/dimos/constants.py @@ -14,6 +14,8 @@ import os from pathlib import Path +import subprocess +import sys try: # Not a dependency, just the best way to get config path if available. @@ -27,6 +29,16 @@ STATE_DIR = Path(GLib.get_user_state_dir()) / "dimos" CACHE_DIR = Path(GLib.get_user_cache_dir()) / "dimos" +if sys.platform == "linux": + MACHINE_ID = Path("/etc/machine-id").read_text().strip() +if os.sys.platform == "darwin": + parts = subprocess.run( + ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"], + capture_output=True, + text=True, + ).stdout.split('"') + MACHINE_ID = parts[parts.index("IOPlatformUUID") + 2].lower() + DIMOS_PROJECT_ROOT = Path(__file__).parent.parent if (DIMOS_PROJECT_ROOT / ".git").exists(): diff --git a/dimos/core/global_config.py b/dimos/core/global_config.py index ec150c9bc9..e8228bbfdc 100644 --- a/dimos/core/global_config.py +++ b/dimos/core/global_config.py @@ -20,7 +20,7 @@ from pydantic import AliasChoices, Field from pydantic_settings import BaseSettings, SettingsConfigDict -from dimos.constants import DEFAULT_BUILD_NATIVE +from dimos.constants import DEFAULT_BUILD_NATIVE, MACHINE_ID from dimos.models.vl.types import VlModelName from dimos.visualization.rerun.constants import ( RERUN_ENABLE_WEB, @@ -84,6 +84,10 @@ class GlobalConfig(BaseSettings): default_factory=_default_transport, validation_alias=AliasChoices("DIMOS_TRANSPORT", "transport"), ) + zenoh_password: str = Field( + default=MACHINE_ID, + validation_alias=AliasChoices("DIMOS_ZENOH_PASSWORD", "zenoh_password"), + ) build_native: bool = DEFAULT_BUILD_NATIVE dtop: bool = False obstacle_avoidance: bool = True diff --git a/dimos/protocol/service/zenohservice.py b/dimos/protocol/service/zenohservice.py index 2cc491f9ae..ed56a7ab8e 100644 --- a/dimos/protocol/service/zenohservice.py +++ b/dimos/protocol/service/zenohservice.py @@ -14,12 +14,16 @@ from __future__ import annotations +import hashlib import json import threading from typing import Any +from pydantic import Field import zenoh +from dimos.constants import CACHE_DIR +from dimos.core.global_config import global_config from dimos.protocol.service.spec import BaseConfig, Service from dimos.utils.logging_config import setup_logger @@ -30,12 +34,14 @@ class ZenohConfig(BaseConfig): mode: str = "peer" + user: str = "dimos" + password: str = Field(default_factory=lambda: global_config.zenoh_password) connect: list[str] = [] listen: list[str] = [] @property def session_key(self) -> str: - return f"{self.mode}|{json.dumps(sorted(self.connect))}|{json.dumps(sorted(self.listen))}" + return f"{self.mode}|{self.user}|{self.password}|{json.dumps(sorted(self.connect))}|{json.dumps(sorted(self.listen))}" class ZenohSessionPool: @@ -50,6 +56,15 @@ def acquire(self, config: ZenohConfig) -> zenoh.Session: if key not in self._sessions: zconfig = zenoh.Config() zconfig.insert_json5("mode", json.dumps(config.mode)) + if config.password: + zconfig.insert_json5("transport/auth/usrpwd/user", json.dumps(config.user)) + zconfig.insert_json5( + "transport/auth/usrpwd/password", json.dumps(config.password) + ) + zconfig.insert_json5( + "transport/auth/usrpwd/dictionary_file", + json.dumps(_password_dictionary_file(config.user, config.password)), + ) if config.connect: zconfig.insert_json5("connect/endpoints", json.dumps(config.connect)) if config.listen: @@ -89,3 +104,14 @@ def session(self) -> zenoh.Session: if self._session is None: raise RuntimeError("Zenoh session not initialized. Call start() first.") return self._session + + +# zenoh requires a file for auth +def _password_dictionary_file(user: str, password: str) -> str: + directory = CACHE_DIR / "zenoh_auth" + directory.mkdir(parents=True, exist_ok=True) + digest = hashlib.sha256(f"{user}:{password}".encode()).hexdigest()[:16] + path = directory / f"{digest}.txt" + path.write_text(f"{user}:{password}\n") + path.chmod(0o600) + return str(path) From 1d1f61c16fa7375526b625b04593405a18d6508f Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 21 Jul 2026 12:48:33 -0700 Subject: [PATCH 44/51] fix: accumulate EEF teleop commands --- .../tasks/eef_twist_task/eef_twist_task.py | 33 ++++++++++++++----- .../eef_twist_task/test_eef_twist_task.py | 25 +++++++++++++- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/dimos/control/tasks/eef_twist_task/eef_twist_task.py b/dimos/control/tasks/eef_twist_task/eef_twist_task.py index 3fa79fcf4b..c5ee5f81a5 100644 --- a/dimos/control/tasks/eef_twist_task/eef_twist_task.py +++ b/dimos/control/tasks/eef_twist_task/eef_twist_task.py @@ -78,6 +78,8 @@ def __init__(self, name: str, config: EEFTwistTaskConfig) -> None: self._lock = threading.Lock() self._latest_twist: TwistStamped | None = None self._last_update_time = 0.0 + self._last_commanded_positions: NDArray[np.float64] | None = None + self._last_commanded_pose: pinocchio.SE3 | None = None def claim(self) -> ResourceClaim: return ResourceClaim(self._joint_names, self._config.priority, ControlMode.SERVO_POSITION) @@ -92,10 +94,6 @@ def on_ee_twist_command(self, twist: TwistStamped, t_now: float) -> bool: logger.warning("EEFTwistTask rejecting non-finite twist", task=self._name) return False with self._lock: - if np.allclose(values, 0.0): - self._clear_locked() - self._last_update_time = t_now - return True self._latest_twist = twist self._last_update_time = t_now return True @@ -109,13 +107,25 @@ def compute(self, state: CoordinatorState) -> JointCommandOutput | None: self._config.timeout > 0 and state.t_now - self._last_update_time > self._config.timeout ): - self._clear_locked() + self._clear_locked(clear_target=True) return None q_current = self._get_current_joints(state) if q_current is None or not np.all(np.isfinite(q_current)): return None - target_pose = self._ik.forward_kinematics(q_current) + q_current = np.asarray(q_current, dtype=np.float64) + if np.allclose(twist_to_numpy(twist), 0.0): + if self._last_commanded_positions is None: + return None + return JointCommandOutput( + joint_names=self._joint_names_list, + positions=self._last_commanded_positions.tolist(), + mode=ControlMode.SERVO_POSITION, + ) + + target_pose = self._last_commanded_pose + if target_pose is None: + target_pose = self._ik.forward_kinematics(q_current) dt = min(max(state.dt, 0.0), _MAX_DT) candidate = self._integrate_twist(target_pose, twist, dt) @@ -139,9 +149,13 @@ def compute(self, state: CoordinatorState) -> JointCommandOutput | None: ) return None + commanded_positions = np.asarray(q_solution, dtype=np.float64).reshape(-1) + self._last_commanded_positions = commanded_positions.copy() + self._last_commanded_pose = self._ik.forward_kinematics(commanded_positions) + return JointCommandOutput( joint_names=self._joint_names_list, - positions=q_solution.flatten().tolist(), + positions=commanded_positions.tolist(), mode=ControlMode.SERVO_POSITION, ) @@ -160,8 +174,11 @@ def _get_current_joints(self, state: CoordinatorState) -> NDArray[np.floating[An positions.append(pos) return np.array(positions, dtype=np.float64) - def _clear_locked(self) -> None: + def _clear_locked(self, *, clear_target: bool = False) -> None: self._latest_twist = None + if clear_target: + self._last_commanded_positions = None + self._last_commanded_pose = None def _integrate_twist( self, pose: pinocchio.SE3, twist: TwistStamped, dt: float diff --git a/dimos/control/tasks/eef_twist_task/test_eef_twist_task.py b/dimos/control/tasks/eef_twist_task/test_eef_twist_task.py index c937c2044d..1d189ae87a 100644 --- a/dimos/control/tasks/eef_twist_task/test_eef_twist_task.py +++ b/dimos/control/tasks/eef_twist_task/test_eef_twist_task.py @@ -42,6 +42,7 @@ def __init__(self) -> None: self.solution = np.array([0.01, 0.02, 0.03], dtype=np.float64) self.converged = True self.final_error = 0.0 + self.solve_from_pose = False def forward_kinematics(self, q_current: NDArray[np.float64]) -> FakePose: self.fk_calls.append(q_current.copy()) @@ -51,6 +52,8 @@ def solve( self, pose: FakePose, q_current: NDArray[np.float64] ) -> tuple[NDArray[np.float64], bool, float]: self.solve_calls.append(pose.copy()) + if self.solve_from_pose: + return pose.translation.copy(), self.converged, self.final_error return self.solution.copy(), self.converged, self.final_error @@ -124,6 +127,23 @@ def test_integration_uses_current_fk_and_coordinator_dt( assert fake_ik.solve_calls[1].translation[0] > fake_ik.solve_calls[0].translation[0] +def test_commands_accumulate_from_last_command_with_stale_feedback( + task: EEFTwistTask, fake_ik: FakeIK +) -> None: + fake_ik.solve_from_pose = True + assert task.on_ee_twist_command(_twist(1.0), t_now=1.0) + + first = task.compute(_state(1.01, positions=[0.0, 0.0, 0.0], dt=0.01)) + second = task.compute(_state(1.02, positions=[0.0, 0.0, 0.0], dt=0.01)) + + assert first is not None + assert second is not None + assert first.positions is not None + assert second.positions is not None + assert second.positions[0] > first.positions[0] + assert second.positions[0] == pytest.approx(0.02) + + def test_non_converged_ik_solution_is_accepted_when_joint_delta_is_safe( task: EEFTwistTask, fake_ik: FakeIK ) -> None: @@ -190,4 +210,7 @@ def test_timeout_and_zero_command_clear_then_next_nonzero_reseeds( assert fake_ik.solve_calls[-1].translation[0] > 1.0 assert task.on_ee_twist_command(_twist(0.0), t_now=2.02) - assert not task.is_active() + assert task.is_active() + held = task.compute(_state(2.03, positions=[0.0, 0.0, 0.0])) + assert held is not None + assert held.positions == [1.01, 0.0, 0.0] From 4c9174c7bc59ea94348dc80ed5a66cd7301c3373 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 21 Jul 2026 15:31:58 -0700 Subject: [PATCH 45/51] doc: simplify integration doc --- .../manipulation/piper_integration.md | 52 ++++--------------- 1 file changed, 11 insertions(+), 41 deletions(-) diff --git a/docs/capabilities/manipulation/piper_integration.md b/docs/capabilities/manipulation/piper_integration.md index 30ae14d60f..0f892c7403 100644 --- a/docs/capabilities/manipulation/piper_integration.md +++ b/docs/capabilities/manipulation/piper_integration.md @@ -3,24 +3,19 @@ title: "Piper Integration" description: "Connect and run a Piper arm with DimOS manipulation and teleoperation blueprints." --- -DimOS integrates the Piper arm through its CAN-based `piper_sdk` adapter and the -standard manipulation stack. The Piper hardware configuration uses the CAN port -from `GlobalConfig.can_port`; when it is not set, teleoperation blueprints use -the mock adapter where supported. +## Optional SLCAN setup -The manipulation extra provides the verified software prerequisites: Drake and -`piper-sdk`. Piper is a six-degree-of-freedom arm in the DimOS hardware model. +Use this separate path only with a serial-CAN adapter, such as `/dev/ttyACM0`; -For real hardware, set `GlobalConfig.can_port` to the CAN interface used by the -arm. The default hardware address is `can0`; the standard Piper configuration -uses the mock adapter when no CAN port is configured. Prepare the CAN interface -before running a hardware blueprint. +```bash +sudo slcand -o -c -s8 /dev/ttyACM0 can0 +sudo ip link set can0 up +``` ## Bring up the Piper CAN interface Piper uses SocketCAN at 1,000,000 bit/s. For the default vendor setup, use -DimOS's vendored copy of the upstream activation helper, pinned to upstream -revision `4eddfcf8`: +DimOS's vendored copy of the upstream activation helper: ```bash bash dimos/robot/manipulators/piper/scripts/can_activate.sh can0 1000000 @@ -33,49 +28,24 @@ the interface before starting a blueprint: ip link show can0 ``` -### Optional SLCAN setup - -Use this separate path only with a serial-CAN adapter, such as `/dev/ttyACM0`; -it is not the default/vendor Piper setup: - -```bash -sudo slcand -o -c -s8 /dev/ttyACM0 can0 -sudo ip link set can0 up -``` - ## Run a Piper blueprint Use the coordinator for the basic manipulation composition: ```bash -dimos run coordinator-piper +dimos --can-port can0 run coordinator-piper ``` For keyboard Cartesian teleoperation, use: ```bash -dimos run keyboard-teleop-piper +dimos --can-port can0 run keyboard-teleop-piper ``` The Quest teleoperation composition is available as: ```bash -dimos run teleop-quest-piper +dimos --can-port can0 run keyboard-teleop-piper ``` -Add `--simulation` to run the supported MuJoCo composition instead of real -hardware. - -## Integration points - -The Piper blueprints are defined in: - -- [`robot/manipulators/piper/blueprints/basic.py`](/dimos/robot/manipulators/piper/blueprints/basic.py) — coordinator -- [`robot/manipulators/piper/blueprints/teleop.py`](/dimos/robot/manipulators/piper/blueprints/teleop.py) — keyboard, - Cartesian, and Quest teleoperation compositions -- [`robot/manipulators/piper/config.py`](/dimos/robot/manipulators/piper/config.py) — hardware and model - configuration - -The manipulation requirements include Drake and `piper-sdk`. Follow the -hardware vendor's documentation for connecting and powering the arm; DimOS -provides the integration and blueprint compositions. +Note that ommitting the `--can-port` argument will fallback the control coordinator to use fake hardware adapter. This is good for testing. From 74cbd82be5a2e1ed86e3f916307d454b65e03820 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 21 Jul 2026 15:42:04 -0700 Subject: [PATCH 46/51] chore: cleanup --- .../eef_twist_task/test_eef_twist_task.py | 20 -------------- .../piper/scripts/can_activate.sh | 26 +++++++------------ 2 files changed, 9 insertions(+), 37 deletions(-) diff --git a/dimos/control/tasks/eef_twist_task/test_eef_twist_task.py b/dimos/control/tasks/eef_twist_task/test_eef_twist_task.py index f95a67067b..b11b35bc10 100644 --- a/dimos/control/tasks/eef_twist_task/test_eef_twist_task.py +++ b/dimos/control/tasks/eef_twist_task/test_eef_twist_task.py @@ -43,7 +43,6 @@ def __init__(self) -> None: self.solution = np.array([0.01, 0.02, 0.03], dtype=np.float64) self.converged = True self.final_error = 0.0 - self.solve_from_pose = False def forward_kinematics(self, q_current: NDArray[np.float64]) -> FakePose: self.fk_calls.append(q_current.copy()) @@ -53,8 +52,6 @@ def solve( self, pose: FakePose, q_current: NDArray[np.float64] ) -> tuple[NDArray[np.float64], bool, float]: self.solve_calls.append(pose.copy()) - if self.solve_from_pose: - return pose.translation.copy(), self.converged, self.final_error return self.solution.copy(), self.converged, self.final_error @@ -127,23 +124,6 @@ def test_jog_integrates_from_commanded_anchor_not_live_state( assert fake_ik.solve_calls[1].translation[0] > fake_ik.solve_calls[0].translation[0] -def test_commands_accumulate_from_last_command_with_stale_feedback( - task: EEFTwistTask, fake_ik: FakeIK -) -> None: - fake_ik.solve_from_pose = True - assert task.on_ee_twist_command(_twist(1.0), t_now=1.0) - - first = task.compute(_state(1.01, positions=[0.0, 0.0, 0.0], dt=0.01)) - second = task.compute(_state(1.02, positions=[0.0, 0.0, 0.0], dt=0.01)) - - assert first is not None - assert second is not None - assert first.positions is not None - assert second.positions is not None - assert second.positions[0] > first.positions[0] - assert second.positions[0] == pytest.approx(0.02) - - def test_non_converged_ik_solution_is_accepted_when_joint_delta_is_safe( task: EEFTwistTask, fake_ik: FakeIK ) -> None: diff --git a/dimos/robot/manipulators/piper/scripts/can_activate.sh b/dimos/robot/manipulators/piper/scripts/can_activate.sh index 3fd5a64601..aa4270365c 100755 --- a/dimos/robot/manipulators/piper/scripts/can_activate.sh +++ b/dimos/robot/manipulators/piper/scripts/can_activate.sh @@ -15,23 +15,15 @@ DEFAULT_BITRATE="${2:-1000000}" # USB hardware address (optional parameter) USB_ADDRESS="${3}" echo "-------------------START-----------------------" -# Check if ethtool is installed. -if ! dpkg -l | grep -q "ethtool"; then - echo "\e[31mError: ethtool not detected in the system.\e[0m" - echo "Please use the following command to install ethtool:" - echo "sudo apt update && sudo apt install ethtool" - exit 1 -fi - -# Check if can-utils is installed. -if ! dpkg -l | grep -q "can-utils"; then - echo "\e[31mError: can-utils not detected in the system.\e[0m" - echo "Please use the following command to install ethtool:" - echo "sudo apt update && sudo apt install can-utils" - exit 1 -fi - -echo "Both ethtool and can-utils are installed." +# The helper uses iproute2's ip command and ethtool directly. Check commands +# instead of distribution-specific package metadata. +for command in ip ethtool; do + if ! command -v "$command" >/dev/null 2>&1; then + echo "\e[31mError: $command is required but was not found.\e[0m" + echo "Install $command with your distribution's package manager." + exit 1 + fi +done # Retrieve the number of CAN modules in the current system. CURRENT_CAN_COUNT=$(ip link show type can | grep -c "link/can") From f2b2a24018edaa3823f465b7c5fc622dc6c0f117 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:22:53 +0000 Subject: [PATCH 47/51] [autofix.ci] apply automated fixes --- dimos/robot/manipulators/test_blueprints.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dimos/robot/manipulators/test_blueprints.py b/dimos/robot/manipulators/test_blueprints.py index 109952d949..8738b00b61 100644 --- a/dimos/robot/manipulators/test_blueprints.py +++ b/dimos/robot/manipulators/test_blueprints.py @@ -29,13 +29,13 @@ keyboard_teleop_openarm, keyboard_teleop_openarm_mock, ) +from dimos.robot.manipulators.openyam.blueprints.teleop import ( + keyboard_teleop_openyam, +) from dimos.robot.manipulators.piper.blueprints.teleop import ( coordinator_teleop_piper, keyboard_teleop_piper, ) -from dimos.robot.manipulators.openyam.blueprints.teleop import ( - keyboard_teleop_openyam, -) from dimos.robot.manipulators.xarm.blueprints.basic import ( dual_xarm6_planner, xarm6_planner_only, From 114472004557e8fcfc51fe7b97e5404f41659593 Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 21 Jul 2026 22:39:39 -0700 Subject: [PATCH 48/51] feat: add Piper CAN activation command --- MANIFEST.in | 1 + dimos/hardware/manipulators/piper/adapter.py | 99 ++++++++++--------- dimos/robot/cli/dimos.py | 2 + dimos/robot/cli/piper.py | 50 ++++++++++ dimos/robot/cli/test_piper.py | 60 +++++++++++ dimos/robot/manipulators/piper/test_config.py | 71 ------------- dimos/robot/manipulators/test_blueprints.py | 19 ---- .../manipulators/xarm/blueprints/teleop.py | 29 +----- dimos/robot/manipulators/xarm/config.py | 1 + dimos/robot/manipulators/xarm/test_config.py | 38 ------- .../teleop/keyboard/keyboard_teleop_module.py | 9 +- pyproject.toml | 1 + stubs/pygame/__init__.pyi | 2 + 13 files changed, 176 insertions(+), 206 deletions(-) create mode 100644 dimos/robot/cli/piper.py create mode 100644 dimos/robot/cli/test_piper.py delete mode 100644 dimos/robot/manipulators/piper/test_config.py delete mode 100644 dimos/robot/manipulators/xarm/test_config.py diff --git a/MANIFEST.in b/MANIFEST.in index 1536332725..704c5e6641 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,6 +9,7 @@ global-exclude .DS_Store # runtime data and must stay out. Add a new extension here when code starts # loading a new data type — never go back to per-package globs. recursive-include dimos *.yaml *.yml *.json *.urdf *.html *.css *.js *.svg *.tcss +recursive-include dimos/robot/manipulators/piper/scripts can_activate.sh LICENSE # --- Exclusions (must come after the includes above so they win) --- # Test fixtures must never ship. diff --git a/dimos/hardware/manipulators/piper/adapter.py b/dimos/hardware/manipulators/piper/adapter.py index ea717f1026..5313d9864d 100644 --- a/dimos/hardware/manipulators/piper/adapter.py +++ b/dimos/hardware/manipulators/piper/adapter.py @@ -50,6 +50,8 @@ SHUTDOWN_TIMEOUT = 5.0 STARTUP_RESET_WAIT = 0.5 STARTUP_ZERO_WAIT = 1.0 +ENABLE_RETRY_COUNT = 50 +ENABLE_RETRY_INTERVAL = 0.01 # Default configurable parameters DEFAULT_GRIPPER_SPEED = 1000 @@ -111,13 +113,12 @@ def connect(self) -> bool: self._close_failed_connection() return False self._connected = True - print(f"Piper connected via CAN port {self._can_port}") + logger.info("Piper connected", can_port=self._can_port) return True - else: - print(f"ERROR: Failed to connect to Piper on {self._can_port} - no status received") - return False - except Exception as e: - print(f"ERROR: Failed to connect to Piper on {self._can_port}: {e}") + logger.error("Failed to connect to Piper: no status received", can_port=self._can_port) + return False + except Exception: + logger.exception("Failed to connect to Piper", can_port=self._can_port) return False def _initialize_startup_state(self) -> bool: @@ -161,12 +162,12 @@ def _enable_piper(self) -> bool: if sdk is None: return False try: - for attempt in range(50): + for attempt in range(ENABLE_RETRY_COUNT): if sdk.EnablePiper(): self._enabled = True return True - if attempt < 49: - time.sleep(0.01) + if attempt < ENABLE_RETRY_COUNT - 1: + time.sleep(ENABLE_RETRY_INTERVAL) except Exception: logger.exception("Piper SDK enable command failed") return False @@ -243,6 +244,7 @@ def is_connected(self) -> bool: status = self._sdk.GetArmStatus() return status is not None except Exception: + logger.exception("Piper arm status query failed") return False def activate(self) -> bool: @@ -313,6 +315,7 @@ def set_control_mode(self, mode: ControlMode) -> bool: self._control_mode = mode return True except Exception: + logger.exception("Failed to set Piper control mode") return False def get_control_mode(self) -> ControlMode: @@ -431,7 +434,7 @@ def write_joint_positions( is_mit_mode=0x00, ) except Exception: - pass + logger.exception("Failed to set Piper motion speed") try: self._sdk.JointCtrl( @@ -443,8 +446,8 @@ def write_joint_positions( piper_joints[5], ) return True - except Exception as e: - print(f"Piper joint control error: {e}") + except Exception: + logger.exception("Piper joint control failed") return False def write_joint_velocities(self, velocities: list[float]) -> bool: @@ -464,6 +467,7 @@ def write_stop(self) -> bool: self._sdk.MotionCtrl_1(0x01, 0, 0) return True except Exception: + logger.exception("Failed to stop Piper motion") return False def _move_to_zero_position(self) -> bool: @@ -480,6 +484,7 @@ def _move_to_zero_position(self) -> bool: ) self._sdk.JointCtrl(0, 0, 0, 0, 0, 0) except Exception: + logger.exception("Failed to command Piper zero position") return False deadline = time.monotonic() + SHUTDOWN_TIMEOUT @@ -491,13 +496,14 @@ def _move_to_zero_position(self) -> bool: ): return True except Exception: + logger.exception("Failed to read Piper position during shutdown") return False time.sleep(SHUTDOWN_POLL_INTERVAL) return False def _initialize_gripper(self) -> bool: """Initialize the gripper in its enabled, closed position.""" - if not self._sdk or not hasattr(self._sdk, "GripperCtrl"): + if self._sdk is None: return False try: self._sdk.GripperCtrl(0, self._gripper_speed, GRIPPER_DISABLE_CODE, 0) @@ -505,16 +511,18 @@ def _initialize_gripper(self) -> bool: self._gripper_initialized = True return True except Exception: + logger.exception("Failed to initialize Piper gripper") return False def _deactivate_gripper(self) -> bool: """Disable gripper control before disconnecting the arm.""" - if not self._sdk or not hasattr(self._sdk, "GripperCtrl"): + if self._sdk is None: return True try: self._sdk.GripperCtrl(0, self._gripper_speed, GRIPPER_DISABLE_CODE, 0) return True except Exception: + logger.exception("Failed to deactivate Piper gripper") return False def write_enable(self, enable: bool) -> bool: @@ -540,6 +548,7 @@ def write_enable(self, enable: bool) -> bool: self._enabled = False return True except Exception: + logger.exception("Failed to change Piper enable state", enable=enable) return False def read_enabled(self) -> bool: @@ -552,11 +561,10 @@ def write_clear_errors(self) -> bool: return False try: - if hasattr(self._sdk, "ClearError"): - self._sdk.ClearError() - return True + self._sdk.ClearError() + return True except Exception: - pass + logger.exception("Failed to clear Piper errors") # Alternative: disable and re-enable self.write_enable(False) @@ -573,18 +581,17 @@ def read_cartesian_position(self) -> dict[str, float] | None: return None try: - if hasattr(self._sdk, "GetArmEndPoseMsgs"): - pose_msgs = self._sdk.GetArmEndPoseMsgs() - if pose_msgs and pose_msgs.end_pose: - ep = pose_msgs.end_pose - return { - "x": ep.X_axis * MM_TO_M, - "y": ep.Y_axis * MM_TO_M, - "z": ep.Z_axis * MM_TO_M, - "roll": ep.RX_axis * MILLIDEG_TO_RAD, - "pitch": ep.RY_axis * MILLIDEG_TO_RAD, - "yaw": ep.RZ_axis * MILLIDEG_TO_RAD, - } + pose_msgs = self._sdk.GetArmEndPoseMsgs() + if pose_msgs and pose_msgs.end_pose: + ep = pose_msgs.end_pose + return { + "x": ep.X_axis * MM_TO_M, + "y": ep.Y_axis * MM_TO_M, + "z": ep.Z_axis * MM_TO_M, + "roll": ep.RX_axis * MILLIDEG_TO_RAD, + "pitch": ep.RY_axis * MILLIDEG_TO_RAD, + "yaw": ep.RZ_axis * MILLIDEG_TO_RAD, + } except Exception: pass @@ -608,15 +615,14 @@ def read_gripper_position(self) -> float | None: return None try: - if hasattr(self._sdk, "GetArmGripperMsgs"): - gripper_msgs = self._sdk.GetArmGripperMsgs() - if gripper_msgs and gripper_msgs.gripper_state: - # Piper gripper position is in 0.001 mm units. - pos: float = gripper_msgs.gripper_state.grippers_angle - return min( - GRIPPER_MAX_OPENING_M, - max(0.0, pos / GRIPPER_STROKE_UNITS_PER_M), - ) + gripper_msgs = self._sdk.GetArmGripperMsgs() + if gripper_msgs and gripper_msgs.gripper_state: + # Piper gripper position is in 0.001 mm units. + pos: float = gripper_msgs.gripper_state.grippers_angle + return min( + GRIPPER_MAX_OPENING_M, + max(0.0, pos / GRIPPER_STROKE_UNITS_PER_M), + ) except Exception: pass @@ -628,14 +634,13 @@ def write_gripper_position(self, position: float) -> bool: return False try: - if hasattr(self._sdk, "GripperCtrl"): - if not self._gripper_initialized and not self._initialize_gripper(): - return False - gripper_position = round( - max(0.0, min(GRIPPER_MAX_OPENING_M, position)) * GRIPPER_STROKE_UNITS_PER_M - ) - self._sdk.GripperCtrl(gripper_position, self._gripper_speed, 0x01, 0) - return True + if not self._gripper_initialized and not self._initialize_gripper(): + return False + gripper_position = round( + max(0.0, min(GRIPPER_MAX_OPENING_M, position)) * GRIPPER_STROKE_UNITS_PER_M + ) + self._sdk.GripperCtrl(gripper_position, self._gripper_speed, 0x01, 0) + return True except Exception: pass diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 773d97d248..e21a292061 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -42,6 +42,7 @@ from dimos.mapping.utils.cli.rename import main as _map_rename_main from dimos.mapping.utils.cli.replay import main as _map_replay_main from dimos.mapping.utils.cli.replay_marker import main as _map_replay_marker_main +from dimos.robot.cli.piper import app as piper_app from dimos.robot.unitree.go2.cli.go2tool import app as go2tool_app from dimos.utils.logging_config import setup_logger from dimos.visualization.rerun.constants import RerunOpenOption @@ -153,6 +154,7 @@ def callback(**kwargs) -> None: # type: ignore[no-untyped-def] main.callback()(create_dynamic_callback()) # type: ignore[no-untyped-call] main.add_typer(go2tool_app, name="go2tool") +main.add_typer(piper_app, name="piper") def arg_help( diff --git a/dimos/robot/cli/piper.py b/dimos/robot/cli/piper.py new file mode 100644 index 0000000000..1ab9852882 --- /dev/null +++ b/dimos/robot/cli/piper.py @@ -0,0 +1,50 @@ +# Copyright 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 collections.abc import Iterator +from contextlib import contextmanager +from importlib import resources +from pathlib import Path +import subprocess + +import typer + +app = typer.Typer(help="Piper robot commands") + + +@contextmanager +def _can_activation_helper() -> Iterator[Path]: + """Materialize the bundled CAN helper for this invocation.""" + package = resources.files("dimos.robot.manipulators.piper.scripts") + with resources.as_file(package / "can_activate.sh") as helper: + yield helper + + +@app.command("can-activate") +def can_activate( + interface: str = typer.Argument(..., help="CAN interface to configure"), + bitrate: int = typer.Option(1_000_000, "--bitrate", help="CAN bitrate"), +) -> None: + """Configure a Piper CAN interface using the bundled helper.""" + if not typer.confirm( + "This will request sudo to configure CAN. Continue?", + default=False, + ): + typer.echo("Aborted.") + raise typer.Exit(1) + + with _can_activation_helper() as helper: + subprocess.run([str(helper), interface, str(bitrate)], check=True) diff --git a/dimos/robot/cli/test_piper.py b/dimos/robot/cli/test_piper.py new file mode 100644 index 0000000000..33ddfe80d5 --- /dev/null +++ b/dimos/robot/cli/test_piper.py @@ -0,0 +1,60 @@ +# Copyright 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 unittest.mock import Mock + +from typer.testing import CliRunner + +from dimos.robot.cli import piper + +runner = CliRunner() + + +def test_can_activate_confirms_before_spawning(monkeypatch): + confirm = Mock(return_value=True) + run = Mock() + monkeypatch.setattr(piper.typer, "confirm", confirm) + monkeypatch.setattr(piper.subprocess, "run", run) + + result = runner.invoke(piper.app, ["can1", "--bitrate", "500000"]) + + assert result.exit_code == 0, result.output + confirm.assert_called_once() + run.assert_called_once() + command = run.call_args.args[0] + assert command[0].endswith("can_activate.sh") + assert command[1:] == ["can1", "500000"] + assert run.call_args.kwargs == {"check": True} + + +def test_can_activate_rejection_does_not_spawn(monkeypatch): + confirm = Mock(return_value=False) + run = Mock() + monkeypatch.setattr(piper.typer, "confirm", confirm) + monkeypatch.setattr(piper.subprocess, "run", run) + + result = runner.invoke(piper.app, ["can0"]) + + assert result.exit_code == 1 + assert "Aborted." in result.output + run.assert_not_called() + + +def test_bundled_helper_and_license_are_available(): + package = piper.resources.files("dimos.robot.manipulators.piper.scripts") + + with piper._can_activation_helper() as helper: + assert helper.name == "can_activate.sh" + assert helper.is_file() + assert (package / "LICENSE").is_file() diff --git a/dimos/robot/manipulators/piper/test_config.py b/dimos/robot/manipulators/piper/test_config.py deleted file mode 100644 index e699ad2734..0000000000 --- a/dimos/robot/manipulators/piper/test_config.py +++ /dev/null @@ -1,71 +0,0 @@ -# Copyright 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 pathlib import Path - -from dimos.core.global_config import global_config -from dimos.robot.manipulators.piper import config as piper_config -from dimos.robot.manipulators.piper.config import ( - PIPER_HOME_JOINTS, - make_piper_model_config, - piper_hardware, -) - - -def test_piper_model_config_uses_default_home_joints() -> None: - config = make_piper_model_config() - - assert config.home_joints == PIPER_HOME_JOINTS - - -def test_piper_model_config_uses_supplied_home_joints() -> None: - home_joints = [0.1, 0.2, 0.3, 0.4, 0.5, 0.6] - config = make_piper_model_config(home_joints=home_joints) - - assert config.home_joints == home_joints - - -def test_piper_defaults_to_mock_without_can_port(monkeypatch) -> None: - monkeypatch.setattr(global_config, "simulation", "") - - for can_port in (None, ""): - monkeypatch.setattr(global_config, "can_port", can_port) - hardware = piper_hardware() - - assert hardware.adapter_type == "mock" - assert hardware.address is None - - -def test_piper_uses_configured_can_port(monkeypatch) -> None: - can_port = "can7" - monkeypatch.setattr(global_config, "simulation", "") - monkeypatch.setattr(global_config, "can_port", can_port) - - hardware = piper_hardware() - - assert hardware.adapter_type == "piper" - assert hardware.address == can_port - - -def test_piper_simulation_selection_is_unchanged(monkeypatch) -> None: - monkeypatch.setattr(global_config, "simulation", "mujoco") - monkeypatch.setattr(global_config, "can_port", "can7") - # Avoid resolving the LFS-backed scene path just to inspect selection. - simulation_path = Path("piper/scene.xml") - monkeypatch.setattr(piper_config, "PIPER_SIM_PATH", simulation_path) - - hardware = piper_hardware() - - assert hardware.adapter_type == "sim_mujoco" - assert hardware.address == str(simulation_path) diff --git a/dimos/robot/manipulators/test_blueprints.py b/dimos/robot/manipulators/test_blueprints.py index 8738b00b61..afb7ba94a6 100644 --- a/dimos/robot/manipulators/test_blueprints.py +++ b/dimos/robot/manipulators/test_blueprints.py @@ -116,25 +116,6 @@ def test_piper_keyboard_declares_high_priority_gripper_servo() -> None: assert trajectory.type == "trajectory" -def test_piper_keyboard_declares_gripper_endpoints_and_light_keyboard_kwargs() -> None: - hardware = _module_kwargs(keyboard_teleop_piper, ControlCoordinator)["hardware"][0] - keyboard_kwargs = _module_kwargs(keyboard_teleop_piper, KeyboardTeleopModule) - - assert keyboard_kwargs == {} - assert (hardware.gripper_closed_position, hardware.gripper_open_position) == (0.0, 0.07) - - -def test_piper_quest_declares_normalized_gripper_endpoints() -> None: - hardware = _module_kwargs(coordinator_teleop_piper, ControlCoordinator)["hardware"][0] - task = next( - task for task in _coordinator_tasks(coordinator_teleop_piper) if task.name == "teleop_piper" - ) - - assert (hardware.gripper_closed_position, hardware.gripper_open_position) == (0.0, 0.07) - assert task.params["gripper_open_pos"] == 1.0 - assert task.params["gripper_closed_pos"] == 0.0 - - def test_planner_helper_defaults_to_no_visualization() -> None: blueprint = planner(robots=[make_xarm7_model_config(name="arm", add_gripper=True)]) diff --git a/dimos/robot/manipulators/xarm/blueprints/teleop.py b/dimos/robot/manipulators/xarm/blueprints/teleop.py index 2029ec5bda..c03867c8c5 100644 --- a/dimos/robot/manipulators/xarm/blueprints/teleop.py +++ b/dimos/robot/manipulators/xarm/blueprints/teleop.py @@ -16,7 +16,6 @@ from __future__ import annotations -from dimos.control.components import make_gripper_joints from dimos.control.coordinator import ControlCoordinator, TaskConfig from dimos.core.coordination.blueprints import autoconnect from dimos.core.global_config import global_config @@ -160,24 +159,14 @@ hand="right", name="teleop_xarm", priority=20, - params={ - "gripper_joint": make_gripper_joints("arm")[0], - "gripper_open_pos": 1.0, - "gripper_closed_pos": 0.0, - "max_joint_delta_deg": 50.0, - }, + params=XARM_GRIPPER_PARAMS, ), eef_twist_task( _xarm7_teleop_hw, model_path=XARM7_FK_MODEL, ee_joint_id=7, priority=10, - params={ - "gripper_joint": make_gripper_joints("arm")[0], - "gripper_open_pos": 1.0, - "gripper_closed_pos": 0.0, - "max_joint_delta_deg": 50.0, - }, + params=XARM_GRIPPER_PARAMS, ), ], ), @@ -195,24 +184,14 @@ hand="right", name="teleop_xarm", priority=20, - params={ - "gripper_joint": make_gripper_joints("arm")[0], - "gripper_open_pos": 1.0, - "gripper_closed_pos": 0.0, - "max_joint_delta_deg": 50.0, - }, + params=XARM_GRIPPER_PARAMS, ), eef_twist_task( _xarm6_teleop_hw, model_path=XARM6_FK_MODEL, ee_joint_id=6, priority=10, - params={ - "gripper_joint": make_gripper_joints("arm")[0], - "gripper_open_pos": 1.0, - "gripper_closed_pos": 0.0, - "max_joint_delta_deg": 50.0, - }, + params=XARM_GRIPPER_PARAMS, ), ], ), diff --git a/dimos/robot/manipulators/xarm/config.py b/dimos/robot/manipulators/xarm/config.py index b7cd2ccdbb..f74b82da6b 100644 --- a/dimos/robot/manipulators/xarm/config.py +++ b/dimos/robot/manipulators/xarm/config.py @@ -63,6 +63,7 @@ "gripper_joint": make_gripper_joints("arm")[0], "gripper_open_pos": 0.85, "gripper_closed_pos": 0.0, + "max_joint_delta_deg": 50.0, } XARM7_SIM_HOME = [0.0, -0.247, 0.0, 0.909, 0.0, 1.15644, 0.0] diff --git a/dimos/robot/manipulators/xarm/test_config.py b/dimos/robot/manipulators/xarm/test_config.py deleted file mode 100644 index d7106b2f5c..0000000000 --- a/dimos/robot/manipulators/xarm/test_config.py +++ /dev/null @@ -1,38 +0,0 @@ -# Copyright 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 dimos.core.global_config import global_config -from dimos.robot.manipulators.xarm.config import xarm6_hardware, xarm7_hardware - - -def test_xarm_mock_factories_configure_gripper_endpoints(monkeypatch) -> None: - monkeypatch.setattr(global_config, "simulation", "") - monkeypatch.setattr(global_config, "xarm6_ip", "") - monkeypatch.setattr(global_config, "xarm7_ip", "") - - for hardware in ( - xarm6_hardware( - gripper=True, - gripper_open_position=0.85, - gripper_closed_position=0.0, - mock_without_address=True, - ), - xarm7_hardware( - gripper=True, - gripper_open_position=0.85, - gripper_closed_position=0.0, - mock_without_address=True, - ), - ): - assert (hardware.gripper_closed_position, hardware.gripper_open_position) == (0.0, 0.85) diff --git a/dimos/teleop/keyboard/keyboard_teleop_module.py b/dimos/teleop/keyboard/keyboard_teleop_module.py index 85e4ef3e7b..80ad6e1471 100644 --- a/dimos/teleop/keyboard/keyboard_teleop_module.py +++ b/dimos/teleop/keyboard/keyboard_teleop_module.py @@ -41,7 +41,7 @@ pygame = None # type: ignore[assignment] if TYPE_CHECKING: - from pygame.key import ScancodeWrapper # type: ignore[attr-defined] + from pygame.key import _ScancodeWrapper from dimos.constants import DEFAULT_THREAD_JOIN_TIMEOUT from dimos.core.core import rpc @@ -101,10 +101,7 @@ def _gripper_key_codes() -> tuple[int, int]: """Return pygame's bracket key codes without relying on stub attributes.""" if pygame is None: return (-1, -1) - return ( - pygame.K_LEFTBRACKET, # type: ignore[attr-defined] - pygame.K_RIGHTBRACKET, # type: ignore[attr-defined] - ) + return pygame.K_LEFTBRACKET, pygame.K_RIGHTBRACKET class KeyboardTeleopModule(Module): @@ -267,7 +264,7 @@ def _set_gripper_position(self, position: float) -> None: def _twist_from_keys( - keys: ScancodeWrapper | set[int], + keys: _ScancodeWrapper | set[int], *, linear_speed: float, angular_speed: float, diff --git a/pyproject.toml b/pyproject.toml index 8f95173e5d..1c74312169 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ exclude = [ "**/*.svg", "**/*.tcss", "**/*.pyi", + "robot/manipulators/piper/scripts/*", ] [tool.setuptools.exclude-package-data] diff --git a/stubs/pygame/__init__.pyi b/stubs/pygame/__init__.pyi index 2784e8f506..4f98342e93 100644 --- a/stubs/pygame/__init__.pyi +++ b/stubs/pygame/__init__.pyi @@ -59,6 +59,8 @@ K_LCTRL: int K_LSHIFT: int K_RCTRL: int K_RSHIFT: int +K_LEFTBRACKET: int +K_RIGHTBRACKET: int # --- submodules -------------------------------------------------------- From 3afbb1a4e6b8d23d55d369dbc3a01b4b83755b2d Mon Sep 17 00:00:00 2001 From: cc Date: Tue, 21 Jul 2026 23:21:59 -0700 Subject: [PATCH 49/51] refactor: inline Piper CAN activation --- MANIFEST.in | 1 - dimos/robot/cli/piper.py | 23 ++- dimos/robot/cli/test_piper.py | 32 +++-- .../robot/manipulators/piper/scripts/LICENSE | 21 --- .../piper/scripts/can_activate.sh | 136 ------------------ .../manipulation/piper_integration.md | 21 ++- pyproject.toml | 1 - 7 files changed, 43 insertions(+), 192 deletions(-) delete mode 100644 dimos/robot/manipulators/piper/scripts/LICENSE delete mode 100755 dimos/robot/manipulators/piper/scripts/can_activate.sh diff --git a/MANIFEST.in b/MANIFEST.in index 704c5e6641..1536332725 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,7 +9,6 @@ global-exclude .DS_Store # runtime data and must stay out. Add a new extension here when code starts # loading a new data type — never go back to per-package globs. recursive-include dimos *.yaml *.yml *.json *.urdf *.html *.css *.js *.svg *.tcss -recursive-include dimos/robot/manipulators/piper/scripts can_activate.sh LICENSE # --- Exclusions (must come after the includes above so they win) --- # Test fixtures must never ship. diff --git a/dimos/robot/cli/piper.py b/dimos/robot/cli/piper.py index 1ab9852882..0e22fd5eea 100644 --- a/dimos/robot/cli/piper.py +++ b/dimos/robot/cli/piper.py @@ -14,10 +14,6 @@ from __future__ import annotations -from collections.abc import Iterator -from contextlib import contextmanager -from importlib import resources -from pathlib import Path import subprocess import typer @@ -25,20 +21,12 @@ app = typer.Typer(help="Piper robot commands") -@contextmanager -def _can_activation_helper() -> Iterator[Path]: - """Materialize the bundled CAN helper for this invocation.""" - package = resources.files("dimos.robot.manipulators.piper.scripts") - with resources.as_file(package / "can_activate.sh") as helper: - yield helper - - @app.command("can-activate") def can_activate( interface: str = typer.Argument(..., help="CAN interface to configure"), bitrate: int = typer.Option(1_000_000, "--bitrate", help="CAN bitrate"), ) -> None: - """Configure a Piper CAN interface using the bundled helper.""" + """Configure an existing Piper SocketCAN interface.""" if not typer.confirm( "This will request sudo to configure CAN. Continue?", default=False, @@ -46,5 +34,10 @@ def can_activate( typer.echo("Aborted.") raise typer.Exit(1) - with _can_activation_helper() as helper: - subprocess.run([str(helper), interface, str(bitrate)], check=True) + commands = [ + ["sudo", "ip", "link", "set", interface, "down"], + ["sudo", "ip", "link", "set", interface, "type", "can", "bitrate", str(bitrate)], + ["sudo", "ip", "link", "set", interface, "up"], + ] + for command in commands: + subprocess.run(command, check=True) diff --git a/dimos/robot/cli/test_piper.py b/dimos/robot/cli/test_piper.py index 33ddfe80d5..71d3fef9d0 100644 --- a/dimos/robot/cli/test_piper.py +++ b/dimos/robot/cli/test_piper.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from unittest.mock import Mock +from unittest.mock import Mock, call from typer.testing import CliRunner @@ -31,11 +31,14 @@ def test_can_activate_confirms_before_spawning(monkeypatch): assert result.exit_code == 0, result.output confirm.assert_called_once() - run.assert_called_once() - command = run.call_args.args[0] - assert command[0].endswith("can_activate.sh") - assert command[1:] == ["can1", "500000"] - assert run.call_args.kwargs == {"check": True} + assert run.call_args_list == [ + call(["sudo", "ip", "link", "set", "can1", "down"], check=True), + call( + ["sudo", "ip", "link", "set", "can1", "type", "can", "bitrate", "500000"], + check=True, + ), + call(["sudo", "ip", "link", "set", "can1", "up"], check=True), + ] def test_can_activate_rejection_does_not_spawn(monkeypatch): @@ -51,10 +54,15 @@ def test_can_activate_rejection_does_not_spawn(monkeypatch): run.assert_not_called() -def test_bundled_helper_and_license_are_available(): - package = piper.resources.files("dimos.robot.manipulators.piper.scripts") +def test_can_activate_uses_default_bitrate(monkeypatch): + monkeypatch.setattr(piper.typer, "confirm", Mock(return_value=True)) + run = Mock() + monkeypatch.setattr(piper.subprocess, "run", run) - with piper._can_activation_helper() as helper: - assert helper.name == "can_activate.sh" - assert helper.is_file() - assert (package / "LICENSE").is_file() + result = runner.invoke(piper.app, ["can0"]) + + assert result.exit_code == 0, result.output + assert run.call_args_list[1] == call( + ["sudo", "ip", "link", "set", "can0", "type", "can", "bitrate", "1000000"], + check=True, + ) diff --git a/dimos/robot/manipulators/piper/scripts/LICENSE b/dimos/robot/manipulators/piper/scripts/LICENSE deleted file mode 100644 index cc8ae3326e..0000000000 --- a/dimos/robot/manipulators/piper/scripts/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Agilex Robotice Co., Ltd. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/dimos/robot/manipulators/piper/scripts/can_activate.sh b/dimos/robot/manipulators/piper/scripts/can_activate.sh deleted file mode 100755 index aa4270365c..0000000000 --- a/dimos/robot/manipulators/piper/scripts/can_activate.sh +++ /dev/null @@ -1,136 +0,0 @@ -#!/bin/bash -# Vendored helper; functional body retained unchanged from upstream. -# Copyright (c) Agilex Robotice Co., Ltd. -# SPDX-License-Identifier: MIT -# See LICENSE in this directory for the full license text. -# Source: https://raw.githubusercontent.com/agilexrobotics/piper_sdk/4eddfcf8/can_activate.sh -# Upstream commit: 4eddfcf8 (blob SHA-1: 60cc95e7ea51da662884a7400f563bcdef5af9b8) - -# The default CAN name can be set by the user via command-line parameters. -DEFAULT_CAN_NAME="${1:-can0}" - -# The default bitrate for a single CAN module can be set by the user via command-line parameters. -DEFAULT_BITRATE="${2:-1000000}" - -# USB hardware address (optional parameter) -USB_ADDRESS="${3}" -echo "-------------------START-----------------------" -# The helper uses iproute2's ip command and ethtool directly. Check commands -# instead of distribution-specific package metadata. -for command in ip ethtool; do - if ! command -v "$command" >/dev/null 2>&1; then - echo "\e[31mError: $command is required but was not found.\e[0m" - echo "Install $command with your distribution's package manager." - exit 1 - fi -done - -# Retrieve the number of CAN modules in the current system. -CURRENT_CAN_COUNT=$(ip link show type can | grep -c "link/can") - -# Verify if the number of CAN modules in the current system matches the expected value. -if [ "$CURRENT_CAN_COUNT" -ne "1" ]; then - if [ -z "$USB_ADDRESS" ]; then - # Iterate through all CAN interfaces. - for iface in $(ip -br link show type can | awk '{print $1}'); do - # Use ethtool to retrieve bus-info. - BUS_INFO=$(sudo ethtool -i "$iface" | grep "bus-info" | awk '{print $2}') - - if [ -z "$BUS_INFO" ];then - echo "Error: Unable to retrieve bus-info for interface $iface." - continue - fi - - echo "Interface $iface is inserted into USB port $BUS_INFO" - done - echo -e " \e[31m Error: The number of CAN modules detected by the system ($CURRENT_CAN_COUNT) does not match the expected number (1). \e[0m" - echo -e " \e[31m Please add the USB hardware address parameter, such as: \e[0m" - echo -e " bash can_activate.sh can0 1000000 1-2:1.0" - echo "-------------------ERROR-----------------------" - exit 1 - fi -fi - -# Load the gs_usb module. -# sudo modprobe gs_usb -# if [ $? -ne 0 ]; then -# echo "Error: Unable to load the gs_usb module." -# exit 1 -# fi - -if [ -n "$USB_ADDRESS" ]; then - echo "Detected USB hardware address parameter: $USB_ADDRESS" - - # Use ethtool to find the CAN interface corresponding to the USB hardware address. - INTERFACE_NAME="" - for iface in $(ip -br link show type can | awk '{print $1}'); do - BUS_INFO=$(sudo ethtool -i "$iface" | grep "bus-info" | awk '{print $2}') - if [ "$BUS_INFO" = "$USB_ADDRESS" ]; then - INTERFACE_NAME="$iface" - break - fi - done - - if [ -z "$INTERFACE_NAME" ]; then - echo "Error: Unable to find CAN interface corresponding to USB hardware address $USB_ADDRESS." - exit 1 - else - echo "Found the interface corresponding to USB hardware address $USB_ADDRESS: $INTERFACE_NAME." - fi -else - # Retrieve the unique CAN interface. - INTERFACE_NAME=$(ip -br link show type can | awk '{print $1}') - - # Check if the interface name has been retrieved. - if [ -z "$INTERFACE_NAME" ]; then - echo "Error: Unable to detect CAN interface." - exit 1 - fi - BUS_INFO=$(sudo ethtool -i "$INTERFACE_NAME" | grep "bus-info" | awk '{print $2}') - echo "Expected to configure a single CAN module, detected interface $INTERFACE_NAME with corresponding USB address $BUS_INFO." -fi - -# Check if the current interface is already activated. -IS_LINK_UP=$(ip link show "$INTERFACE_NAME" | grep -q "UP" && echo "yes" || echo "no") - -# Retrieve the bitrate of the current interface. -CURRENT_BITRATE=$(ip -details link show "$INTERFACE_NAME" | grep -oP 'bitrate \K\d+') - -if [ "$IS_LINK_UP" = "yes" ] && [ "$CURRENT_BITRATE" -eq "$DEFAULT_BITRATE" ]; then - echo "Interface $INTERFACE_NAME is already activated with a bitrate of $DEFAULT_BITRATE." - - # Check if the interface name matches the default name. - if [ "$INTERFACE_NAME" != "$DEFAULT_CAN_NAME" ]; then - echo "Rename interface $INTERFACE_NAME to $DEFAULT_CAN_NAME." - sudo ip link set "$INTERFACE_NAME" down - sudo ip link set "$INTERFACE_NAME" name "$DEFAULT_CAN_NAME" - sudo ip link set "$DEFAULT_CAN_NAME" up - echo "The interface has been renamed to $DEFAULT_CAN_NAME and reactivated." - else - echo "The interface name is already $DEFAULT_CAN_NAME." - fi -else - # If the interface is not activated or the bitrate is different, configure it. - if [ "$IS_LINK_UP" = "yes" ]; then - echo "Interface $INTERFACE_NAME is already activated, but the bitrate is $CURRENT_BITRATE, which does not match the set value of $DEFAULT_BITRATE." - else - echo "Interface $INTERFACE_NAME is not activated or bitrate is not set." - fi - - # Set the interface bitrate and activate it. - sudo ip link set "$INTERFACE_NAME" down - sudo ip link set "$INTERFACE_NAME" type can bitrate $DEFAULT_BITRATE - sudo ip link set "$INTERFACE_NAME" up - echo "Interface $INTERFACE_NAME has been reset to bitrate $DEFAULT_BITRATE and activated." - - # Rename the interface to the default name. - if [ "$INTERFACE_NAME" != "$DEFAULT_CAN_NAME" ]; then - echo "Rename interface $INTERFACE_NAME to $DEFAULT_CAN_NAME." - sudo ip link set "$INTERFACE_NAME" down - sudo ip link set "$INTERFACE_NAME" name "$DEFAULT_CAN_NAME" - sudo ip link set "$DEFAULT_CAN_NAME" up - echo "The interface has been renamed to $DEFAULT_CAN_NAME and reactivated." - fi -fi - -echo "-------------------OVER------------------------" diff --git a/docs/capabilities/manipulation/piper_integration.md b/docs/capabilities/manipulation/piper_integration.md index 0f892c7403..4df8656ba3 100644 --- a/docs/capabilities/manipulation/piper_integration.md +++ b/docs/capabilities/manipulation/piper_integration.md @@ -12,17 +12,26 @@ sudo slcand -o -c -s8 /dev/ttyACM0 can0 sudo ip link set can0 up ``` -## Bring up the Piper CAN interface +This is a separate prerequisite for serial-CAN adapters. It is not needed when +the Piper adapter already exposes a native SocketCAN interface. + +## Bring up a native Piper CAN interface Piper uses SocketCAN at 1,000,000 bit/s. For the default vendor setup, use -DimOS's vendored copy of the upstream activation helper: +the DimOS CLI to configure an existing CAN interface and bring it up: + +```bash +dimos piper can-activate can0 +``` + +For a non-default bitrate, pass `--bitrate` explicitly: ```bash -bash dimos/robot/manipulators/piper/scripts/can_activate.sh can0 1000000 +dimos piper can-activate can0 --bitrate 500000 ``` -If the device already exposes `can0`, run the vendored helper directly. Verify -the interface before starting a blueprint: +The command asks for confirmation before requesting sudo. Verify the interface +before starting a blueprint: ```bash ip link show can0 @@ -45,7 +54,7 @@ dimos --can-port can0 run keyboard-teleop-piper The Quest teleoperation composition is available as: ```bash -dimos --can-port can0 run keyboard-teleop-piper +dimos --can-port can0 run teleop-quest-piper ``` Note that ommitting the `--can-port` argument will fallback the control coordinator to use fake hardware adapter. This is good for testing. diff --git a/pyproject.toml b/pyproject.toml index 1c74312169..8f95173e5d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,6 @@ exclude = [ "**/*.svg", "**/*.tcss", "**/*.pyi", - "robot/manipulators/piper/scripts/*", ] [tool.setuptools.exclude-package-data] From 90d4a12575a5ad44d45bf72d7d48ee9a0346a1d7 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 21:17:38 +0000 Subject: [PATCH 50/51] [autofix.ci] apply automated fixes --- dimos/robot/cli/dimos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dimos/robot/cli/dimos.py b/dimos/robot/cli/dimos.py index 1bf4cf93c6..8a6a2024c6 100644 --- a/dimos/robot/cli/dimos.py +++ b/dimos/robot/cli/dimos.py @@ -42,8 +42,8 @@ from dimos.mapping.utils.cli.rename import main as _map_rename_main from dimos.mapping.utils.cli.replay import main as _map_replay_main from dimos.mapping.utils.cli.replay_marker import main as _map_replay_marker_main -from dimos.robot.manipulators.galaxea_a1z.teach_replay_cli import app as a1z_app from dimos.robot.cli.piper import app as piper_app +from dimos.robot.manipulators.galaxea_a1z.teach_replay_cli import app as a1z_app from dimos.robot.unitree.go2.cli.go2tool import app as go2tool_app from dimos.utils.logging_config import setup_logger from dimos.visualization.rerun.constants import RerunOpenOption From 75d58ec9adffe07ed3dbffb6db25ce03835f70cb Mon Sep 17 00:00:00 2001 From: Jeff Hykin Date: Wed, 22 Jul 2026 16:07:37 -0700 Subject: [PATCH 51/51] hide zenoh noise --- dimos/protocol/service/zenohservice.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dimos/protocol/service/zenohservice.py b/dimos/protocol/service/zenohservice.py index ed56a7ab8e..433307a044 100644 --- a/dimos/protocol/service/zenohservice.py +++ b/dimos/protocol/service/zenohservice.py @@ -27,7 +27,8 @@ from dimos.protocol.service.spec import BaseConfig, Service from dimos.utils.logging_config import setup_logger -zenoh.init_log_from_env_or("warn") +# temp: silence benign multi-homed duplicate-link "close (reason INVALID)" spam +zenoh.init_log_from_env_or("warn,zenoh_transport::unicast::establishment=off") logger = setup_logger()