A full autonomous robot navigation stack built on Nav2, SLAM Toolbox, and Gazebo Harmonic. Single command launches everything — Gazebo, SLAM, Nav2, RViz, frontier exploration. Scale from one robot to a fleet by editing a single list.
Distro detection is automatic. Source your ROS install and launch — no config changes needed between Humble and Jazzy.
- Features
- Requirements
- Installation
- Quick Start
- All Launch Modes
- Fleet Management
- Open-RMF Traffic Scheduling
- Worlds
- Troubleshooting
|
Core Navigation
|
Multi-Robot Fleet
|
|
Safety Stack
|
Tooling
|
| Humble | Jazzy | |
|---|---|---|
| OS | Ubuntu 22.04 | Ubuntu 24.04 |
| Gazebo | Harmonic | Harmonic |
Nav2 plugin syntax differs between distros. Launch files detect
$ROS_DISTROautomatically and pick the right params — no manual changes needed.
# Replace humble with jazzy on Ubuntu 24.04
sudo apt install -y \
ros-humble-ros-gz ros-humble-ros-gz-bridge \
ros-humble-xacro ros-humble-joint-state-publisher \
ros-humble-nav2-bringup ros-humble-slam-toolbox \
ros-humble-navigation2 ros-humble-teleop-twist-keyboard
mkdir -p ~/rosnav/src && cd ~/rosnav/src
git clone https://github.com/darshmenon/rosnav.git
cd ~/rosnav
colcon build --symlink-install
source install/setup.bash# Explore the hospital — SLAM + Nav2 + frontier explorer in one command
ros2 launch diff_drive_robot slam_nav.launch.py world_name:=hospital explore:=true
# Multi-robot fleet (2 robots, coordinated exploration)
ros2 launch diff_drive_robot multi_robot.launch.py
# Keyboard control (any terminal)
ros2 run teleop_twist_keyboard teleop_twist_keyboardMaps auto-save to src/diff_drive_robot-main/maps/map_<world>.yaml every 15 s during exploration.
Gazebo + SLAM + Nav2 + RViz + frontier explorer. Robot maps the world on its own.
ros2 launch diff_drive_robot slam_nav.launch.py world_name:=hospital explore:=trueDrive the robot yourself to build the map.
ros2 launch diff_drive_robot slam_nav.launch.py world_name:=hospital
# Run frontier explorer later if needed:
ros2 run diff_drive_robot frontier_explorer.pyIn SLAM mode, localization comes from SLAM Toolbox (map -> base_link), not AMCL. RViz 2D Goal Pose works after Nav2 reports Managed nodes are active; keep safety:=true enabled so Nav2 /cmd_vel is relayed to Gazebo's /cmd_vel_safe.
Load a saved map and navigate in localisation-only mode.
ros2 launch diff_drive_robot robot.launch.py world:=/full/path/to/hospital.world
# Force a specific map:
ros2 launch diff_drive_robot robot.launch.py map:=/full/path/to/my_map.yamlAfter mapping — boustrophedon lawnmower sweep over the full free space.
ros2 run diff_drive_robot coverage_planner.py
# Tighter rows for warehouse:
ros2 run diff_drive_robot coverage_planner.py --ros-args -p sweep_spacing:=0.4Mission Layer ← mission_server.py patrol / sequence / goto
Nav Layer ← Nav2 BT + MPPI path planning + control
Safety Layer ← collision_monitor stop / slowdown from scan
ros2 launch diff_drive_robot slam_nav.launch.py world_name:=hospital safety:=true
# Separate terminal — start mission server
ros2 run diff_drive_robot mission_server.py
# Send missions
ros2 run diff_drive_robot mission_server.py patrol robot1 1,2,0 3,4,90 0,0,180
ros2 run diff_drive_robot mission_server.py goto robot1 3.0 -1.0 45
ros2 run diff_drive_robot mission_server.py status
ros2 run diff_drive_robot mission_server.py cancelSpeak or type plain-English commands; Whisper transcribes, ollama parses, Nav2 executes.
Mic → Whisper STT → ollama LLM → NavigateToPose → Nav2
# Start nav stack first
ros2 launch diff_drive_robot slam_nav.launch.py world_name:=hospital
# Separate terminal — start LLM navigator
ros2 run diff_drive_robot llm_nav.py
# Press Enter to speak, or type directly:
# > go to room_b
# > go to 2.5 1.0
# > stop
# Text-only (no mic):
ros2 topic pub /llm_nav/command std_msgs/msg/String "data: 'go to room_a'" --onceNamed locations are defined in config/locations.yaml (origin, room_a–c, hallway, charging_dock).
The node retries for up to 60 s if Nav2 is still starting up.
Override defaults:
ros2 run diff_drive_robot llm_nav.py --ros-args \
-p whisper_model:=small \
-p ollama_model:=llama2 \
-p record_seconds:=6.0Requirements: ollama serve running with a model pulled (ollama pull llama2).
Swap the standard 2-wheel diff-drive base for a 4-wheel mecanum base that can strafe sideways and move diagonally without rotating — useful in tight spaces. Works with any launch mode above via drive_type:=mecanum.
ros2 launch diff_drive_robot robot.launch.py drive_type:=mecanum
# strafe sideways with no rotation:
ros2 topic pub -r 20 /cmd_vel_safe geometry_msgs/msg/Twist \
"{linear: {x: 0.0, y: 0.3, z: 0.0}, angular: {x: 0.0, y: 0.0, z: 0.0}}"Details on what changes under the hood: concepts.md § 18.
Swap DWB (default local controller on Humble) for nav2_mppi_controller. Works with any single-robot launch mode via controller:=mppi. Jazzy already defaults to MPPI, so this switch is a no-op there.
ros2 launch diff_drive_robot slam_nav.launch.py world_name:=hospital controller:=mppiUses a project-tuned nav2_params_mppi.yaml (same costmaps/BT/footprint as the default config, DiffDrive motion model, tuned critics). Verified end-to-end in Gazebo: MPPI-driven robot reaches goals and triggers the same recovery BT on failure as DWB.
Tight spaces (e.g.
maze): MPPI noticeably outperformed DWB during frontier exploration testing — DWB repeatedly failed at the same narrow corner even after exhausting recovery retries, while MPPI cleared it on the first attempt. If frontier exploration keeps stalling at the same spot in a cluttered world, trycontroller:=mppi.
Front-steered, rear-driven base — two fixed rear wheels, two front wheels on steering knuckles. Uses Gazebo's native AckermannSteering system plugin and always runs MPPI (with AckermannConstraints.min_turning_r) since DWB has no turning-radius constraint.
ros2 launch diff_drive_robot robot.launch.py drive_type:=ackermannFor mapping by driving the car-like base, use SLAM mode:
ros2 launch diff_drive_robot slam_nav.launch.py world_name:=hospital drive_type:=ackermannFor the full visual launch with Gazebo GUI + RViz + working 2D Goal Pose:
ros2 launch diff_drive_robot slam_nav.launch.py world_name:=hospital drive_type:=ackermann rviz:=true headless:=false safety:=trueDetails: concepts.md § 18b.
By default, robot1 runs SLAM and shares /map — other robots localize via
AMCL on it. slam_mode:=multi is the experimental alternative: every robot
runs its own namespaced SLAM (/robotN/map), merged into /map_merged by
map_merge_known.py.
frontier_coordinator assigns each robot a unique frontier, skipping robots
whose Nav2 server isn't ready yet and avoiding recently failed goals.
# SLAM + coordinated exploration (default)
ros2 launch diff_drive_robot multi_robot.launch.py
# Different world
ros2 launch diff_drive_robot multi_robot.launch.py world:=warehouse
# Headless (SSH / CI)
ros2 launch diff_drive_robot multi_robot.launch.py headless:=trueGenerated fleets use robot_count plus a spawn layout. Spawn validation is on
by default: the launch file parses the selected world's SDF collision
boxes/cylinders, ignores floor pads, keeps robot_clearance from obstacles and
other robots, and relocates blocked spawn points within spawn_search_radius.
If no free point is found, launch fails before Gazebo spawns a robot into a wall.
ros2 launch diff_drive_robot multi_robot.launch.py robot_count:=4 robot_layout:=grid
ros2 launch diff_drive_robot multi_robot.launch.py robot_count:=6 robot_layout:=circle spawn_spacing:=1.4
# Exact custom poses override generated count/layout
ros2 launch diff_drive_robot multi_robot.launch.py \
robots_json:='[{"name":"robot1","x":-2,"y":-1},{"name":"robot2","x":-0.8,"y":-1},{"name":"robot3","x":0.5,"y":-1}]'Everything else — TF, Nav2 params, scan fusion, fleet tools, frontier coordinator — picks up the generated robot list automatically.
Same drive_type/controller choice as single-robot mode (Modes 7–9 above), applied to every robot in the fleet:
# Fleet of mecanum (holonomic) robots
ros2 launch diff_drive_robot multi_robot.launch.py drive_type:=mecanum
# Fleet of Ackermann (car-like) robots
ros2 launch diff_drive_robot multi_robot.launch.py drive_type:=ackermanndiff (the default) keeps the existing hand-tuned fleet nav2 params. mecanum/ackermann have no separate fleet-tuned file — they reuse and auto-namespace the single-robot nav2_params_*.yaml at launch time instead, so footprint/controller tuning stays in one place per drive type.
The default exploration stack uses reachable Wavefront Frontier Detection plus weighted scoring. You can switch back to simpler behavior without editing code.
# Default: reachable frontiers, info gain minus travel distance
ros2 launch diff_drive_robot multi_robot.launch.py frontier_detector:=wfd frontier_scorer:=weighted
# Simpler baseline for comparison
ros2 launch diff_drive_robot multi_robot.launch.py frontier_detector:=classic frontier_scorer:=nearest
# Fuse non-SLAM robots' scans into /map_fused for faster shared mapping
ros2 launch diff_drive_robot multi_robot.launch.py merge_scans:=true
# Experimental: every robot runs SLAM, maps merge into /map_merged
# (known-frame merge from spawn poses — not unknown-pose map matching;
# use a dedicated map-merge backend if robots start unaligned)
ros2 launch diff_drive_robot multi_robot.launch.py slam_mode:=multiIf Nav2 reports Failed to make progress or 0 poses, the coordinator avoids
that frontier area for failed_goal_cooldown seconds and selects frontier goals
with at least frontier_clearance_radius clearance from occupied map cells.
# Watch action/TF readiness, assignments, failures, and map progress
ros2 topic echo /exploration/stats
# Full fleet management stack
ros2 launch diff_drive_robot multi_robot.launch.py fleet_mgmt:=trueRViz can show frontier candidates, assigned goals, visited goals, and assignment
lines by adding a MarkerArray display for /exploration/frontiers.
The stats JSON includes nav_ready, nav_waiting, tf_ready, and
tf_waiting, which is the fastest way to tell whether a robot is blocked on
Nav2 startup or localization/TF.
| Argument | Default | Description |
|---|---|---|
world |
hospital |
World name or full .world path |
explore |
true |
true = SLAM + frontier; false = pre-built map + AMCL |
slam_mode |
single |
single = robot1 SLAM + AMCL for others; multi = every robot SLAM + /map_merged |
headless |
false |
No Gazebo GUI or RViz |
fleet_mgmt |
false |
Start mission server, task allocator, health monitor, collision avoidance, deadlock recovery |
drive_type |
diff |
Fleet-wide AMR type: diff (hand-tuned fleet nav2 params), mecanum, or ackermann (both reuse and auto-namespace the same single-robot nav2_params_*.yaml used by slam_nav.launch.py) |
controller |
dwb |
Local controller for drive_type:=mecanum (dwb or mppi, Humble only). Ignored for diff (fleet template is always MPPI) and ackermann (always MPPI) |
robot_count |
2 |
Number of generated robots when robots_json is empty |
robot_layout |
line |
Generated spawn layout: line, grid, or circle |
spawn_x, spawn_y, spawn_z, spawn_yaw |
-2.0, -1.0, 0.3, 0.0 |
Base generated spawn pose |
spawn_spacing |
1.2 |
Spacing between generated spawn poses |
validate_spawns |
true |
Parse SDF collisions and relocate spawns away from walls/obstacles |
robot_clearance |
0.45 |
Minimum clearance from obstacles and other robots |
spawn_search_radius |
4.0 |
Maximum relocation search radius around a blocked spawn |
spawn_search_step |
0.25 |
Radial step for relocation search |
nav2_start_delay |
10.0 |
Base delay before starting robot1 Nav2 in explore mode |
amcl_start_delay |
13.0 |
Base delay before starting AMCL for non-SLAM robots in explore mode |
robot_start_stagger |
6.0 |
Additional startup delay per robot; increase for larger fleets or slow machines |
robots_json |
(empty) | Exact robot list JSON; overrides generated count/layout |
merge_scans |
false |
Publish /map_fused by layering non-SLAM robot scans into unknown cells |
frontier_detector |
wfd |
Frontier plugin: wfd = reachable wavefront frontiers, classic = all free/unknown boundaries |
frontier_scorer |
weighted |
Goal plugin: weighted = info gain minus distance, nearest = closest valid frontier |
distance_weight |
1.0 |
Distance penalty used by frontier_scorer:=weighted |
info_gain_weight |
3.0 |
Information gain reward used by frontier_scorer:=weighted |
hysteresis_radius |
2.0 |
Radius for keeping a robot near its current exploration region |
hysteresis_gain |
1.5 |
Continuity bonus inside hysteresis_radius |
frontier_clearance_radius |
0.30 |
Minimum map clearance around selected frontier goals |
failed_goal_radius |
0.75 |
Radius for matching recently failed frontier goals |
failed_goal_cooldown |
45.0 |
Seconds to avoid a frontier area after Nav2 reports failure |
publish_markers |
true |
Publish RViz debug markers on /exploration/frontiers |
nav_wait_warn_sec |
15.0 |
Seconds between coordinator warnings for missing Nav2 action servers |
tf_wait_warn_sec |
15.0 |
Seconds between coordinator warnings for missing map -> robot/base_link TF |
rviz |
true |
Launch RViz when not headless |
map |
(auto) | Path to map YAML — only used when explore:=false |
# Topics per robot
ros2 topic list | grep -E "/robot1|/robot2"
# Send goals
ros2 action send_goal /robot1/navigate_to_pose nav2_msgs/action/NavigateToPose \
"{pose: {header: {frame_id: map}, pose: {position: {x: -1.5, y: -0.5}, orientation: {w: 1.0}}}}"
ros2 action send_goal /robot2/navigate_to_pose nav2_msgs/action/NavigateToPose \
"{pose: {header: {frame_id: map}, pose: {position: {x: 0.0, y: -0.5}, orientation: {w: 1.0}}}}"
# Watch odom
ros2 topic echo /robot1/odom --once
# Watch exploration metrics
ros2 topic echo /exploration/statsros2 run diff_drive_robot fleet_manager.py list # list active robots
ros2 run diff_drive_robot fleet_manager.py status # SLAM / Nav2 / map state
ros2 run diff_drive_robot fleet_manager.py add robot3 1.0 2.0 # spawn robot at (1,2)
ros2 run diff_drive_robot fleet_manager.py teleop robot1 # keyboard drive
ros2 run diff_drive_robot fleet_manager.py goto robot2 3.0 -1.0
ros2 run diff_drive_robot fleet_manager.py dock robot1 # navigate to charging_dock
ros2 run diff_drive_robot fleet_manager.py undock robot1 # back away 0.5 m from the dock
ros2 run diff_drive_robot fleet_manager.py explore robot2
ros2 run diff_drive_robot fleet_manager.py savemap src/diff_drive_robot-main/maps/map_hospital
ros2 run diff_drive_robot fleet_manager.py health # per-robot health report
# Missions (mission_server must be running)
ros2 run diff_drive_robot fleet_manager.py mission robot1 patrol 1,2,0 3,4,90 0,0,180
ros2 run diff_drive_robot fleet_manager.py mission robot1 status
ros2 run diff_drive_robot fleet_manager.py mission robot1 cancel
# Task queue
ros2 run diff_drive_robot fleet_manager.py tasks add 2.0 1.5 0 pickup_A
ros2 run diff_drive_robot fleet_manager.py tasks add 4.0 -1.0 90 dock_B
ros2 run diff_drive_robot fleet_manager.py tasks status
ros2 run diff_drive_robot fleet_manager.py tasks clear![]() |
![]() |
dock does a two-phase approach: Nav2 drives to the dock's staging pose, then
aruco_dock.py takes over — detects the dock's ArUco marker and visually
servos in (lateral offset, yaw, distance) until it's centered and at the
target stand-off distance. Automatically retries (restage or reverse+re-search)
on a lost/missed marker, up to max_retries.
# Launch a world with the dock station + marker (hospital world only)
ros2 launch diff_drive_robot slam_nav.launch.py world_name:=hospital rviz:=true
# Dock / undock (single robot — pass '' as the namespace)
ros2 run diff_drive_robot fleet_manager.py dock '' charging_dock
ros2 run diff_drive_robot fleet_manager.py undock '' 0.5 0.05
# Run just the visual-approach node directly (skips the Nav2 staging step)
ros2 run diff_drive_robot aruco_dock.py --ros-args -p dock_name:=charging_dockPer-dock settings (marker ID/size, target distance, staging pose, retries) live in
config/docks.yaml. Live camera view while docking: /tmp/aruco_dock_view.jpg,
or add an RViz Image display on /camera/image_raw.
Native Nav2 docking (multi-robot, drive_type:=diff): in addition to the fleet_manager.py dock/ArUco flow above, multi_robot.launch.py also brings up Nav2's own opennav_docking docking_server per robot (own lifecycle manager, /robotN/dock_robot and /robotN/undock_robot actions), tuned via the docking_server: section in nav2_multirobot_params.yaml. It consumes the same /robotN/detected_dock_pose that aruco_dock.py publishes, so the two docking paths share one visual-detection source. Requires sudo apt install ros-$ROS_DISTRO-opennav-docking; skipped automatically for mecanum/ackermann (no tuned dock pose for those bases yet).
ros2 run diff_drive_robot fleet_gui.pyClick on the map to send goals, use sliders for teleop, spawn robots, save the SLAM map — all in one window.
ros2 run diff_drive_robot multi_teleop.py
# WASD to drive, R to switch robot, N to spawn newros2 run diff_drive_robot obstacle_tracker.py
# Visualise in RViz: MarkerArray on /obstacle_tracker/markers
ros2 topic echo /obstacle_tracker/stateros2 run diff_drive_robot fleet_health.py
ros2 topic echo /fleet/healthTracks odom/scan Hz, Nav2 node presence, collision state, and mission state per robot. Reports OK / WARN / ERROR at 1 Hz.
ros2 run diff_drive_robot task_allocator.py # or fleet_mgmt:=true in multi_robot
ros2 run diff_drive_robot fleet_manager.py tasks add 2.0 1.5 0 pickup_AHungarian assignment across idle robots. Pure-Python — no scipy needed.
The fleet-management stack above (task allocator, priority collision avoidance, deadlock recovery) is reactive — robots yield to each other only once a conflict is imminent. rmf_fleet.launch.py adds real Open-RMF traffic scheduling on the same Nav2 stacks instead: rmf_traffic_schedule negotiates conflict-free itineraries across the whole fleet up front, and rmf_task_dispatcher assigns tasks to whichever registered robot can do them. Details, architecture, and known gaps: concepts.md §11b.
Requires (apt, already installed if ros-humble-rmf-* shows up in ros2 pkg list):
sudo apt install ros-humble-rmf-fleet-adapter-python ros-humble-rmf-traffic-ros2 \
ros-humble-rmf-task-ros2 ros-humble-rmf-task-msgs# 1. Static-map fleet — RMF's nav graph needs fixed map-frame coordinates
ros2 launch diff_drive_robot multi_robot.launch.py explore:=false robot_count:=2
# 2. RMF traffic scheduling + task dispatch + fleet adapter (separate terminal)
ros2 launch diff_drive_robot rmf_fleet.launch.py robot_count:=2
# 3. Submit a patrol task and watch the fleet negotiate shared corridor space
ros2 run diff_drive_robot rmf_submit_task.py patrol room_a room_b --rounds 2The URDF supports both 2D and 3D LiDAR, selected with a launch argument — no manual xacro editing needed:
# Single robot
ros2 launch diff_drive_robot slam_nav.launch.py lidar_type:=3d
# Fleet — every robot gets 3D lidar
ros2 launch diff_drive_robot multi_robot.launch.py lidar_type:=3dlidar_type:=2d (default) publishes sensor_msgs/msg/LaserScan on /scan (/{ns}/scan in a fleet). lidar_type:=3d swaps in a 16-channel gpu_lidar (VLP-16 style) publishing sensor_msgs/msg/PointCloud2 on /points (/{ns}/points) instead — mecanum/ackermann ignore this arg and always use 2D.
Both costmaps (local + global) already list a points observation source alongside scan — Nav2 marks/clears obstacles from whichever one is actually publishing, so lidar_type:=3d feeds path planning directly, no separate config needed. Verified end-to-end: real 16×1800 PointCloud2 data flows on /points (and per-robot /{ns}/points in a fleet) with correct frame_id, and the local costmap marks occupied cells from it with /scan absent.
gz-sim's
gpu_lidarpublishesgz.msgs.LaserScanon the sensor's own topic and the realgz.msgs.PointCloudPackedon a nested<topic>/points— the bridge config bridges that nested topic and (in the fleet launch) remaps it down to a clean/{ns}/points.
| World | Size | Description |
|---|---|---|
maze |
— | Enclosed maze, ideal for exploration |
obstacles |
— | Open field with barrel obstacles |
warehouse |
24×20 m | 5 shelf rows, loading dock, staging zone, pillars, pallet stacks |
house |
16×12 m | Living room, kitchen, hallway, 2 bedrooms, bathroom, furniture |
corridor |
— | Narrow corridor with branching rooms |
hospital |
26×18 m | Central corridor, north/south patient bays, nurse station, storage |
office |
22×~12 m | Central corridor, lobby, 2 meeting rooms, kitchen |
empty |
100×100 m | Flat open ground plane, no obstacles — baseline/smoke-test world |
multi_terrain |
~25 m long | Flat spawn area, then (along +X) a 12°/22° ramp pair, a 6-step staircase, a rough bump patch, and jittered discrete obstacles — for perception/costmap stress-testing. Adapted from a quadruped RL terrain course; a wheeled base won't climb the staircase, but it's still useful for nav/perception around obstacles it can't cross |
All worlds use SDF primitives only — no external model downloads, instant load. All 9 work with every drive type (diff, mecanum, ackermann — see Mode 7 and Mode 9) and either controller (controller:=dwb|mppi). multi_terrain has no pre-built map yet — launch with explore:=true (SLAM mode).
# Single robot, any world
ros2 launch diff_drive_robot slam_nav.launch.py world_name:=warehouse explore:=true
# Multi-robot, any world
ros2 launch diff_drive_robot multi_robot.launch.py world:=warehouse
ros2 launch diff_drive_robot multi_robot.launch.py world:=house
ros2 launch diff_drive_robot multi_robot.launch.py world:=corridor explore:=false| Symptom | Fix |
|---|---|
FATAL: plugin X does not exist |
Check $ROS_DISTRO is sourced correctly — wrong distro params loaded |
SmacPlannerHybrid not found |
sudo apt install ros-$ROS_DISTRO-nav2-smac-planner |
| Map not saving | Confirm explore:=true; maps write to src/diff_drive_robot-main/maps/ |
No frontiers in explorer logs |
Check for TF_OLD_DATA / dropped scans; kill stale Gazebo/ROS processes |
| Robot not moving | ros2 topic hz /cmd_vel — if 0, Nav2 lifecycle failed; check node list |
| 2D Goal Pose accepted but robot does not move | Keep safety:=true; Gazebo subscribes to /cmd_vel_safe, and the safety relay forwards Nav2 /cmd_vel there |
| Robots not visible in Gazebo | Rebuild: colcon build --symlink-install then source install/setup.bash |
goal rejected immediately |
Nav2 still starting — coordinator retries every 2 s automatically |
| All robots go to same area | Old per-robot frontier_explorer nodes running — kill them; only frontier_coordinator should run |
| Multi-robot TF errors | Run ros2 run tf2_tools view_frames to inspect the tree; confirm rsp.launch.py frame_prefix fix is applied |
| RViz GLSL errors | Cosmetic — safe to ignore |
┌─────────────────────────────────────────────────────┐
│ Fleet Management │
│ mission_server · task_allocator · fleet_health │
│ priority_collision_avoidance · deadlock_recovery │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ Per-Robot Stack │
│ Nav2 (MPPI + Smac + BT) · AMCL / SLAM Toolbox │
│ velocity_smoother · collision_monitor │
└──────────────────────┬──────────────────────────────┘
│
┌──────────────────────▼──────────────────────────────┐
│ Simulation │
│ Gazebo Harmonic · ros-gz-bridge │
│ LaserScan · Odometry · TF │
└─────────────────────────────────────────────────────┘
Made by @darshmenon · Blog post



