service-piper-moveit-rbnx
Robonix package for arm motion planning + execution on the Piper + Orbbec Dabai DCW grasp pipeline. Wraps the upstream MoveIt config (piper_with_gripper_moveit) plus the C++ moveit_control_node_yolo grasp executor. Owns the service/manipulation/* namespace.
Catalog name: robonix.service.piper_moveit.
Capability surface
| Contract | Mode | Transport | Source / handler |
|---|---|---|---|
robonix/service/manipulation/driver |
rpc | gRPC | Driver(CMD_INIT, config_json) — lifecycle gate |
robonix/service/manipulation/execute_grasp |
rpc | gRPC/MCP | ExecuteGrasp(pose, gripper_width, timeout) → result |
robonix/service/manipulation/reset |
rpc | gRPC | Reset() → move arm to safe home pose |
Extra contract TOMLs shipped under capabilities/service/manipulation/ (demo, demo_place) are handler helpers used during bring-up; they are not exposed in the deploy manifest.
Design intent (most-conservative path)
The C++ moveit_control_node_yolo is battle-tested upstream. The MoveIt MoveGroupInterface Python API is less stable than its C++ counterpart. Re-implementing the planning + execution logic in Python carries real risk for zero gain. So we vendor the C++ source verbatim and put a typed front door on it via ROS topic + status poll:
| component | provenance | what we do |
|---|---|---|
| MoveIt config (URDF/SRDF/…) | upstream piper_with_gripper_moveit |
vendor verbatim, colcon-build |
moveit_control_node_yolo |
upstream piper_moveit_control |
vendor verbatim, colcon-build |
| Top-level launch | upstream piper_moveit.launch.py |
fork (see below) |
| atlas Service wrapper | new (piper_moveit/main.py) |
only Python we own |
Signalling flow
Pilot LLM (or skill-pick-rbnx)
│
▼
MCP manipulation/execute_grasp (PoseStamped + width)
│
▼
┌─ publishes /graspnet/grasps (graspnet_msgs/GraspPose)
│
▼
C++ moveit_control_node_yolo (vendored)
│
├─ tf2 transform → arm/base_link
├─ MoveGroupInterface plan
└─ execute via FollowJointTrajectory action
│
▼
primitive-agilex-piper-arm-rbnx executes on real hardware
│
▼
piper_ctl publishes /arm/arm_status (PiperStatusMsg)
│
▼
◄─── execute_grasp polls busy → idle, returns success/elapsed
Launch fork (launch/piper_moveit_rbnx.launch.py)
Four deltas from upstream piper_moveit.launch.py:
- Removed fake
ros2_control_node— upstream's launch spawns a simulated joint driver publishing/arm/joint_states. Withprimitive-agilex-piper-arm-rbnxalready publishing real hardware joint states, running both = two publishers fighting on the topic = MoveIt plans against garbage. - Removed RViz — robonix-managed deploy is headless. Bring up RViz manually in another shell if needed:
ros2 launch piper_with_gripper_moveit moveit_rviz.launch.py move_group joint_statesremap target switched from/arm/joint_states(which had no real publisher after we removed the fake controller) to/arm/joint_states_single(whatprimitive-agilex-piper-arm-rbnxactually publishes).- Added
link6 → arm/link6static TF bridge to glue the unprefixed TF tree (primitive-agilex-piper-description-rbnxpublishes;primitive-agilex-piper-handeye-rbnxpublisheslink6 → camera_color_optical_frameinto) onto the prefixed one (this launch publishes viaframe_prefix="arm/"for MoveIt's planning frame). Without the bridge, the C++ node'stf_buffer_->transform(grasp_pose_in_camera_frame, "arm/base_link")call fails because the camera frame andarm/base_linkare in different subtrees.
The rest (rsp.launch.py, static_virtual_joint_tfs.launch.py) is included verbatim via IncludeLaunchDescription, so future upstream tweaks don't need a re-fork.
Why no spawn_controllers.launch.py?
Upstream uses spawn_controllers to start joint_trajectory_controller + gripper_controller ros2_control instances. Those drive the fake controller_manager we removed. The C++ node does not talk to controller_manager — it submits FollowJointTrajectory action goals directly to whoever advertises the action server. primitive-agilex-piper-arm-rbnx must advertise that action server for the real-hardware path to work.
Frame tree (post-boot)
Two parallel subtrees, joined at link6 ↔ arm/link6:
unprefixed (piper_description + easy_handeye2): prefixed (this package):
base_link arm/world ──┐
└── link1 ── ... ── link6 arm/base_link │ static
├── camera_* arm/link1 │ joints
│ ... │ from URDF
└── arm/link6 ◄═════════ arm/link6 ────┘
(identity static TF — the bridge)
Both subtrees describe the same physical robot, just under different frame-name conventions. The bridge means tf_buffer_->transform(camera_color_optical_frame_pose, arm/base_link) walks: camera_* → link6 → arm/link6 → arm/base_link, and works.
Boot ordering
- Requires
primitive-agilex-piper-arm-rbnx(publishes/arm/arm_status; the sentinel waits for the first sample); - Requires
primitive-agilex-piper-handeye-rbnx(staticlink6 → camera_color_optical_frameTF used insideexecute_grasp); - Requires
primitive-agilex-piper-description-rbnx(provides the unprefixed TF subtree); - Independent of
service-object-detect-rbnx/service-grasp-pose-rbnxat boot time (no perception dep during Init).
Driver-init lifecycle
on_init ── parse cfg
── log atlas deps (informational)
── spawn rclpy bridge thread
(publisher /graspnet/grasps,
subscriber /arm/arm_status)
── spawn `ros2 launch piper_moveit_rbnx.launch.py`
(RSP + move_group + cpp executor)
── sentinel-wait for first /arm/arm_status sample
(proves piper_ctl_rbnx is alive)
on_deactivate ── kill launch group + stop rclpy thread
on_init is heavy — sentinel timeout default 60 s because launch takes ~5–10 s to bring up move_group, and we need real piper joint feedback to flow before we declare ourselves ACTIVE.
execute_grasp semantics — two-phase wait
- busy phase (≤ ⅓ ×
timeout_s): wait for/arm/arm_statusto transition to non-zero (i.e. arm started moving). If never: either the C++ node did not pick up our publish, or the goal was already physically satisfied → fail conservatively. - idle phase (remaining budget): wait for return to 0 (idle).
success=True iff both phases complete in budget.
Layout
service-piper-moveit-rbnx/
├── package_manifest.yaml
├── capabilities/
│ ├── service/manipulation/{driver,execute_grasp,reset,demo,demo_place}.v1.toml
│ └── lib/manipulation/srv/ExecuteGrasp.srv
├── launch/
│ └── piper_moveit_rbnx.launch.py # fork of upstream launch
├── piper_moveit/
│ ├── __init__.py
│ └── main.py # robonix Service + rclpy bridge
├── scripts/
│ ├── build.sh # colcon + rbnx codegen --mcp
│ └── start.sh
└── src/ # vendored ROS packages
├── piper_moveit/ # piper_with_gripper_moveit + piper_no_gripper_moveit
├── piper_moveit_control/ # C++ moveit_control_node_yolo
├── piper_humble/ # empty shell (referenced by cmakelists)
├── piper_msgs/ # PiperStatusMsg etc.
└── graspnet_msgs/ # GraspPose etc. (full version)
Config (passed via Driver(CMD_INIT, config_json))
arm_group_name: arm # MoveIt SRDF group
end_effector_link: arm/link6 # in prefixed tree
gripper_action_name: /gripper_controller/follow_joint_trajectory
sentinel_timeout_s: 60.0 # wait for /arm/arm_status
All four are optional (defaults match upstream). end_effector_link intentionally uses the prefixed arm/link6 — MoveIt's planning frame is arm/world, so the EE link lives in the prefixed subtree.
Build / run standalone
bash scripts/build.sh # colcon (~30–60 s)
ROBONIX_ATLAS=127.0.0.1:50051 \
bash scripts/start.sh
Verification
# 1. atlas-side
rbnx caps | grep manipulation
# expect: piper_moveit ACTIVE, contracts:
# robonix/service/manipulation/{driver, execute_grasp, reset}
# 2. ROS-side (the cpp bridge is up)
ros2 topic info /graspnet/grasps # expect 1+ subscribers (cpp node)
ros2 node list | grep moveit_control # the cpp node
# 3. TF bridge
ros2 run tf2_ros tf2_echo arm/base_link camera_color_optical_frame
# Expect a transform composed of:
# arm/base_link ← arm/joint{1..6} ← arm/link6 (RSP, prefixed)
# arm/link6 ←IDENTITY← link6 (the bridge)
# link6 ← <calib> ← camera_color_optical_frame (easy_handeye2)
# 4. Direct execute_grasp test (CAREFUL — this moves the real arm).
# Park the arm in safe position first.
rbnx ask "move the arm 30cm up, no rotation"
Failure modes
| symptom | cause | fix |
|---|---|---|
sentinel: no /arm/arm_status sample within 60s |
piper_ctl_rbnx not ACTIVE, or auto_can_setup failed |
rbnx caps \| grep piper_ctl to confirm; bring up CAN manually if needed |
execute_grasp: timeout: arm_status never went busy |
C++ node did not subscribe /graspnet/grasps; or move_group rejected the goal silently |
check ros2 topic info /graspnet/grasps for subs; check move_group logs |
| MoveIt plans against wrong joint values | /arm/joint_states_single remap not taking effect |
check the launch fork — verify joint_states_topic in move_group_configuration |
TF lookup error in cpp arm/base_link → camera_color_optical_frame |
Static bridge link6 ↔ arm/link6 not running, OR easy_handeye2 calib not staged |
check ros2 run tf2_ros tf2_echo link6 arm/link6 is identity; verify easy_handeye2 is ACTIVE |
FollowJointTrajectory action server not advertised |
piper_ctl_rbnx does not expose this action | bridge work in piper_ctl_rbnx — out of scope for this package |
License
This package: MulanPSL-2.0. Vendored MoveIt config + grasp executor: see their respective LICENSE files.