Personal setup notes and code for a Waveshare RoArm-M2-S: a repaired gripper servo, a split MoveIt/driver architecture across a Raspberry Pi and a Windows/WSL machine, and a browser dashboard that combines a live camera feed with YOLO object detection and click-to-move control.
The gripper's bus servo burnt out and was replaced with a new ST3215 unit. New servos
ship from the factory as ID 1, but this arm's servo bus already has a device on
that ID (BASE_SERVO_ID = 11 conflicts happen if the new part isn't renumbered), so
the replacement had to be reassigned to its expected ID before it would work:
- Servo ID map used by this firmware:
BASE=11, SHOULDER_DRIVING=12, SHOULDER_DRIVEN=13, ELBOW=14, GRIPPER=15. - Fix applied over the serial JSON protocol (115200 baud), with the new servo alone
on the bus:
{"T":501,"raw":11,"new":15} # reassign ID 11 -> 15 (gripper) {"T":502,"id":15} # set middle/reference position - Command reference:
CMD_SET_SERVO_ID = 501,CMD_SET_MIDDLE = 502, both defined in the firmware'sjson_cmd.h(see waveshareteam's RoArm-M2 wiki /roarm_ws_em0source for the full command table).
Two machines, split the same way as this user's other Waveshare robot project (GUI/planning on the PC, driver on the Pi):
┌─────────────────────────┐ Tailscale (WireGuard) ┌──────────────────────────┐
│ Windows + WSL2 Ubuntu │ <───────────────────────────────> │ Raspberry Pi 5 │
│ 22.04, ROS2 Humble │ ROS_DOMAIN_ID=24 │ Ubuntu Server 24.04 │
│ │ RMW=rmw_cyclonedds_cpp │ │
│ - MoveIt2 + RViz2 │ │ - Docker container │
│ (roarm_moveit) │ │ (Ubuntu 22.04 + │
│ - IKFast solver │ │ ROS2 Humble base) │
│ - GUI shows via WSLg │ │ - roarm_driver node │
│ │ │ -> /dev/ttyUSB0 │
│ │ │ - USB camera /dev/video0 │
└─────────────────────────┘ └──────────────────────────┘
Both machines are on the same physical LAN (WSL2 in mirrored networking mode
shares the Windows host's real IP/subnet), and ICMP ping worked fine between them —
but raw UDP delivery from the Pi to WSL was silently dropped even after opening
Windows Firewall rules for the specific remote IP (tried both -Profile Private
and -Profile Any, scoped to the Pi's address). Root cause was never conclusively
identified (candidates: WSL2 mirrored-mode inbound-UDP quirk, router-level
client isolation, or a firewall layer not visible via New-NetFirewallRule).
Tailscale reliably works around it by tunneling over WireGuard, and
tailscale ping confirmed a working path immediately. If you retry the plain-LAN
route later, wsl-gui/cyclonedds.xml and pi-driver/cyclonedds.xml show the working
CycloneDDS config shape (explicit <Peers> + AllowMulticast=spdp, not false
— disabling multicast entirely also broke same-host node discovery inside the Pi's
own container).
pi-driver/Dockerfile— lightweight image for the Pi: Ubuntu 22.04 +ros-humble-ros-base(no desktop/GUI packages) +roarm_driver/roarm_description/roarm_msgsbuilt from waveshareteam/roarm_ws (ros2-humblebranch).pi-driver/cyclonedds.xml— CycloneDDS config deployed inside the Pi's driver container.wsl-gui/cyclonedds.xml— matching config used on the WSL side.dashboard/— Flask app: live MJPEG stream with YOLOv8 detections, click an object in the browser to send the arm there.
# On the Pi
docker build --platform linux/arm64 -t roarm-driver:latest pi-driver/
docker run -d --name roarm-driver --network host --privileged \
--device=/dev/ttyUSB0 --restart unless-stopped roarm-driver:latest tail -f /dev/null
docker cp pi-driver/cyclonedds.xml roarm-driver:/root/cyclonedds.xml
docker exec -d roarm-driver bash -c '
export CYCLONEDDS_URI=file:///root/cyclonedds.xml
source /opt/ros/humble/setup.bash
source /root/roarm_ws/install/setup.bash
exec ros2 run roarm_driver roarm_driver --ros-args -p serial_port:=/dev/ttyUSB0'Gotcha: killing the driver with pkill -f "ros2 run roarm_driver" only kills the
ros2 run launcher wrapper, not the actual spawned node process — it leaks a zombie
that still holds /dev/ttyUSB0 open. Use pkill -9 -f roarm_driver (matches the real
binary path too) before restarting it, and check ps aux | grep roarm_driver for
duplicates if the arm ever seems to get commands from an unexpected/stale source.
Needs waveshareteam/roarm_ws (ros2-humble branch) cloned separately and built with
MoveIt + MoveIt Task Constructor + the IKFast plugin. On a resource-constrained machine,
build with MAKEFLAGS="-j2" colcon build --parallel-workers 1 per package —
moveit_task_constructor_core compiling with full default parallelism (-j=core count)
OOM-killed cc1plus on a machine with ~6GB free RAM. Also remember to
source install/setup.bash from the local workspace itself (not just
/opt/ros/humble/setup.bash) between separate colcon build invocations — otherwise
CMake can't find custom packages like roarm_msgs that have no system-wide fallback
(unlike the MoveIt packages, which silently resolve against /opt/ros/humble if the
local overlay isn't on CMAKE_PREFIX_PATH, masking the missing-source-step bug).
source /opt/ros/humble/setup.bash
source ~/roarm_arm_ws/install/setup.bash
export ROARM_MODEL=roarm_m2
export ROS_DOMAIN_ID=24
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
export CYCLONEDDS_URI=file:///path/to/wsl-gui/cyclonedds.xml
ros2 launch roarm_moveit roarm_moveit.launch.pyDrag the interactive marker in RViz, click Plan, then Plan & Execute to move the real arm.
Runs entirely on the Pi (camera and serial are both local there), independent of the
MoveIt/ROS2 side — it talks to the arm directly over the same serial JSON protocol
the firmware exposes, using CMD_XYZT_GOAL_CTRL ({"T":104,...}) so the ESP32's own
built-in inverse kinematics handles the move, no MoveIt/IKFast needed for this path.
cd dashboard
python3 -m venv venv
./venv/bin/pip install torch --index-url https://download.pytorch.org/whl/cpu
./venv/bin/pip install torchvision --index-url https://download.pytorch.org/whl/cpu
./venv/bin/pip install -r requirements.txt
./venv/bin/python3 app.py # serves http://<pi-ip>:8091Depth without a depth camera: the gripper-mounted camera is monocular, so there's
no real depth sensing. Instead, app.py back-projects the clicked pixel through the
camera's current pose (derived from the arm's own end-effector feedback, {"T":105})
onto an assumed flat table plane (TABLE_Z_MM) — a ray-plane intersection, not a guess.
This is the standard approach for eye-in-hand tabletop picking without a depth sensor,
but it needs two things measured/tuned for your physical setup before it's accurate:
TABLE_Z_MM— table height in the arm's base frame.CAMERA_FORWARD_OFFSET_MM/CAMERA_UP_OFFSET_MM/CAMERA_TILT_OFFSET_DEG— how the camera is actually mounted relative to the gripper TCP.CAMERA_HFOV_DEG— the USB camera's real horizontal field of view (currently a rough guess; a proper checkerboard calibration would replace this and the two offsets above with real numbers).
All of these are constants at the top of dashboard/app.py.
- Camera/table geometry constants in
dashboard/app.pyare estimates — need real measurements or a checkerboard calibration pass. - Pi has rebooted unexpectedly a couple of times during setup (once mid Docker
build) — cause not confirmed (no OOM-kill evidence in
dmesg, and the last one showed a cleansystemd-poweroffsequence, so possibly a manual power cycle rather than a crash). Worth keeping an eye onjournalctl --list-boots. -
roarm-drivercontainer doesn't auto-restart the ROS node itself on Pi reboot (only the container's placeholder process does, via--restart unless-stopped) — you have to re-run thedocker exec -d ... ros2 run roarm_driverstep by hand after any Pi reboot.