Command Palette

Search for a command to run...

Back to Blog
ROS2 Complete Guide
roboticsros2autonomousnavigationslam

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

  1. What is ROS2?
  2. Installation & Setup
  3. Core Architecture
  4. Nodes
  5. Topics — Publish & Subscribe
  6. Services — Request & Response
  7. Actions — Long-Running Tasks
  8. Parameters
  9. TF2 — Coordinate Transforms
  10. Launch Files
  11. URDF — Robot Description
  12. Gazebo Simulation
  13. Sensor Integration
  14. SLAM — Mapping & Localization
  15. Nav2 — Autonomous Navigation
  16. End-to-End Project: Autonomous Nav Robot
  17. 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 tool

2.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 ~/.bashrc

2.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-install means 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_node

4.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_node

Parameter 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.yaml

9. 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:=true

10.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 -y

12.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.py

14. 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 -y

14.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.2

14.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_map

14.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 -y

15.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 goals

15.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_sender

16.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 publish

Services

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 YAML

TF2

ros2 run tf2_tools view_frames          # Generate TF tree PDF
ros2 run tf2_ros tf2_echo map base_link # Print live transform

Bag Files (Record & Replay)

ros2 bag record /scan /odom /cmd_vel    # Record topics
ros2 bag info my_bag/                   # Inspect bag
ros2 bag play my_bag/                   # Replay

Build

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 build

Documentation generated for ROS2 Humble — the recommended LTS release. Stack: slam_toolbox + Nav2 + Gazebo Classic + RPLiDAR A1/A2

Design & Developed by aspirant op
© 2026. All rights reserved.