Skip to main content

ROS 2 Lite 6 — Robotic Arm Controller

View source on GitHub

Build a Docker image carrying a ROS 2 Humble + xArm SDK app locally, docker save it to a tarball, bake it into the application sysext via avocado build, and load it on the device on sysext merge. The container reaches the arm over Ethernet on a shared LAN.

The container runs a full ROS 2 graph: robot_state_publisher broadcasts TF transforms and the URDF, lite6_node publishes /joint_states at 30 Hz, and foxglove_bridge exposes all topics over WebSocket for browser-based visualization. Any machine on the LAN can visualize the arm live — no ROS 2 install required, just a browser.

Prerequisites​

  • macOS 10.12+ or Linux (Ubuntu 22.04+, Fedora 39+)
  • Docker Desktop or a working local Docker daemon
  • The latest Avocado CLI
  • A supported target — see supported_targets in avocado.yaml for the full list. All are arm64; this guide's commands use any NXP i.MX or raspberrypi5, and the rest follow the same flow.
  • A UFactory Lite 6 robot arm with its control box powered on and on Ethernet
  • A switch (or any router with a built-in switch) for the device, arm, and your laptop to share

Topology​

┌── [your laptop] (DHCP)
[switch / router] ─────┤
├── [Avocado device] (DHCP on eth0)
└── [Lite 6]

The device, the arm, and your laptop all sit on one shared LAN. The arm's IP must be reachable from the device. Two paths:

The arm ships with a factory IP in the 192.168.1.x range (printed on the control box sticker). To get it onto your LAN:

  1. Power the arm on with a temporary direct cable between the arm and a laptop set to 192.168.1.50/24.
  2. In a browser, open http://<arm-ip>:18333 to reach UFactory Studio.
  3. Settings → Network → change the IP to one on your LAN, e.g. 10.0.0.42. Save and power-cycle the arm.
  4. Move the cable to your switch. The arm is now a normal host on your LAN.

Path 2 — Run a dedicated 192.168.1.x switch​

Plug the arm and the Avocado device into a small unmanaged switch, leave the arm at its factory IP, and configure the device with a static 192.168.1.10/24 (replace overlay/network/etc/systemd/network/10-eth0-dhcp.network with a static-address version — see Customize).


Phase 1 — Build, deploy, wave the arm​

Initialize​

avocado init --reference ros2-ufactory-lite6 ros2-lite6
cd ros2-lite6

For a non-default target:

avocado init --reference ros2-ufactory-lite6 --target imx8mp-evk ros2-lite6
cd ros2-lite6

Install​

avocado install -f

Pulls the SDK image and the runtime feed packages: docker + the kernel modules.

Set the arm IP​

Edit overlay/app/etc/container-app/container-app.env:

LITE6_IP=192.168.1.125 # whatever you set in Path 1 above
LITE6_AUTOSTART_SEQUENCE=false
ROS_DOMAIN_ID=42

Don't have an arm yet? Use LITE6_IP=mock — the container publishes synthetic data so you can exercise the rest of the stack.

Build the container image​

The Docker image is built on your development machine (not inside the Avocado SDK). You must run this step every time you change application code — anything under app/ (Python source, Dockerfile, launch files, URDF, entrypoint). Avocado doesn't know how to build Docker images; it just packages whatever tarball is at overlay/app/usr/lib/container-app/lite6.tar into the sysext. If you skip this step after changing code, the device will run the old image.

TARGET_PLATFORM=linux/arm64 sh build-image.sh

This cross-builds the image for arm64 via Docker buildx, then docker saves it to the tarball. First build takes ~5 minutes (downloads ros:humble-ros-base, installs xArm SDK + robot_state_publisher + tf2_ros + foxglove_bridge, builds the ROS 2 package via colcon). Subsequent builds are fast thanks to Docker layer caching.

Build the runtime​

avocado build

Deploy​

# Raspberry Pi 5
avocado provision -r dev --profile sd

# i.MX 8MP EVK
avocado provision -r dev --profile sd

# i.MX 9x boards via UUU / eMMC
avocado provision -r dev --profile uuu-emmc

