Sanbot Elf: a native ROS 2 driver, in action

In August I mentioned, in one paragraph, that the Sanbot Elf had stopped needing a bridge to talk to ROS 2. This is the longer version, with the robot actually doing things.

The part I didn’t expect to manage: the Sanbot has no LiDAR, so I gave it one. An LDS-006 on an FT232 USB-serial adapter plugs into the robot’s own USB port, the Android kernel has no driver for that chip, so the app reads the serial stream itself and publishes a normal /scan at 5 Hz. As far as I know nobody else has put a LiDAR on a Sanbot this way, though I’d be glad to hear otherwise.

Left: the robot, filmed on my phone. Right: RViz on the ROS 2 laptop, recorded on a second run of the same demo script and lined up to the first, so it is the same sequence rather than the same instant.

Short version on YouTube: https://youtube.com/shorts/3zeAn2q98po

Why a native driver

The Sanbot Elf is a 19 kg service humanoid from Qihan: omni-wheel base, a head that pans and tilts, two arms (Sanbot calls them wings), a face on a screen, an IR ring, an Orbbec depth camera and an HD camera in the head. Everything runs on an Android 7.1 board inside it, and the only way in is the vendor’s OpenSDK, which is Java, Android-only, and has no idea ROS exists.

The usual answer, and the one I used for months, is an Android app on the robot that wraps the SDK and a Python node on a laptop that talks to it over HTTP and WebSockets and republishes everything as ROS topics. The closest prior art I could find, a ROS 1 driver, takes the same approach. It works. It also means a laptop has to sit on the network pretending to be the robot, and every command takes an extra hop through JSON.

So the robot now speaks DDS itself. As far as I know it’s the first native ROS 2 driver for the Sanbot Elf, though I’d be happy to be corrected.

How it works

There’s no rclcpp or rclpy for Android, so the transport is hand-written in Kotlin: RTPS discovery, CDR serialisation, reliability, fragmentation, about 4,900 lines. It started life in my Android ROS 2 Sensor app, which turns a phone into a ROS 2 node, and the Sanbot app pulls in the same module rather than keeping a second copy. Two slightly different copies of a wire protocol don’t crash; they make ros2 topic echo print plausible nonsense, which is worse.

Every message layout is tested byte for byte against payloads produced by real ROS 2, in both directions. The app claims the old Python node’s name, sanbot_driver, and the same topic names, so the launch files, RViz configs and URDF I already had work unchanged.

The proof

Asked who publishes /scan, a stock ROS 2 Jazzy install on a laptop answers _CREATED_BY_BARE_DDS_APP_. That’s ROS 2’s label for a DDS participant that isn’t a ROS node it recognises. In other words, the robot is on the graph directly and there’s no bridge process anywhere to blame.

Terminal output of ros2 topic info -v /scan showing the publisher's node name as _CREATED_BY_BARE_DDS_APP_, /scan at 5 Hz, and 36 sanbot_driver topics
No ROS node on the publishing end, just the robot speaking DDS.

Measured today from a Jazzy laptop on the same Wi-Fi: 36 topics under /sanbot_driver, /scan at 5 Hz, the IMU at about 20 Hz, the head camera (compressed) at about 5 Hz, all 17 IR sensors, PIR presence, and the robot’s battery.

What it does

The demo in the video is driven entirely by standard topics, sent from a short Python script on the laptop. The only Sanbot-specific thing on the laptop is the URDF that draws the model in RViz:

  • /sanbot_driver/say makes it speak.
  • /joint_states moves the head and arms. Because robot_state_publisher on the laptop listens to the same topic, the model in RViz moves its arms in step with the real ones.
  • /sanbot_driver/face changes the expression. The SDK has no JOY or ANGER, so happy maps to SMILE, sad to CRY, and so on.
  • /cmd_vel drives it. The base is omni-wheeled, so linear.y strafes, though it can’t blend axes and does one at a time.
RViz on a ROS 2 laptop showing the Sanbot Elf model standing in its live LiDAR scan of the room, with the robot's head camera image in the side panel
The Sanbot in RViz on the laptop: its own LiDAR scan of the room, and the head camera feed on the left.

What surprised me

All three joint mappings are reversed. Head pan, head tilt and both arms run backwards relative to the URDF, and tilt only spans 0 to 90 degrees rather than 180. Those numbers were measured on the hardware, and without the reversals the robot mirrors the RViz model instead of following it. It looks exactly like a bug until you’ve watched it.

There’s no read-back. The OpenSDK can move the head and arms but can’t tell you where they are; the motion managers have commands and nothing else. So the driver publishes joint_states_commanded and refuses to call it anything else, because it is commanded, not measured. If someone pushes an arm, ROS will never know.

The LiDAR is an LDS-006, a Neato-protocol unit, on an FT232 USB-serial adapter plugged into the robot’s own USB port. The Sanbot’s kernel has no ftdi_sio driver, so the adapter never appears as a serial port. The app talks the FTDI protocol over raw USB in userspace, sends the lidar the magic string startlds$ to spin it up, and parses the packets itself.

And the battery. The robot has two: the tablet in its head, which is permanently on mains from the base and always reads 100%, and the drive pack, which is the one you care about. Android’s battery API only sees the first; the pack is only reachable through the vendor SDK. For a while ~/battery cheerfully reported 100% while the robot sat at 57% and falling.

What still isn’t right

  • No head or arm position feedback, as above.
  • Odometry is an estimate. There are no wheel encoders exposed, so heading comes from the gyro and distance from what was commanded. It’s published as ~/odom_estimate, not /odom, on purpose.
  • About 85% of IR messages are lost on the wire. Seventeen best-effort publishers fanning out at around 90 messages a second is too many; it wants one array message instead.
  • No ROS 2 services. The transport doesn’t do them yet, so one-shot actions like snapshots still go through the app’s HTTP endpoints.
  • SLAM and navigation run on another machine. A 2016 Android board isn’t the place for them, and I’m fine with that.
  • The robot stamps everything with its own clock, so if that drifts, tf2 rejects every transform and it looks like the network has died. Set the clock first.

What’s next

Nothing glamorous. Fold the IR into a single message, and give the native driver the same hours of SLAM and driving the old bridge had before I trust it the same way. Services are the bigger job and will wait until something actually needs one.

Leave a Reply