ROS 2 software stack for the LeKiwi 3-wheel omnidirectional mobile robot and its payloads. It provides holonomic drive with odometry, joystick teleoperation, SLAM and Nav2 navigation, motor diagnostics, battery monitoring, spoken status announcements and a MuJoCo simulation. The base includes a LiDAR, and optional payloads add more; for example, the pan-tilt payload adds an Orbbec Gemini 2 depth camera.
This is a real, motorized robot with no hardwired physical emergency stop. /emergency_stop is a software service call (toggled via joystick button or Foxglove) that tells the hardware interface to stop issuing motor commands. It is not a hardware kill switch, and it will not help if the software stack itself has hung, crashed, or lost connection to the joystick.
This repository is a work in progress and includes experimental and AI-generated content. Expect breaking changes and incomplete safety coverage. Simulation (sim:=true) has verified motion control (see Simulation) but is not validated against real hardware behavior. No warranty, express or implied — see LICENSE.
| Package | Purpose |
|---|---|
lekiwi_bringup |
Top-level launch files that bring up the whole system, and the udev rules |
lekiwi_control |
ros2_control setup, controllers, joystick teleop, diagnostics and emergency-stop handling |
lekiwi_description |
URDF models and meshes for the base and each payload |
lekiwi_mujoco |
MuJoCo models generated from the URDF, and a standalone (no ROS) viewer |
lekiwi_navigation |
Nav2, SLAM (slam_toolbox), localization (AMCL), EKF sensor fusion, maps and waypoint patrol |
lekiwi_audio |
Spoken status announcements (e-stop, mode switching, waypoints, battery) |
These are separate repositories, included as git submodules under modules/ and payloads/:
| Repository | Purpose |
|---|---|
sts_hardware_interface |
ros2_control hardware interface for the Feetech STS servos |
bno055_hardware_interface |
ros2_control hardware interface for the BNO055 IMU |
ldlidar_ros2 |
LD06 LiDAR driver with bug fixes |
ina260_battery_monitor |
INA260 battery voltage, current and power, with threshold events |
mujoco_ros2_plugins |
mujoco_ros2_control plugins, currently a configurable simulated emergency-stop service (default /emergency_stop; sim:=true only) |
pantilt_ros2 (payloads/) |
Pan-tilt with a Gemini 2 camera, with its own MuJoCo model |
| Component | Details |
|---|---|
| Drive | Three Feetech STS3215 servo motors (IDs 7 left, 8 back, 9 right) on one serial bus at 1 Mbaud |
| LiDAR | LD06 (the same device as the LD19; the driver is configured with the LDLiDAR_LD19 product name) |
| IMU | BNO055 |
| Battery monitor | INA260 current and voltage sensor |
| Microphone array | reSpeaker Flex (XVF3800), for the spoken announcements |
| Controller | A Steam Deck used as a generic joystick (see Joystick) |
| Payload | Optional, for example the pan-tilt with a Gemini 2 camera |
Real hardware (not sim:=true) needs three USB devices at fixed paths, rather than whichever /dev/ttyUSB0-style name the kernel assigns on that boot:
| Symlink | Device | Used by |
|---|---|---|
/dev/ttyLIDAR |
LD06 LiDAR (CP210x USB-UART) | lekiwi_bringup/config/laser.yaml (port_name) |
/dev/ttySERVO |
Feetech STS servo bus | lekiwi_control/config/urdf_config.yaml (serial_port) |
| reSpeaker Flex ALSA nodes | XVF3800 USB audio | lekiwi_audio selects it by ALSA card name; the rule only grants non-root permission and does not rename the card |
Install the rules once per robot, then reconnect the devices or reboot:
sudo cp lekiwi_bringup/udev/99-lekiwi.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules && sudo udevadm triggerWithout them, /dev/ttyLIDAR and /dev/ttySERVO do not exist and the default serial ports fail to open.
Requires ROS 2 (CI-tested on Kilted and Jazzy) with:
ros2_controland its standard controllers- Nav2, slam_toolbox, robot_localization and laser_filters
joyandjoy_teleop
Clone with the submodules. Before building the default pan-tilt payload, install its dependencies and clone Cloudini and the Orbbec source driver as described in the pan-tilt installation instructions; Kilted does not provide the Orbbec driver through apt. In those instructions, use the documented submodule path for the Orbbec compatibility patch.
cd ~/ros2_ws/src
git clone --recurse-submodules https://github.com/adityakamath/lekiwi_ros2.git
# Install the pan-tilt dependencies and clone/build OrbbecSDK_ROS2 and Cloudini here.
cd ~/ros2_ws
colcon build --packages-up-to lekiwi_bringup
source install/setup.bashCloudini is built from source for the real camera's optional point cloud.
sim:=true needs the MuJoCo packages:
sudo apt install ros-kilted-mujoco-ros2-control ros-kilted-mujoco-ros2-control-plugins
pip install -r lekiwi_mujoco/requirements.txt # into the interpreter that ROS launch usesSee the lekiwi_mujoco README for the full list.
ros2 launch lekiwi_bringup lekiwi.launch.py # real robot with the pan-tilt payload
ros2 launch lekiwi_bringup lekiwi.launch.py payload:="" # base only
ros2 launch lekiwi_bringup lekiwi.launch.py mission:=map # SLAM mapping
ros2 launch lekiwi_bringup lekiwi.launch.py mission:=amcl map_name:=livingroom1 # localize on a saved map
ros2 launch lekiwi_bringup lekiwi.launch.py sim:=true mujoco_scene:=home # MuJoCo simulationThe most common arguments for lekiwi.launch.py (--show-arguments lists them all):
| Argument | Default | Description |
|---|---|---|
payload |
pantilt |
Payload to mount: "" for the base alone, or a payload name (see Payloads) |
mission |
"" |
Navigation mode: map (SLAM mapping), slam or amcl (localization, needs map_name). Empty maps from scratch, or uses AMCL if map_name is set |
map_name |
"" |
Map to load, e.g. livingroom1 |
wp_loops |
0 |
Waypoint patrol passes per start: 0 loops forever, N>0 runs N passes |
fusion_mode |
base |
EKF sensor fusion: base, imu or odom; odom is required when imu:=false |
imu |
true |
A physical BNO055 is present (honored on hardware and in simulation); false needs fusion_mode:=odom |
laser |
true |
A physical LD06 is present; false skips the LiDAR launch |
audio |
true |
A physical reSpeaker is present; false skips lekiwi_audio |
battery_monitor |
true |
A physical INA260 is present; false skips the battery monitor |
sim |
false |
Run in MuJoCo instead of on hardware |
mujoco_scene |
flat |
sim only: flat, arena, home (furnished apartment with pushable props), maze, none or a scene file |
mujoco_gui |
false |
sim only: show the MuJoCo viewer (headless otherwise, no display or GPU needed) |
diagnostics |
false |
Launch the motor and IMU diagnostics nodes |
joy |
false |
Launch joy_node on this device (set true if the joystick is plugged in locally) |
Payload-specific arguments (payload:=pantilt): enable_camera:=false retains camera geometry while skipping real or simulated camera publishing. pointcloud:=true (off by default; nothing in the stack uses the cloud and it is costly on a Raspberry Pi) enables one colored cloud and Cloudini compression on real hardware; camera_fps controls both real RGB/depth rates (15 Hz default, simulation capped at 5 Hz). The driver include is lazy, and simulation does not start the real driver.
Teleoperation is set up for a Steam Deck used as a generic joystick, not through Steam Input, so the button and axis numbers are specific to that interface.
| Control | Action |
|---|---|
| L1 (hold) | Deadman: drive and payload commands are only sent while it is held |
| Left stick | Forward, back and strafe |
| Right stick | Rotate in place |
| D-pad | Pan and tilt, with payload:=pantilt |
| B | Toggle emergency stop |
| X | Toggle between teleop and Nav2 control |
| R1 (hold) | Disable the collision monitor's predictive stop |
| Screenshot | Save the current SLAM map |
| Right joystick button (8) | Submit the edited Nav2 target via /nav2_send_goal (Nav2 mode only) |
| Y / A / Settings | Record / toggle / reset a waypoint patrol |
All of these, plus navigation goal outcomes, are announced by lekiwi_audio (unless audio:=false). To test the patrol announcements without the infinite loop, launch with wp_loops:=1.
| File | What it sets |
|---|---|
lekiwi_control/config/urdf_config.yaml |
Servo serial port, motor IDs, mock mode, servo speed profile |
lekiwi_control/config/control.yaml |
Drive controller and controller manager |
lekiwi_control/config/base_teleop.yaml |
Joystick buttons and axes, and the base's speed limits |
lekiwi_bringup/config/laser.yaml |
LiDAR serial port and settings |
lekiwi_navigation/config/nav2/ |
Nav2, AMCL, slam_toolbox, map saver and waypoint patrol settings |
lekiwi_navigation/config/robot_localization/ |
EKF sensor fusion for each fusion_mode |
lekiwi_audio/config/phrases.yaml |
The spoken phrases |
The base's speed limits are the joystick axis scales in base_teleop.yaml; the drive controller enforces none, so nav2.yaml's velocity_smoother must match them.
Nav2 no-go and speed-limited zones are supported. The zone masks live under lekiwi_navigation/maps/<map_name>/filters/ and are saved automatically with each map.
sim:=true runs the same launch files against MuJoCo instead of the hardware, headless by default (mujoco_gui:=true opens the viewer and needs a display). The model is generated at launch from the URDF and controller configuration by lekiwi_mujoco, which mounts the payload's own model. The simulated hardware covers the wheels, IMU, LiDAR (/scan), the emergency stop, and the payload's joints and sensors (with the pan-tilt, its camera images). The battery monitor and audio are not simulated and are skipped.
Motion control is verified end to end: a single controller_manager drives the wheels and the payload together, the IMU publishes real data including gravity, and odometry and TF update correctly. The simulation has not been compared against real hardware. To watch it from another machine, run foxglove_bridge and connect Foxglove to ws://<host>:8765.
The base runs on its own (payload:=""). Optional payloads, each in its own repository under payloads/, mount on top of it; payload:=<name> selects one. Currently that is pantilt (pantilt_ros2: pan-tilt + Gemini 2 camera), which is also the reference to copy. Following the same layout, users can add their own payloads, such as the SO-101 arm.
How a payload is wired in (<name> is the payload value):
- Registered in
_VALID_PAYLOADSinlekiwi_bringup/launch/lekiwi.launch.py, which forwardspayloadto the control launch (which also starts teleop) and the laser launch. - Description: the payload repository provides a URDF module with a fixed mount joint;
lekiwi_description/urdf/base_<name>/combines it with the base. - Control and teleop: the payload's own control package supplies its controller (
pantilt_controller.yaml, whichlekiwi_controlgives to the spawner with--param-file) and its servo profile, so nothing is copied into lekiwi and onecontroller_managerstill drives the base and the payload.lekiwi_controlkeeps only what depends on the host, the joystick layout inconfig/<name>_teleop.yaml, loaded on top ofbase_teleop.yaml. - Sensors: a payload that blocks part of the LiDAR's view adds
lekiwi_bringup/config/<name>_laser_filter.yaml, whichlaser.launch.pyapplies automatically. - Simulation: the payload's MuJoCo package builds its model from the robot's URDF (for
pantilt,pt_mujoco'sbuild_payload_spec), andlekiwi_mujocoattaches it at the URDF's mount joint.
A few launch-file branches still test for pantilt explicitly (in lekiwi.launch.py and control.launch.py), so adding a payload means extending them as well as providing the pieces above.
The description exports base_pantilt_gemini2.urdf in lekiwi_description/urdf/base_pantilt/, using PT101. LeKiwi meshes use ../../meshes/; pantilt meshes use raw GitHub URLs. See URDF regeneration.
MuJoCo includes the PT101 with Gemini 2 model, lekiwi_pt101_gemini2.xml. See camera model and regeneration.
Real Gemini 2 requires the upstream Orbbec source driver and USB rules described in pantilt installation. Camera-only calibration uses pt_bringup/gemini2.launch.py; live acquisition and physical mount calibration still await hardware. Gemini uses /gemini2/* in hardware and simulation; real and simulated Gemini 2 generate /gemini2/scan directly from depth images, including when pointcloud:=true; simulation also provides a camera-mounted six-axis IMU. Add one colored cloud with pointcloud:=true; on real hardware this also starts Cloudini and publishes /gemini2/depth_registered/points/compressed. Adjust the shared camera_fps or set enable_camera:=false to lower the load. See simulation fidelity.
To run LeKiwi with the default Gemini 2 geometry but no real camera streaming:
ros2 launch lekiwi_bringup lekiwi.launch.py enable_camera:=falseLeKiwi passes enable_camera to the camera-only launch in pt_bringup, which skips the driver and streaming helpers before resolving camera dependencies when false. Camera geometry remains in the URDF. In simulation, the same flag skips camera rendering and processing while preserving its URDF and MJCF geometry.
Apache License 2.0 - See LICENSE file.