Insert the SD card (or trigger the relevant flash mode), apply power.

Find the device IP​

The serial console always works:

ip addr show eth0 | grep 'inet '

Verify​

SSH in (empty root password):

ssh root@<device-ip>

systemctl status docker.service container-app.service
# both: Active: active (running)

docker images
# lite6-ros2 latest ...

# Live arm status via HTTP
curl http://<device-ip>:8080/status | jq
# {"connected": true, "mock": false, "ip": "10.0.0.42",
# "joints_deg": [0.0, 0.0, ...], ...}

# Make the arm wave (~10 seconds)
curl -X POST http://<device-ip>:8080/wave

# ROS 2 topics (inside the container)
docker exec ros2-lite6 bash -lc \
'source /ws/install/setup.bash && ros2 topic list'
# /joint_states
# /robot_description
# /tf
# /tf_static

# Live joint state stream
docker exec ros2-lite6 bash -lc \
'source /ws/install/setup.bash && ros2 topic echo /joint_states'

If connected: false, check that LITE6_IP is correct and the device can ping the arm:

ping -c 3 10.0.0.42

ROS 2 on your laptop​

The container publishes a full ROS 2 graph over DDS. Any machine on the same network with matching ROS_DOMAIN_ID can see and interact with the arm's topics and TF tree.

Install ROS 2 Humble locally, then:

export ROS_DOMAIN_ID=42
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp

# Verify topics are visible
ros2 topic list
# /joint_states — 30 Hz joint angles (sensor_msgs/JointState)
# /robot_description — URDF (latched, std_msgs/String)
# /tf — live joint transforms from robot_state_publisher
# /tf_static — fixed transforms (world → base_link)

# Stream live joint state
ros2 topic echo /joint_states

# Inspect the TF tree
ros2 run tf2_tools view_frames
# Generates frames.pdf showing: world → base_link → link1 → ... → link6

RViz visualization​

The shipped RViz config is at overlay/app/usr/share/ros2-lite6/rviz/lite6.rviz. Copy it to your laptop and launch:

export ROS_DOMAIN_ID=42
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
rviz2 -d lite6.rviz

You should see:

  • The Lite 6 arm rendered as a 3D robot model (from the URDF)
  • Joint frames (TF axes) at each link, updating in real time
  • A grid on the XY plane for spatial reference

Move the arm via the web UI at http://<device-ip>:8080 or the HTTP API — the RViz model follows in real time.

Foxglove Studio (browser-based visualization)​

The container runs Foxglove Bridge on port 8765, exposing all ROS 2 topics over WebSocket. No ROS 2 install needed on your laptop — just a browser.

  1. Open https://app.foxglove.dev in your browser (free, no account required)
  2. Click Open connection
  3. Select Foxglove WebSocket and enter: ws://<device-ip>:8765
  4. Click Open

You now have live access to every ROS 2 topic. Recommended panels to add:

  • 3D — renders the robot model from /robot_description with live TF. You'll see the arm move in real time as you jog it from the web UI.
  • Raw Messages on /joint_states — live stream of joint angles, updated at 30 Hz.
  • Plot on /joint_states.position[0] through [5] — real-time chart of each joint angle over time.
  • Topic Graph — shows the full node/topic graph (lite6_node → /joint_states → robot_state_publisher → /tf).
  • Diagnostics — summary of all active topics with message rates and types.

