
ROS2 Complete Guide
From setup to autonomous navigation — a comprehensive guide to ROS2 covering nodes, topics, services, actions, SLAM, Nav2, and more.
ROS2 Complete Guide
From Setup to Autonomous Navigation
Table of Contents
- What is ROS2?
- Installation & Setup
- Core Architecture
- Nodes
- Topics — Publish & Subscribe
- Services — Request & Response
- Actions — Long-Running Tasks
- Parameters
- TF2 — Coordinate Transforms
- Launch Files
- URDF — Robot Description
- Gazebo Simulation
- Sensor Integration
- SLAM — Mapping & Localization
- Nav2 — Autonomous Navigation
- End-to-End Project: Autonomous Nav Robot
- Useful CLI Reference
1. What is ROS2?
ROS2 (Robot Operating System 2) is a middleware framework for building robot software. It is NOT an operating system — it is a collection of tools, libraries, and conventions that run on top of Linux (or macOS/Windows).
Why ROS2 over ROS1?
| Feature | ROS1 | ROS2 | |---------|------|------| | Real-time support | No | Yes (DDS-based) | | Multi-robot | Limited | Native | | Security | None | DDS security | | Python version | Python 2 | Python 3 | | Communication | roscore required | Decentralized | | Lifecycle nodes | No | Yes |
ROS2 Distributions (LTS)
| Distro | Ubuntu | Status | |--------|--------|--------| | Humble Hawksbill | 22.04 | Recommended (LTS) | | Iron Irwini | 22.04 | Stable | | Jazzy Jalisco | 24.04 | Latest LTS |
Use Humble for maximum package compatibility (especially Nav2 and slam_toolbox).
2. Installation & Setup
2.1 Install ROS2 Humble (Ubuntu 22.04)
# Set locale
sudo apt update && sudo apt install locales
sudo locale-gen en_US en_US.UTF-8
sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
# Add ROS2 apt repo
sudo apt install software-properties-common curl -y
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \
-o /usr/share/keyrings/ros-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] \
http://packages.ros.org/ros2/ubuntu \
$(. /etc/os-release && echo $UBUNTU_CODENAME) main" | \
sudo tee /etc/apt/sources.list.d/ros2.list
# Install
sudo apt update
sudo apt install ros-humble-desktop -y # Full desktop (includes RViz, demos)
sudo apt install python3-colcon-common-extensions -y # Build tool2.2 Source ROS2 (Every Terminal)
source /opt/ros/humble/setup.bash
# Or add to ~/.bashrc so it's automatic
echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc
source ~/.bashrc2.3 Create a Workspace
ros2_ws/ ← Workspace root
├── src/ ← Your packages go here
│ └── my_robot/
│ ├── my_robot/ ← Python source files
│ │ ├── __init__.py
│ │ └── my_node.py
│ ├── package.xml ← Package metadata & dependencies
│ └── setup.py ← Python entry points
├── build/ ← Auto-generated by colcon
├── install/ ← Auto-generated by colcon
└── log/ ← Build logs
# Create workspace
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws
# Create a Python package
cd src
ros2 pkg create --build-type ament_python my_robot \
--dependencies rclpy std_msgs geometry_msgs
# Build workspace
cd ~/ros2_ws
colcon build --symlink-install
# Source your workspace (after every build)
source install/setup.bash
--symlink-installmeans you don't need to rebuild after editing Python files — changes are reflected immediately.
3. Core Architecture
ROS2's communication is built on DDS (Data Distribution Service) — a standard publish-subscribe middleware. No central master process is needed (unlike ROS1's roscore).
┌─────────────────────────────────────────────────────────────┐
│ ROS2 System │
│ │
│ ┌──────────────┐ Topic /scan ┌──────────────────┐ │
│ │ LiDAR Node │ ──────────────► │ SLAM Node │ │
│ └──────────────┘ └──────────────────┘ │
│ │ │
│ ┌──────────────┐ Topic /odom │ Topic /map │
│ │ Odom Node │ ──────────────► ▼ │
│ └──────────────┘ ┌──────────────────────┐ │
│ │ Nav2 Planner │ │
│ ┌──────────────┐ └──────────────────────┘ │
│ │ Goal Client │ ──── Action /navigate_to_pose ──► Nav2 │
│ └──────────────┘ │
│ Topic /cmd_vel │
│ ┌───────────────────────┐ │
│ │ Motor Controller │ │
│ └───────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Communication Primitives
┌─────────────────────────────────────────────────────────────┐
│ TOPICS │ SERVICES │ ACTIONS │
│ (async stream) │ (sync request) │ (async + feedback) │
├─────────────────┼────────────────────┼──────────────────────┤
│ Fire & forget │ Request/Response │ Goal → Feedback │
│ One-to-many │ One-to-one │ → Result │
│ sensor data │ on/off toggle │ navigation goals │
│ /cmd_vel │ /set_mode │ /navigate_to_pose │
└─────────────────┴────────────────────┴──────────────────────┘
4. Nodes
A Node is a single executable process. Each node has one responsibility (separation of concerns). Nodes communicate via Topics, Services, and Actions.
4.1 Minimal Node
# my_robot/minimal_node.py
import rclpy
from rclpy.node import Node
class MinimalNode(Node):
def __init__(self):
super().__init__('minimal_node') # Node name in ROS graph
self.get_logger().info('Node is alive!')
# Timer: call self.tick every 1 second
self.timer = self.create_timer(1.0, self.tick)
def tick(self):
self.get_logger().info('Tick!')
def main(args=None):
rclpy.init(args=args) # Initialize ROS2 runtime
node = MinimalNode()
rclpy.spin(node) # Block & process callbacks
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()4.2 Register Node in setup.py
# setup.py
from setuptools import setup
setup(
name='my_robot',
version='0.0.1',
packages=['my_robot'],
entry_points={
'console_scripts': [
# 'command_name = package.module:function'
'minimal_node = my_robot.minimal_node:main',
],
},
)colcon build --symlink-install
ros2 run my_robot minimal_node4.3 Node Lifecycle
Lifecycle nodes give you explicit state control — useful for hardware drivers and nav components.
Unconfigured
│ configure()
▼
Inactive ◄─── deactivate() ──┐
│ activate() │
▼ │
Active ──── deactivate() ───┘
│ cleanup()
▼
Unconfigured
│ shutdown()
▼
Finalized
5. Topics — Publish & Subscribe
Topics are typed, named channels. Any node can publish or subscribe to any topic. Messages are defined by .msg files.
5.1 Common Message Types
| Message | Package | Usage |
|---------|---------|-------|
| std_msgs/String | std_msgs | Basic string |
| geometry_msgs/Twist | geometry_msgs | Linear + angular velocity |
| sensor_msgs/LaserScan | sensor_msgs | LiDAR data |
| sensor_msgs/Image | sensor_msgs | Camera frame |
| nav_msgs/Odometry | nav_msgs | Robot pose + velocity |
| geometry_msgs/PoseStamped | geometry_msgs | Pose with timestamp |
5.2 Publisher
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
class VelocityPublisher(Node):
def __init__(self):
super().__init__('velocity_publisher')
# create_publisher(MsgType, topic_name, queue_size)
self.pub = self.create_publisher(Twist, '/cmd_vel', 10)
self.timer = self.create_timer(0.1, self.publish_cmd) # 10 Hz
def publish_cmd(self):
msg = Twist()
msg.linear.x = 0.2 # forward 0.2 m/s
msg.angular.z = 0.0 # no rotation
self.pub.publish(msg)
def main():
rclpy.init()
rclpy.spin(VelocityPublisher())
rclpy.shutdown()5.3 Subscriber
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
class VelocityListener(Node):
def __init__(self):
super().__init__('velocity_listener')
# create_subscription(MsgType, topic, callback, queue_size)
self.sub = self.create_subscription(
Twist, '/cmd_vel', self.on_cmd, 10
)
def on_cmd(self, msg: Twist):
self.get_logger().info(
f'linear.x={msg.linear.x:.2f} angular.z={msg.angular.z:.2f}'
)
def main():
rclpy.init()
rclpy.spin(VelocityListener())
rclpy.shutdown()5.4 Topic Data Flow
VelocityPublisher VelocityListener
Node Node
│ ▲
│ publish(Twist) │
└──────────── Topic: /cmd_vel ──────────────┘
(buffered queue, DDS)
Multiple subscribers are all notified simultaneously.
5.5 QoS Profiles
Quality of Service controls how messages are delivered.
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy
# For sensor data (best-effort, no history needed)
sensor_qos = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
durability=DurabilityPolicy.VOLATILE,
depth=10
)
# For critical commands (reliable delivery)
reliable_qos = QoSProfile(
reliability=ReliabilityPolicy.RELIABLE,
depth=10
)
self.sub = self.create_subscription(LaserScan, '/scan', self.cb, sensor_qos)Important: Publisher and subscriber QoS must be compatible or the connection silently fails.
6. Services — Request & Response
Services are synchronous — the caller blocks until the server responds. Use for one-off commands, not continuous data.
6.1 Service Server
from example_interfaces.srv import AddTwoInts
import rclpy
from rclpy.node import Node
class MathServer(Node):
def __init__(self):
super().__init__('math_server')
self.srv = self.create_service(
AddTwoInts, 'add_two_ints', self.handle_add
)
def handle_add(self, request, response):
response.sum = request.a + request.b
self.get_logger().info(f'{request.a} + {request.b} = {response.sum}')
return response
def main():
rclpy.init()
rclpy.spin(MathServer())
rclpy.shutdown()6.2 Service Client
from example_interfaces.srv import AddTwoInts
import rclpy
from rclpy.node import Node
class MathClient(Node):
def __init__(self):
super().__init__('math_client')
self.client = self.create_client(AddTwoInts, 'add_two_ints')
# Wait until server is available
while not self.client.wait_for_service(timeout_sec=1.0):
self.get_logger().warn('Waiting for server...')
def send_request(self, a, b):
req = AddTwoInts.Request()
req.a = a
req.b = b
future = self.client.call_async(req)
rclpy.spin_until_future_complete(self, future)
return future.result()
def main():
rclpy.init()
client = MathClient()
result = client.send_request(3, 5)
print(f'Result: {result.sum}') # 8
rclpy.shutdown()6.3 Service Flow
Client Node Server Node
│ │
│ call_async(Request{a=3, b=5}) │
│ ────────────────────────────────► │
│ │ handle_add()
│ │ response.sum = 8
│ Response{sum=8} │
│ ◄──────────────────────────────── │
│ │
future.result() │
7. Actions — Long-Running Tasks
Actions are for goals that take time and need feedback. Nav2 uses actions for navigation goals. They are non-blocking on the client side.
7.1 Action Flow
Action Client Action Server
│ │
│ send_goal(Goal) │
│ ────────────────────────────────► │
│ │
│ Feedback (periodic updates) │
│ ◄──────────────────────────────── │
│ Feedback │
│ ◄──────────────────────────────── │
│ │
│ Result (when done) │
│ ◄──────────────────────────────── │
│ │
│ cancel_goal() ─────────────────► │ (optional)
7.2 Action Client Example
import rclpy
from rclpy.node import Node
from rclpy.action import ActionClient
from nav2_msgs.action import NavigateToPose
from geometry_msgs.msg import PoseStamped
class NavClient(Node):
def __init__(self):
super().__init__('nav_client')
self._client = ActionClient(self, NavigateToPose, 'navigate_to_pose')
def send_goal(self, x, y):
goal = NavigateToPose.Goal()
goal.pose = PoseStamped()
goal.pose.header.frame_id = 'map'
goal.pose.pose.position.x = x
goal.pose.pose.position.y = y
goal.pose.pose.orientation.w = 1.0
self._client.wait_for_server()
future = self._client.send_goal_async(
goal,
feedback_callback=self.on_feedback
)
future.add_done_callback(self.goal_response_cb)
def on_feedback(self, feedback_msg):
fb = feedback_msg.feedback
self.get_logger().info(
f'Distance remaining: {fb.distance_remaining:.2f}m'
)
def goal_response_cb(self, future):
handle = future.result()
if handle.accepted:
result_future = handle.get_result_async()
result_future.add_done_callback(self.result_cb)
def result_cb(self, future):
result = future.result().result
self.get_logger().info('Navigation complete!')
def main():
rclpy.init()
client = NavClient()
client.send_goal(2.0, 1.5)
rclpy.spin(client)
rclpy.shutdown()8. Parameters
Parameters are node-level configuration values. They replace ROS1's parameter server.
from rclpy.node import Node
import rclpy
class ParamNode(Node):
def __init__(self):
super().__init__('param_node')
# Declare with default values
self.declare_parameter('speed', 0.5)
self.declare_parameter('frame_id', 'base_link')
# Read
speed = self.get_parameter('speed').get_parameter_value().double_value
frame_id = self.get_parameter('frame_id').get_parameter_value().string_value
self.get_logger().info(f'Speed: {speed}, Frame: {frame_id}')Setting Parameters at Runtime
# Set from CLI
ros2 param set /param_node speed 1.0
# List all params
ros2 param list /param_node
# Dump to YAML
ros2 param dump /param_nodeParameter File (YAML)
# config/robot_params.yaml
param_node:
ros__parameters:
speed: 0.8
frame_id: "odom"ros2 run my_robot param_node --ros-args --params-file config/robot_params.yaml9. TF2 — Coordinate Transforms
TF2 manages the tree of coordinate frames — it answers: "where is the robot's laser scanner relative to the map?" at any point in time.
9.1 Frame Tree for a Nav Robot
map
│
└── odom (world-fixed, drifts over time)
│
└── base_footprint (robot center on floor)
│
└── base_link (robot body center)
├── laser_link (LiDAR position)
├── imu_link (IMU position)
└── camera_link (camera position)
9.2 Broadcasting a Transform
from tf2_ros import TransformBroadcaster
from geometry_msgs.msg import TransformStamped
import rclpy
from rclpy.node import Node
class TFBroadcaster(Node):
def __init__(self):
super().__init__('tf_broadcaster')
self.br = TransformBroadcaster(self)
self.timer = self.create_timer(0.05, self.broadcast) # 20 Hz
def broadcast(self):
t = TransformStamped()
t.header.stamp = self.get_clock().now().to_msg()
t.header.frame_id = 'base_link' # parent frame
t.child_frame_id = 'laser_link' # child frame
# LiDAR is 15cm forward, 20cm above base_link
t.transform.translation.x = 0.15
t.transform.translation.z = 0.20
t.transform.rotation.w = 1.0 # no rotation
self.br.sendTransform(t)9.3 Looking Up a Transform
from tf2_ros import Buffer, TransformListener
from rclpy.node import Node
import rclpy
class TFListener(Node):
def __init__(self):
super().__init__('tf_listener')
self.tf_buffer = Buffer()
self.tf_listener = TransformListener(self.tf_buffer, self)
self.timer = self.create_timer(1.0, self.lookup)
def lookup(self):
try:
# Where is laser_link relative to map?
transform = self.tf_buffer.lookup_transform(
'map', # target frame
'laser_link', # source frame
rclpy.time.Time()
)
pos = transform.transform.translation
self.get_logger().info(f'Laser at map: x={pos.x:.2f} y={pos.y:.2f}')
except Exception as e:
self.get_logger().warn(f'TF not available: {e}')10. Launch Files
Launch files start multiple nodes at once with shared configuration.
10.1 Python Launch File
# launch/robot_launch.py
from launch import LaunchDescription
from launch_ros.actions import Node
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
def generate_launch_description():
return LaunchDescription([
# Declare arguments
DeclareLaunchArgument('use_sim_time', default_value='false'),
# LiDAR driver
Node(
package='rplidar_ros',
executable='rplidar_composition',
name='rplidar',
parameters=[{'serial_port': '/dev/ttyUSB0'}],
output='screen'
),
# Robot state publisher
Node(
package='robot_state_publisher',
executable='robot_state_publisher',
name='robot_state_publisher',
parameters=[{
'robot_description': open('urdf/robot.urdf').read(),
'use_sim_time': LaunchConfiguration('use_sim_time')
}]
),
# Your controller node
Node(
package='my_robot',
executable='controller_node',
name='controller',
output='screen'
),
])ros2 launch my_robot robot_launch.py
ros2 launch my_robot robot_launch.py use_sim_time:=true10.2 Register Launch Files in setup.py
import os
from glob import glob
setup(
# ...
data_files=[
('share/ament_index/resource_index/packages', ['resource/my_robot']),
('share/my_robot', ['package.xml']),
(os.path.join('share', 'my_robot', 'launch'), glob('launch/*.py')),
(os.path.join('share', 'my_robot', 'config'), glob('config/*.yaml')),
(os.path.join('share', 'my_robot', 'urdf'), glob('urdf/*.urdf')),
],
)11. URDF — Robot Description
URDF (Unified Robot Description Format) is an XML file describing your robot's geometry — links and joints.
11.1 Minimal Differential Drive Robot
<?xml version="1.0"?>
<robot name="my_robot">
<!-- BASE LINK (robot body) -->
<link name="base_link">
<visual>
<geometry>
<box size="0.3 0.2 0.1"/> <!-- 30cm long, 20cm wide, 10cm tall -->
</geometry>
<material name="blue">
<color rgba="0 0 0.8 1"/>
</material>
</visual>
<collision>
<geometry>
<box size="0.3 0.2 0.1"/>
</geometry>
</collision>
<inertial>
<mass value="5.0"/>
<inertia ixx="0.01" iyy="0.01" izz="0.01" ixy="0" ixz="0" iyz="0"/>
</inertial>
</link>
<!-- LEFT WHEEL -->
<link name="left_wheel">
<visual>
<geometry>
<cylinder radius="0.05" length="0.02"/>
</geometry>
</visual>
<inertial>
<mass value="0.5"/>
<inertia ixx="0.001" iyy="0.001" izz="0.001" ixy="0" ixz="0" iyz="0"/>
</inertial>
</link>
<joint name="left_wheel_joint" type="continuous">
<parent link="base_link"/>
<child link="left_wheel"/>
<origin xyz="0 0.11 -0.05" rpy="-1.5708 0 0"/> <!-- π/2 rotation -->
<axis xyz="0 0 1"/>
</joint>
<!-- RIGHT WHEEL (mirror of left) -->
<link name="right_wheel">
<visual>
<geometry>
<cylinder radius="0.05" length="0.02"/>
</geometry>
</visual>
<inertial>
<mass value="0.5"/>
<inertia ixx="0.001" iyy="0.001" izz="0.001" ixy="0" ixz="0" iyz="0"/>
</inertial>
</link>
<joint name="right_wheel_joint" type="continuous">
<parent link="base_link"/>
<child link="right_wheel"/>
<origin xyz="0 -0.11 -0.05" rpy="-1.5708 0 0"/>
<axis xyz="0 0 1"/>
</joint>
<!-- LASER LINK (LiDAR) -->
<link name="laser_link">
<visual>
<geometry>
<cylinder radius="0.035" length="0.04"/>
</geometry>
</visual>
</link>
<joint name="laser_joint" type="fixed">
<parent link="base_link"/>
<child link="laser_link"/>
<origin xyz="0.1 0 0.1"/> <!-- 10cm front, 10cm up -->
</joint>
</robot>11.2 Joint Types
| Type | Description | Example |
|------|-------------|---------|
| fixed | No movement | camera mount |
| continuous | Rotates freely | wheel |
| revolute | Rotates with limits | robot arm joint |
| prismatic | Slides linearly | linear actuator |
11.3 Publish Robot Description
ros2 run robot_state_publisher robot_state_publisher \
--ros-args -p robot_description:="$(cat urdf/robot.urdf)"12. Gazebo Simulation
Gazebo is a 3D physics simulator. Use it to test code without hardware.
12.1 Install Gazebo
sudo apt install ros-humble-gazebo-ros-pkgs -y12.2 Spawn Robot in Gazebo
# In your launch file
from launch.actions import ExecuteProcess, IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from ament_index_python.packages import get_package_share_directory
# Start Gazebo with a world
gazebo = IncludeLaunchDescription(
PythonLaunchDescriptionSource([
get_package_share_directory('gazebo_ros'),
'/launch/gazebo.launch.py'
]),
launch_arguments={'world': 'empty.world'}.items()
)
# Spawn robot from URDF
spawn = Node(
package='gazebo_ros',
executable='spawn_entity.py',
arguments=[
'-entity', 'my_robot',
'-topic', 'robot_description',
'-x', '0', '-y', '0', '-z', '0.05'
],
output='screen'
)12.3 Gazebo Plugins for Differential Drive
Add to your URDF <robot> block:
<gazebo>
<!-- Differential drive plugin — publishes /odom and subscribes to /cmd_vel -->
<plugin name="diff_drive" filename="libgazebo_ros_diff_drive.so">
<left_joint>left_wheel_joint</left_joint>
<right_joint>right_wheel_joint</right_joint>
<wheel_separation>0.22</wheel_separation> <!-- distance between wheels -->
<wheel_diameter>0.10</wheel_diameter>
<max_wheel_torque>20</max_wheel_torque>
<publish_odom>true</publish_odom>
<publish_odom_tf>true</publish_odom_tf>
<odometry_frame>odom</odometry_frame>
<robot_base_frame>base_link</robot_base_frame>
</plugin>
<!-- LiDAR plugin — publishes /scan -->
<plugin name="lidar" filename="libgazebo_ros_ray_sensor.so">
<ros>
<remapping>~/out:=scan</remapping>
</ros>
<output_type>sensor_msgs/LaserScan</output_type>
<frame_name>laser_link</frame_name>
</plugin>
</gazebo>13. Sensor Integration
13.1 LiDAR — LaserScan
from sensor_msgs.msg import LaserScan
import rclpy
from rclpy.node import Node
class LidarProcessor(Node):
def __init__(self):
super().__init__('lidar_processor')
self.sub = self.create_subscription(
LaserScan, '/scan', self.on_scan, 10
)
def on_scan(self, msg: LaserScan):
# msg.ranges = list of distances (meters) from min_angle to max_angle
# msg.ranges[0] = distance at angle_min
# msg.ranges[-1] = distance at angle_max
# Get minimum distance (closest obstacle)
min_dist = min(r for r in msg.ranges if r > msg.range_min)
# Get front distance (middle of scan)
mid = len(msg.ranges) // 2
front_dist = msg.ranges[mid]
self.get_logger().info(
f'Front: {front_dist:.2f}m Closest: {min_dist:.2f}m'
)
if front_dist < 0.5:
self.get_logger().warn('Obstacle ahead!')13.2 LaserScan Message Structure
LaserScan
├── header
│ ├── stamp (timestamp)
│ └── frame_id ('laser_link')
├── angle_min (-π or -3.14159)
├── angle_max (+π or +3.14159)
├── angle_increment (resolution, e.g., 0.00581 rad)
├── range_min (minimum valid distance, e.g., 0.12 m)
├── range_max (maximum valid distance, e.g., 12.0 m)
└── ranges[] (array of distances in meters)
inf = no reading, nan = invalid
13.3 Odometry
Odometry tracks robot position by integrating wheel encoder data.
from nav_msgs.msg import Odometry
from rclpy.node import Node
import rclpy
class OdomReader(Node):
def __init__(self):
super().__init__('odom_reader')
self.sub = self.create_subscription(
Odometry, '/odom', self.on_odom, 10
)
def on_odom(self, msg: Odometry):
x = msg.pose.pose.position.x
y = msg.pose.pose.position.y
# Convert quaternion to yaw
q = msg.pose.pose.orientation
import math
yaw = math.atan2(
2*(q.w*q.z + q.x*q.y),
1 - 2*(q.y*q.y + q.z*q.z)
)
vx = msg.twist.twist.linear.x
wz = msg.twist.twist.angular.z
self.get_logger().info(
f'x={x:.2f} y={y:.2f} yaw={math.degrees(yaw):.1f}° '
f'vx={vx:.2f} wz={wz:.2f}'
)13.4 RPLiDAR A1/A2 (Real Hardware)
sudo apt install ros-humble-rplidar-ros -y
# Grant USB permissions
sudo chmod 666 /dev/ttyUSB0
# Launch
ros2 launch rplidar_ros rplidar_a1_launch.py14. SLAM — Mapping & Localization
SLAM (Simultaneous Localization and Mapping) builds a map while tracking the robot's position within it.
14.1 How SLAM Works
Sensor Data SLAM Algorithm Output
───────────── ────────────────── ───────────
LiDAR scan ──► 1. Match scan to /map topic
(LaserScan) existing features (OccupancyGrid)
│
Odometry ──► 2. Estimate robot /map → /odom
(Odometry) position correction (TF transform)
│
3. Update map RViz viz
and pose estimate
14.2 Install slam_toolbox
sudo apt install ros-humble-slam-toolbox -y14.3 SLAM Config (config/slam_params.yaml)
slam_toolbox:
ros__parameters:
# Solver
solver_plugin: solver_plugins::CeresSolver
# Mode: mapping or localization
mode: mapping
# Frames
odom_frame: odom
map_frame: map
base_frame: base_footprint
# Scan topic
scan_topic: /scan
# Resolution
resolution: 0.05 # 5cm per cell
max_laser_range: 12.0
# Update rates
minimum_travel_distance: 0.2
minimum_travel_heading: 0.214.4 Launch SLAM
# In launch file
Node(
package='slam_toolbox',
executable='async_slam_toolbox_node',
name='slam_toolbox',
parameters=['config/slam_params.yaml'],
output='screen'
)# Visualize in RViz
ros2 launch slam_toolbox online_async_launch.py
# Save the map when done exploring
ros2 run nav2_map_server map_saver_cli -f ~/maps/my_map14.5 OccupancyGrid (Map Message)
OccupancyGrid
├── header (frame: map)
├── info
│ ├── resolution (meters/cell, e.g. 0.05)
│ ├── width (cells)
│ ├── height (cells)
│ └── origin (Pose of cell [0,0])
└── data[] (flat array, row-major)
0 = free space
100 = occupied (obstacle)
-1 = unknown
15. Nav2 — Autonomous Navigation
Nav2 is the ROS2 navigation stack. It takes a goal pose and safely drives the robot there using the map.
15.1 Nav2 Architecture
Goal (PoseStamped)
│
▼
┌─────────────────┐ ┌──────────────────────┐
│ BT Navigator │────►│ Global Planner │
│ (behavior tree) │ │ (NavFn / Smac) │
└─────────────────┘ └──────────────────────┘
│ │ Global path
│ ▼
│ ┌──────────────────────┐
│ │ Global Costmap │
│ │ (inflated obstacles) │
│ └──────────────────────┘
│
▼
┌─────────────────┐ ┌──────────────────────┐
│ Controller │────►│ Local Planner │
│ Server │ │ (DWB / RPP) │
└─────────────────┘ └──────────────────────┘
│ │
│ ┌──────────────────────┐
│ │ Local Costmap │
│ │ (real-time sensor) │
│ └──────────────────────┘
│
▼
/cmd_vel → Motor Controller → Robot moves
15.2 Install Nav2
sudo apt install ros-humble-navigation2 -y
sudo apt install ros-humble-nav2-bringup -y15.3 Nav2 Params (config/nav2_params.yaml)
bt_navigator:
ros__parameters:
global_frame: map
robot_base_frame: base_link
odom_topic: /odom
default_bt_xml_filename: "navigate_w_replanning_time.xml"
controller_server:
ros__parameters:
controller_plugins: ["FollowPath"]
FollowPath:
plugin: "nav2_regulated_pure_pursuit_controller::RegulatedPurePursuitController"
desired_linear_vel: 0.3
max_linear_accel: 2.5
max_linear_decel: 2.5
lookahead_dist: 0.6
local_costmap:
local_costmap:
ros__parameters:
update_frequency: 5.0
publish_frequency: 2.0
global_frame: odom
robot_base_frame: base_link
rolling_window: true
width: 3
height: 3
resolution: 0.05
global_costmap:
global_costmap:
ros__parameters:
update_frequency: 1.0
publish_frequency: 1.0
global_frame: map
robot_base_frame: base_link
resolution: 0.05
map_server:
ros__parameters:
yaml_filename: "/home/user/maps/my_map.yaml"15.4 Launch Nav2
# With a pre-built map (localization mode)
ros2 launch nav2_bringup bringup_launch.py \
map:=/home/user/maps/my_map.yaml \
use_sim_time:=false
# In RViz: use "2D Pose Estimate" to set initial position
# Then "Nav2 Goal" to send navigation goals15.5 Costmap Layers
┌──────────────────── Global Costmap ────────────────────────┐
│ │
│ Static Layer Inflation Layer Obstacle Layer │
│ (known map walls) (obstacle buffer) (sensor updates) │
│ + + = │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ ░ Free ░ Inflate ░ Obstacle ░ Inflate ░ Free ░ │
│ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │
│ │
│ Cost values: 0=free 128=inflated 254=obstacle │
└─────────────────────────────────────────────────────────────┘
16. End-to-End Project: Autonomous Nav Robot
This section walks through building a complete autonomous navigation robot from scratch — simulation first, then real hardware.
16.1 Project Architecture
my_nav_robot_ws/
└── src/
└── my_nav_robot/
├── my_nav_robot/
│ ├── __init__.py
│ ├── controller_node.py ← Velocity commands
│ ├── goal_sender.py ← Send nav goals
│ └── obstacle_avoider.py ← Emergency stop
├── urdf/
│ └── robot.urdf ← Robot description
├── config/
│ ├── slam_params.yaml
│ └── nav2_params.yaml
├── launch/
│ ├── sim_launch.py ← Simulation
│ └── real_launch.py ← Real robot
├── maps/
│ └── (saved after SLAM)
├── package.xml
└── setup.py
16.2 Full Simulation Launch File
# launch/sim_launch.py
import os
from launch import LaunchDescription
from launch_ros.actions import Node
from launch.actions import IncludeLaunchDescription, ExecuteProcess
from launch.launch_description_sources import PythonLaunchDescriptionSource
from ament_index_python.packages import get_package_share_directory
def generate_launch_description():
pkg = get_package_share_directory('my_nav_robot')
urdf = open(os.path.join(pkg, 'urdf', 'robot.urdf')).read()
return LaunchDescription([
# 1. Gazebo simulator
IncludeLaunchDescription(
PythonLaunchDescriptionSource([
get_package_share_directory('gazebo_ros'), '/launch/gazebo.launch.py'
]),
launch_arguments={'world': 'turtlebot3_world.world', 'verbose': 'false'}.items()
),
# 2. Robot description publisher
Node(
package='robot_state_publisher',
executable='robot_state_publisher',
parameters=[{'robot_description': urdf, 'use_sim_time': True}]
),
# 3. Spawn robot in Gazebo
Node(
package='gazebo_ros',
executable='spawn_entity.py',
arguments=['-entity', 'my_robot', '-topic', 'robot_description',
'-x', '0.0', '-y', '0.0', '-z', '0.05'],
output='screen'
),
# 4. SLAM
Node(
package='slam_toolbox',
executable='async_slam_toolbox_node',
parameters=[os.path.join(pkg, 'config', 'slam_params.yaml'),
{'use_sim_time': True}],
output='screen'
),
# 5. Nav2
IncludeLaunchDescription(
PythonLaunchDescriptionSource([
get_package_share_directory('nav2_bringup'), '/launch/navigation_launch.py'
]),
launch_arguments={
'use_sim_time': 'true',
'params_file': os.path.join(pkg, 'config', 'nav2_params.yaml')
}.items()
),
# 6. RViz
Node(
package='rviz2',
executable='rviz2',
arguments=['-d', os.path.join(pkg, 'config', 'nav2_default.rviz')],
output='screen'
),
])16.3 Goal Sender Node
# my_nav_robot/goal_sender.py
import rclpy
from rclpy.node import Node
from rclpy.action import ActionClient
from nav2_msgs.action import NavigateToPose
from geometry_msgs.msg import PoseStamped
import math
class GoalSender(Node):
def __init__(self):
super().__init__('goal_sender')
self._client = ActionClient(self, NavigateToPose, 'navigate_to_pose')
self.goals = [
(2.0, 0.0, 0.0), # (x, y, yaw_degrees)
(2.0, 2.0, 90.0),
(0.0, 2.0, 180.0),
(0.0, 0.0, 0.0), # back to start
]
self.goal_idx = 0
def yaw_to_quaternion(self, yaw_deg):
yaw = math.radians(yaw_deg)
return (0.0, 0.0, math.sin(yaw/2), math.cos(yaw/2)) # (x,y,z,w)
def send_next_goal(self):
if self.goal_idx >= len(self.goals):
self.get_logger().info('All goals completed! Tour done.')
return
x, y, yaw = self.goals[self.goal_idx]
self.get_logger().info(f'Sending goal {self.goal_idx+1}: ({x}, {y})')
goal_msg = NavigateToPose.Goal()
goal_msg.pose = PoseStamped()
goal_msg.pose.header.frame_id = 'map'
goal_msg.pose.header.stamp = self.get_clock().now().to_msg()
goal_msg.pose.pose.position.x = x
goal_msg.pose.pose.position.y = y
qx, qy, qz, qw = self.yaw_to_quaternion(yaw)
goal_msg.pose.pose.orientation.z = qz
goal_msg.pose.pose.orientation.w = qw
self._client.wait_for_server()
future = self._client.send_goal_async(
goal_msg, feedback_callback=self.feedback_cb
)
future.add_done_callback(self.goal_response_cb)
def feedback_cb(self, msg):
dist = msg.feedback.distance_remaining
self.get_logger().info(f' Distance remaining: {dist:.2f}m')
def goal_response_cb(self, future):
handle = future.result()
if not handle.accepted:
self.get_logger().error('Goal rejected!')
return
handle.get_result_async().add_done_callback(self.result_cb)
def result_cb(self, future):
self.get_logger().info(f'Goal {self.goal_idx+1} reached!')
self.goal_idx += 1
self.send_next_goal()
def main():
rclpy.init()
node = GoalSender()
node.send_next_goal()
rclpy.spin(node)
rclpy.shutdown()16.4 Emergency Stop / Obstacle Avoider
# my_nav_robot/obstacle_avoider.py
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import LaserScan
from geometry_msgs.msg import Twist
from std_msgs.msg import Bool
class ObstacleAvoider(Node):
"""
Monitors LiDAR. If obstacle closer than threshold,
publishes emergency stop and overrides /cmd_vel.
"""
STOP_DIST = 0.35 # meters — hard stop
SLOW_DIST = 0.70 # meters — slow down
def __init__(self):
super().__init__('obstacle_avoider')
self.scan_sub = self.create_subscription(LaserScan, '/scan', self.on_scan, 10)
self.cmd_pub = self.create_publisher(Twist, '/cmd_vel', 10)
self.estop_pub = self.create_publisher(Bool, '/emergency_stop', 10)
self.estop = False
def on_scan(self, msg: LaserScan):
# Check front 60° arc (±30° from center)
n = len(msg.ranges)
front_idx = slice(n//2 - n//12, n//2 + n//12)
front_ranges = [
r for r in msg.ranges[front_idx]
if msg.range_min < r < msg.range_max
]
if not front_ranges:
return
closest = min(front_ranges)
if closest < self.STOP_DIST:
if not self.estop:
self.get_logger().warn(f'ESTOP! Obstacle at {closest:.2f}m')
self.estop = True
# Publish hard stop
stop_msg = Twist() # all zeros = stop
self.cmd_pub.publish(stop_msg)
self.estop_pub.publish(Bool(data=True))
else:
if self.estop:
self.get_logger().info('Path clear, resuming.')
self.estop = False
self.estop_pub.publish(Bool(data=False))
def main():
rclpy.init()
rclpy.spin(ObstacleAvoider())
rclpy.shutdown()16.5 Step-by-Step Run Order
# Terminal 1 — Build & source
cd ~/ros2_ws
colcon build --symlink-install
source install/setup.bash
# Terminal 2 — Launch simulation + SLAM + Nav2 + RViz
ros2 launch my_nav_robot sim_launch.py
# In RViz:
# 1. Add display: LaserScan (/scan)
# 2. Add display: Map (/map)
# 3. Add display: RobotModel
# 4. Drive robot around with teleop to build map:
ros2 run teleop_twist_keyboard teleop_twist_keyboard
# Terminal 3 — Save map once explored
ros2 run nav2_map_server map_saver_cli -f ~/ros2_ws/src/my_nav_robot/maps/my_map
# Terminal 4 — Send autonomous goals
ros2 run my_nav_robot goal_sender16.6 Real Hardware (Raspberry Pi + RPLiDAR)
# Install on RPi
sudo apt install ros-humble-rplidar-ros ros-humble-ros2-control -y
# Launch real robot
ros2 launch my_nav_robot real_launch.py
# The real_launch.py replaces Gazebo with:
# 1. RPLiDAR driver → /scan
# 2. Motor driver → subscribes /cmd_vel, publishes /odom
# 3. SLAM + Nav2 (same as sim)17. Useful CLI Reference
Nodes
ros2 node list # List all running nodes
ros2 node info /node_name # Node details (topics, services, params)Topics
ros2 topic list # List all topics
ros2 topic list -t # Include message types
ros2 topic echo /topic_name # Print live messages
ros2 topic hz /topic_name # Message frequency
ros2 topic info /topic_name # Publisher/subscriber count
ros2 topic pub /cmd_vel geometry_msgs/msg/Twist \
'{linear: {x: 0.2}, angular: {z: 0.0}}' # Manually publishServices
ros2 service list # List all services
ros2 service type /service_name # Get service type
ros2 service call /add_two_ints \ # Call a service
example_interfaces/srv/AddTwoInts '{a: 3, b: 5}'Parameters
ros2 param list /node_name # List node params
ros2 param get /node_name param_name # Get value
ros2 param set /node_name param_name 1.5 # Set value
ros2 param dump /node_name # Dump to YAMLTF2
ros2 run tf2_tools view_frames # Generate TF tree PDF
ros2 run tf2_ros tf2_echo map base_link # Print live transformBag Files (Record & Replay)
ros2 bag record /scan /odom /cmd_vel # Record topics
ros2 bag info my_bag/ # Inspect bag
ros2 bag play my_bag/ # ReplayBuild
colcon build # Build all packages
colcon build --packages-select my_robot # Build one package
colcon build --symlink-install # No rebuild for Python edits
source install/setup.bash # Source after every buildDocumentation generated for ROS2 Humble — the recommended LTS release. Stack: slam_toolbox + Nav2 + Gazebo Classic + RPLiDAR A1/A2