The web control UI (http://<device-ip>:8080) and Foxglove (ws://<device-ip>:8765) work simultaneously — control the arm from one, observe it from the other.

✅ Phase 1 complete. Container is running. Arm responds to HTTP. ROS 2 topics publish. TF tree and robot model are live. Foxglove visualizes everything in the browser.


Phase 2 — Edit motion code, push the update​

Add a new sequence​

Edit app/ros2_lite6/ros2_lite6/sequences.py:

def shake(arm: ArmController, oscillations: int = 6) -> None:
"""Quick joint-2 nod."""
arm.go_home()
for i in range(oscillations):
sign = 1.0 if i % 2 == 0 else -1.0
angles = list(HOME_JOINTS_DEG)
angles[1] = sign * 15.0
arm.set_servo_angle(angles, speed_deg_s=80.0)
arm.go_home()

Add a route in app/ros2_lite6/ros2_lite6/http_api.py:

@app.post("/shake")
def shake() -> dict:
threading.Thread(target=sequences.shake, args=(arm,), daemon=True).start()
return {"accepted": "shake"}

Rebuild and push​

Any change to application code (Python, Dockerfile, launch files, URDF) requires rebuilding the Docker image first. avocado build and avocado deploy alone will not pick up code changes — they only package and push the tarball that build-image.sh produces.

# 1. Rebuild the Docker image (required after any code change)
TARGET_PLATFORM=linux/arm64 sh build-image.sh

# 2. Rebuild the Avocado runtime (packages the new tarball into the sysext)
avocado build

# 3. Deploy to the device
avocado runtime deploy dev --device root@<device-ip>

avocado runtime deploy streams the changed sysext bytes, fires systemd-sysext refresh on the device, the app extension's on_merge restarts the container (which loads the updated image on start).

Verify the update​

curl -X POST http://<device-ip>:8080/shake
# Arm performs the new sequence.

✅ Phase 2 complete. New behavior on the arm without re-flashing.


ROS 2 architecture​

The container runs three ROS 2 nodes via a single launch file (lite6.launch.py):

┌──────────────────────────────────────────────────────────────────────┐
│ Docker container (lite6-ros2:latest) │
│ │
│ ┌───────────────────┐ ┌────────────────────┐ ┌────────────────┐ │
│ │ lite6_node │ │ robot_state_pub │ │ foxglove_bridge│ │
│ │ │ │ │ │ │ │
│ │ xArm SDK ←─────────→ Lite 6 arm (TCP) │ │ WebSocket │ │
│ │ FastAPI/Uvicorn │ │ URDF → TF │ │ :8765 │ │
│ │ :8080 │ │ │ │ │ │
│ │ │ │ subscribes: │ │ exposes all │ │
│ │ publishes: │ │ /joint_states │ │ ROS 2 topics │ │
│ │ /joint_states ──│─→│ │ │ to browsers │ │
│ │ │ │ publishes: │ │ │ │
│ │ │ │ /tf, /tf_static │ │ │ │
│ │ │ │ /robot_desc │ │ │ │
│ └───────────────────┘ └────────────────────┘ └────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘

Ports:

PortProtocolPurpose
8080HTTPWeb UI + REST API for arm control
8765WebSocketFoxglove Bridge — connect from app.foxglove.dev

Topics published over DDS (visible to any ROS 2 machine on the network):

TopicTypeRateDescription
/joint_statessensor_msgs/JointState30 HzJoint angles for all 6 joints (radians)
/tftf2_msgs/TFMessage30 HzLive transforms for every joint frame
/tf_statictf2_msgs/TFMessageLatchedFixed transform: world → base_link
/robot_descriptionstd_msgs/StringLatchedFull URDF of the Lite 6

HTTP API (port 8080):

The web UI and REST endpoints (/status, /move/joint, /move/line, /pick_and_place, /wave, /estop, etc.) remain available for non-ROS clients. Both interfaces control the same arm — they share a single thread-safe ArmController.

Foxglove Bridge (port 8765):

All ROS 2 topics are exposed over WebSocket for browser-based visualization via Foxglove Studio. No ROS 2 install needed on the observing machine — any browser on the LAN can connect.


Customize​

Enable autostart-on-boot​

By default the arm sits idle waiting for HTTP commands. To run a sequence as soon as the container comes up, edit overlay/app/etc/container-app/container-app.env:

LITE6_AUTOSTART_SEQUENCE=true

Rebuild and redeploy. Make sure the arm's workspace is clear of obstacles before enabling this — the arm will move at boot, every boot, automatically.

Switch to a dedicated 192.168.1.x arm subnet​

Replace overlay/network/etc/systemd/network/10-eth0-dhcp.network with:

[Match]
Name=eth0 end0 enP*

[Network]
Address=192.168.1.10/24

Then leave the arm at its factory 192.168.1.x IP, plug both into the same switch, set your laptop to a 192.168.1.x static. The device's HTTP API is reachable at http://192.168.1.10:8080.

Use a different UFactory arm​

The xArm Python SDK supports xArm 5/6/7, 850, and Lite 6 with the same API. To switch:

  1. Edit app/ros2_lite6/ros2_lite6/arm.py — HOME_JOINTS_DEG may need a length change for 7-DoF arms.
  2. Replace app/ros2_lite6/urdf/lite6.urdf with a URDF matching the new arm's kinematics (joint count, link lengths, joint axes). The URDF is used by robot_state_publisher to broadcast TF and render the model in RViz.
  3. Rebuild and redeploy.

Switch to full xarm_ros2 + MoveIt 2​

The default reference uses a thin custom ROS 2 node + the xArm Python SDK to keep the image under ~500 MB. To use the full xarm_ros2 stack with MoveIt 2 planning, swap the Dockerfile for one based on moveit/moveit2:humble-source and colcon build xarm_ros2. Expect:

  • ~2 GB compressed image
  • 15+ minute first build
  • Bump stone/<target>/stone-<target>.json var.size to 8192 MB

Adjust the var partition size​

For other targets or when the image grows, edit stone/<target>/stone-<target>.json:

{ "name": "var", "image": "var", "size": 8192, "size_unit": "mebibytes", "expand": "true" }

Debugging​

Container won't start​

journalctl -u container-app.service -b --no-pager | tail -50
docker logs ros2-lite6

Common causes:

  • Architecture mismatch (built for amd64, ran on arm64) → re-run sh build-image.sh with TARGET_PLATFORM=linux/arm64.
  • Image not loaded → check docker images shows lite6-ros2:latest. If missing, docker load in the service's ExecStartPre may have failed; check journalctl -u container-app.service.

/status returns connected: false​

The container started but couldn't reach the arm at LITE6_IP. Check:

  • ping <arm-ip> from the device — if that fails, the device and arm aren't on the same LAN.
  • Arm powered on and not in error state (UFactory Studio web UI is the easiest check).
  • LITE6_IP correctly set in container-app.env.

After fixing, systemctl restart container-app.

ROS 2 topics not showing up on the laptop​

  • ROS_DOMAIN_ID must match on both sides (default 42).
  • RMW_IMPLEMENTATION=rmw_cyclonedds_cpp recommended on both sides for cleaner Docker / multi-host discovery.
  • Firewall on your laptop or LAN may block UDP multicast — check.
  • Verify inside the container first: docker exec ros2-lite6 bash -lc 'source /ws/install/setup.bash && ros2 topic list' should show /joint_states, /robot_description, /tf, and /tf_static.

Foxglove won't connect​

  • Confirm the container is running: docker logs ros2-lite6 2>&1 | grep foxglove — you should see foxglove_bridge log its WebSocket address.
  • Check that port 8765 is listening: cat /proc/net/tcp on the device and look for 2241 (hex for 8765) in the local address column.
  • Foxglove Studio at app.foxglove.dev is served over HTTPS. Some browsers block mixed content (HTTPS page connecting to ws:// non-TLS WebSocket). If the connection silently fails, try the Foxglove Desktop app instead, or use Chrome which is more permissive with localhost/LAN WebSocket connections.

TF tree looks wrong in RViz​

  • ros2 run tf2_tools view_frames on your laptop generates a PDF of the TF tree. Confirm the chain runs from world → base_link → link1 → ... → link6.
  • If the robot model shows but doesn't move, check that /joint_states is being received: ros2 topic hz /joint_states should report ~30 Hz.

Safety​

A robot arm moves. Real-world considerations:

  • Clear the workspace before powering on the arm and before triggering any motion.
  • /estop is the emergency stop endpoint. curl -X POST http://<device-ip>:8080/estop — disables motion immediately.
  • Default LITE6_AUTOSTART_SEQUENCE=false means nothing moves unless you ask. Keep it that way during development.