PTENES
MODULE 4.6 · PROJECT

🏁 Project: Robot That Maps and Navigates

Time to bring the whole course together in a real robot: ESP32 with micro-ROS for the motors, Raspberry Pi 5 with ROS 2 in command and a 2D LiDAR for its eyes. A single launch file starts everything; the robot maps the house and then moves on its own from one room to another, with safety interlocks.

6
Topics
Advanced
Level
Project
Type
4.1 a 4.5
Prerequisites
0%
0 of 0 topics read · Section 1 of 6

Detailed content

1

🏗️ Architecture of the Real ROS 2 Robot

The project robot is the one from Track 3 (chassis, motors with encoders, ESP32, and Raspberry Pi 5) with a 2D LiDAR on top. The division of responsibilities follows the course rule: the computer thinks, the microcontroller controls. The ESP32 reads the encoders and IMU, drives the motors, and cuts power if it stops receiving commands. The Pi 5 runs ROS 2: the LiDAR driver, micro-ROS agent, SLAM, and Nav2. Your PC is only used to view the robot through RViz2 over the network.

Raspberry Pi 5 · ROS 2 micro_ros_agent LiDAR driver → /scan robot_state_publisher (TF) slam_toolbox or AMCL Nav2 → /cmd_vel ESP32micro-ROSpub: /odom, /imusub: /cmd_velmotors + encoders 2D LiDAR PCRViz2 USBUSBWi-Fi separate motor battery, common GND, physical cutoff switch

How to read this map: the amber box is the Pi 5, where all the ROS 2 “intelligence” runs. The cyan parts on the left are the hardware connected over USB. The dashed PC is optional: the robot works without it; you just lose the RViz2 display.

🌱 New here?

With two USB-to-serial adapters, Linux might call the LiDAR /dev/ttyUSB0 today and ttyUSB1 tomorrow. A udev rule creates a fixed name (/dev/lidar, /dev/esp32) based on each adapter’s serial number. And ROS_DOMAIN_ID is the ROS 2 network “channel”: the Pi and PC need the same number to see each other, while nearby robots use different numbers.

Goal: assign fixed names to the ports on the Pi 5 and match the ROS 2 channel with the PC. You can read the <idVendor>, <idProduct> e <serial> values with the first command, connecting one adapter at a time.

terminal (on the Pi 5)
# 1) find the details of the adapter connected now
udevadm info -a -n /dev/ttyUSB0 | grep -m3 -E "idVendor|idProduct|serial"

# 2) create the rule (one line per device) and reload it
sudo nano /etc/udev/rules.d/99-robo.rules
#  SUBSYSTEM=="tty", ATTRS{idVendor}=="<idVendor>", ATTRS{idProduct}=="<idProduct>", ATTRS{serial}=="<serial>", SYMLINK+="lidar"
#  SUBSYSTEM=="tty", ATTRS{idVendor}=="<idVendor>", ATTRS{idProduct}=="<idProduct>", ATTRS{serial}=="<serial>", SYMLINK+="esp32"
sudo udevadm control --reload-rules && sudo udevadm trigger

# 3) same ROS 2 channel on the Pi and PC (choose a number from 0 to 101)
echo "export ROS_DOMAIN_ID=42" >> ~/.bashrc && source ~/.bashrc
ls -l /dev/lidar /dev/esp32

How to check: o ls terminal /dev/lidar e /dev/esp32 pointing to a ttyUSB device; switch the USB ports and restart, and the names will still be right. On the PC, with the same ROS_DOMAIN_ID, ros2 topic list shows the robot’s topics.

Why learn this

A robot that works on the bench but fails “sometimes” almost always has one of these issues: a changed port, motor power causing the Pi to crash, or the network mixing up robots. A clear architecture prevents all three before they happen.

Key concepts

The Pi thinks
ROS 2, SLAM, Nav2
ESP32 age
motors and stopping
udev rule
fixed port name
ROS_DOMAIN_ID
network channel
2

🚀 Launch files that bring everything up

Opening five terminals for every test gets tiring and leads to errors. A launch file written in Python describes all the nodes, their parameters, and the included launch files, then brings everything up with one command. This one has a switch: slam:=True to map, slam:=False to navigate using the saved map. It lives in the launch/ package's folder meu_robo that you created in module 4.1 (the URDF comes from meu_robo_description, from 4.2).

Goal: a launch file that brings up the robot description, micro-ROS agent, LiDAR, and Nav2 (with SLAM or with a map). Adjust the xacro file name, your LiDAR's serial speed (the C1 and A1 use different values: check the driver's launch file), and the map path.

meu_robo/launch/robo_completo.launch.py
# Brings up the entire real robot: TF, ESP32 (micro-ROS), LiDAR, and Nav2 with SLAM or a map
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import Command, LaunchConfiguration
from launch_ros.actions import Node
from launch_ros.parameter_descriptions import ParameterValue


def generate_launch_description():
    pkg = get_package_share_directory('meu_robo')
    nav2 = get_package_share_directory('nav2_bringup')
    desc = get_package_share_directory('meu_robo_description')   # package from module 4.2
    xacro_arq = os.path.join(desc, 'urdf', 'robo.urdf.xacro')
    descricao = ParameterValue(Command(['xacro ', xacro_arq]), value_type=str)

    return LaunchDescription([
        DeclareLaunchArgument('slam', default_value='True',
                              description='True = map; False = navigate using the saved map'),
        DeclareLaunchArgument('mapa', default_value=os.path.expanduser('~/mapas/casa.yaml')),
        DeclareLaunchArgument('params', default_value=os.path.join(pkg, 'config', 'nav2_robo.yaml')),

        # 1) URDF -> fixed TFs (base_link -> laser, base_link -> imu)
        Node(package='robot_state_publisher', executable='robot_state_publisher',
             parameters=[{'robot_description': descricao}]),

        # 2) bridge to the ESP32: /ticks and /cmd_vel pass through here (module 4.3)
        Node(package='micro_ros_agent', executable='micro_ros_agent',
             arguments=['serial', '--dev', '/dev/esp32', '-b', '115200']),

        # 3) LiDAR (RPLIDAR; for the LD19, replace the package, executable, and parameters)
        Node(package='rplidar_ros', executable='rplidar_node',
             parameters=[{'serial_port': '/dev/lidar', 'serial_baudrate': 460800,
                          'frame_id': 'laser'}]),

        # 3b) odometry node from module 4.3: /ticks -> /odom and TF odom -> base_link
        Node(package='meu_robo', executable='odometria'),

        # 4) Nav2: with slam:=True, it also brings up slam_toolbox
        IncludeLaunchDescription(
            PythonLaunchDescriptionSource(os.path.join(nav2, 'launch', 'bringup_launch.py')),
            launch_arguments={'slam': LaunchConfiguration('slam'),
                              'map': LaunchConfiguration('mapa'),
                              'params_file': LaunchConfiguration('params'),
                              'use_sim_time': 'False'}.items()),
    ])
terminal (on the Pi 5)
# in meu_robo's setup.py (ament_python package), add this inside data_files:
#   (os.path.join('share', package_name, 'launch'), glob('launch/*.launch.py')),
#   (os.path.join('share', package_name, 'config'), glob('config/*.yaml')),
# (and "import os" + "from glob import glob" at the top of setup.py)
mkdir -p ~/ros2_ws/src/meu_robo/config
cp ~/meu_nav2.yaml ~/ros2_ws/src/meu_robo/config/nav2_robo.yaml   # from module 4.5
cd ~/ros2_ws && colcon build --symlink-install && source install/setup.bash
ros2 launch meu_robo robo_completo.launch.py slam:=True

How to check

  • ✓The LiDAR spins, the terminal shows the micro-ROS agent creating a session with the ESP32, and Nav2 finishes with its servers "active".
  • ✓ros2 topic list terminal /scan, /odom, /map e /cmd_vel.
  • ✗"executable not found" for the LiDAR: open the driver's launch file and copy the executable name and parameters from there.

Why learn this

The launch file is the robot's "recipe": whoever picks up your project (a colleague, a student, or you six months from now) can bring everything up with one line instead of guessing the order of the terminals.

Key concepts

Launch file
brings everything up together
Argument
slam:=True/False
Include
reuses Nav2's launch file
data_files
installs launch and config
3

🔄 From simulation to reality: what changes

In Gazebo, everything was perfect: wheels that don't slip, noise-free LiDAR, a synchronized clock. On the real robot, SLAM and Nav2 are the same; what changes is who publishes the data e a and its quality. Most problems when moving to the real robot come from odometry and the frame tree, not Nav2.

map odom base_link laser imu slam_toolbox or AMCL (corrects drift) odometry node (ESP32 ticks, 4.3) robot_state_publisher (URDF, fixed)

How to read this map: each arrow is a transform, and the text beside it tells you who publishes it. Each arrow needs exactly one publisher. The classic error is when odometry and another node publish odom → base_link at the same time: the robot "jumps" in RViz2.

ItemSimulatedReal
/scanGazebo pluginLiDAR driver
/odom + TF odomwheel pluginodometry node (ESP32 ticks)
/cmd_velmoves the modelmicro-ROS → H-bridge → motors
use_sim_timeTrueFalse
Erroralmost zeroslipping, noise, battery running low

Goal: check the three real robot sources before mapping. Run with the launch file from topic 2 active and the robot with its wheels lifted for the command test.

terminal
# 1) rates: /scan close to the LiDAR rate, /odom stable
ros2 topic hz /scan
ros2 topic hz /odom

# 2) does the odometry move with the robot? (push it 1 m by hand and compare)
ros2 run tf2_ros tf2_echo odom base_link

# 3) does the command reach the motors? (wheels off the ground; Ctrl+C to stop)
ros2 topic pub -r 10 /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.1}}"

How to check

  • ✓When you push the robot 1 m in a straight line, the x value of tf2_echo should be close to 1.0 (within a few centimeters).
  • ✓With pub, both wheels turn forward; when you stop publishing, they stop in less than a second (ESP32 watchdog).
  • ✗x is negative or twice the expected value: the encoder direction is reversed or the wheel radius is wrong in the odometry code.

Why learn this

Sim-to-real is a skill you'll use again in Track 5, with the arm. The lesson is the same: keep the "brain" the same and validate each real-world data source, one at a time.

Key concepts

One publisher
per transform
tf2_echo
reads a transform
Wheels off the ground
1st motor test
Sim-to-real
same brain
4

🗺️ Map the environment

With the sources validated, create a real map. Follow the procedure from module 4.4, now with the complete robot: launch with slam:=True, teleop from the PC, and save the map using the place's name. Create one map per environment (home, classroom, lab) and store them all in ~/mapas.

Step by step

1

Full battery, area prepared

A low battery changes the motor speed and worsens odometry. Block off stairs, clear the floor, and let people know.

2

Bring up the robot in SLAM mode

On the Pi (over SSH): ros2 launch meu_robo robo_completo.launch.py slam:=True. On the PC: RViz2 with Fixed Frame = map.

3

Drive slowly and close loops

Teleop from the PC, low speed, slow turns, a complete loop in each room, and return to the starting point.

4

Save and mark the forbidden area

Save with map_saver. Then open casa.pgm in an image editor and paint a wide strip in front of the stairs black: for Nav2, that becomes a wall.

Goal: the commands for the mapping session, one terminal per block.

terminal
# Pi 5 (SSH): complete robot in mapping mode
ros2 launch meu_robo robo_completo.launch.py slam:=True

# PC: view and drive (same ROS_DOMAIN_ID)
rviz2 &
ros2 run teleop_twist_keyboard teleop_twist_keyboard

# Pi 5 (another SSH), with the good map on screen: save
mkdir -p ~/mapas && ros2 run nav2_map_server map_saver_cli -f ~/mapas/casa

How to check: ~/mapas/casa.pgm shows the map without duplicated walls; straight corridors stay straight. If a wall appears “doubled,” redo that section and close the loop before saving.

💡 A real keepout area

Painting the PGM is the simple way. Nav2 also has exclusion zones (keepout) with a mask separate from the map, so you can change the area without remaking the map; look for “Keepout Zones” at docs.nav2.org. In either case, the main safeguard near stairs is physical: a gate, barrier, or closed door.

Why learn this

It’s the same process a technician follows when installing a cleaning or delivery robot in a building: map the area, review the map, and mark where the robot cannot go.

Key concepts

slam:=True
mapping mode
SSH + RViz2
Pi works, PC displays
One map
per environment
Keepout
keepout zone
5

🚪 Navigate between rooms

Coordinates like (3,0; 1,5) don’t mean anything to someone using the robot. The final step is to give places names: a dictionary that links “kitchen” to a map pose. That way, “go to the kitchen” becomes a simple command, which later can come from a button, a message, or your voice (Track 5).

“kitchen”request PLACESbase: 0,0 · 0°kitchen: 3,0 1,5 · 90°bedroom: 0,5 −1,5 goToPoseNav2 robot goes unknown name → the script refuses and lists the valid names

How to read this map: the request (cyan) only becomes movement if the name is in the amber table. This “closed list” is the same safety idea Track 5 uses with language models: the request selects from allowed options and never invents coordinates.

Goal: one command python3 vai_para.py cozinha that takes the robot to the room by name. First, bring up the robot with slam:=False and set the initial position in RViz2. Measure the poses on your map by moving the mouse over RViz2.

vai_para.py
#!/usr/bin/env python3
# Usage: python3 vai_para.py cozinha   (only accepts names from the LUGARES list)
import math
import sys
import rclpy
from geometry_msgs.msg import PoseStamped
from nav2_simple_commander.robot_navigator import BasicNavigator, TaskResult

LUGARES = {                       # name: (x, y, angle in degrees) on the casa.yaml map
    'base': (0.0, 0.0, 0.0),
    'sala': (1.2, 0.8, 0.0),
    'cozinha': (3.0, 1.5, 90.0),
    'quarto': (0.5, -1.5, 180.0),
}


def main():
    nome = sys.argv[1].lower() if len(sys.argv) > 1 else ''
    if nome not in LUGARES:
        print('Unknown place. Use one of these:', ', '.join(LUGARES))
        return
    x, y, graus = LUGARES[nome]

    rclpy.init()
    nav = BasicNavigator()
    nav.waitUntilNav2Active()
    alvo = PoseStamped()
    alvo.header.frame_id = 'map'
    alvo.header.stamp = nav.get_clock().now().to_msg()
    alvo.pose.position.x, alvo.pose.position.y = x, y
    alvo.pose.orientation.z = math.sin(math.radians(graus) / 2)
    alvo.pose.orientation.w = math.cos(math.radians(graus) / 2)

    print(f'Going to {nome}...')
    nav.goToPose(alvo)
    while not nav.isTaskComplete():
        pass
    ok = nav.getResult() == TaskResult.SUCCEEDED
    print('Cheguei.' if ok else 'I could not get there.')
    rclpy.shutdown()


if __name__ == '__main__':
    main()

How to check

  • ✓python3 vai_para.py cozinha prints “Going to kitchen...”, the robot passes through the doorway and stops facing the direction you set; then “Arrived.”.
  • ✓python3 vai_para.py garagem doesn’t move and lists the valid names.
  • ✗Stuck at the doorway? Check the costmap in RViz2: if inflation closes the passage, reduce the inflation_radius (module 4.5).

Why learn this

This is where the robot stops being a technical demo and becomes useful: carrying an object to the kitchen, patrolling the living room, returning to base. And the closed list of places is the first step toward safely connecting an AI “brain.”

Key concepts

Named places
name → pose
Closed list
refuses everything else
slam:=False
navigates the map
Doorway
inflation test
6

🛡️ Safety checklist and next steps

A robot moving on its own near people needs safeguards on several layers, because each one can fail in a different way. Software can freeze, the network can drop, and LiDAR can’t see a step. The rule is: no single layer is enough to keep you safe, and the strongest layer is always physical.

person supervising, area prepared Nav2: low speed, keepout zones ESP32: stop if /cmd_vel isn’t received physical switch cuts power to the motors

How to read this map: from the outside in, each layer is simpler and more reliable. If Nav2 makes a mistake, the ESP32 watchdog still stops the robot when commands disappear; if the ESP32 freezes, the physical switch cuts power. The dashed outline is you: for the first few weeks, stay close by.

✓ Before each navigation run

  • ✓Motor cutoff switch within reach and tested.
  • ✓Command watchdog on the ESP32: if there’s no /cmd_vel for half a second, the motors stop.
  • ✓Set a low maximum speed in velocity_smoother; increase it only after many tests.
  • ✓Stairs physically blocked off and marked as keepout on the map.
  • ✓Battery with BMS, secured to the chassis, wires kept away from the wheels.

✗ Never

  • ✗Letting the robot navigate on its own near children, older adults, or animals.
  • ✗Relying on 2D LiDAR to detect steps, glass, or low objects on the floor.
  • ✗Testing a new speed near stairs, a glass table, or fragile furniture.
  • ✗Leaving the lithium battery charging in the robot unattended.

Challenges to take it further

🧹 Step sensor

Two infrared sensors pointed at the floor (module 1.4) on the ESP32: if the floor disappears, the motors stop before any command from the Pi.

🔋 Return to base

Publish the battery voltage over micro-ROS and have the script send the robot to “base” when it drops below a threshold.

📷 Camera on patrol

Combine this with detection from Track 3: at each waypoint, take a photo and look for an object with YOLO26.

📱 Buttons on your phone

A simple page (Track 2) with one button for each room, calling the same LUGARES dictionary.

🌱 New here?

A command watchdog is a timer in the microcontroller that is “reset” each time a command is received. If the time runs out (network drops, Pi freezes, program closes), it stops the motors on its own. You saw the idea in the Wi-Fi control in module 2.1; with a ROS 2 robot it matters even more, because many programs stand between you and the wheels.

Why learn this

A mobile robot is the first machine in the course that can hurt someone without you touching it. Building the habit of using safety layers now will let you put a robotic arm and an AI model in control in Track 5 with confidence.

Key concepts

Layers
none is enough on its own
Watchdog
no command, stop
Physical switch
the final safeguard
Supervision
you nearby

✅ Module and Track 4 summary

✓
Architecture: ESP32 with micro-ROS on the motors, Pi 5 with ROS 2, LiDAR over USB, fixed port names.
✓
A launch file brings up the whole robot, with slam:=True to map and False to navigate.
✓
From simulation to reality: same SLAM and Nav2; validate /scan, odometry, and TF one source at a time.
✓
Named places: a fixed list of destinations, the same AI safety principle from Track 5.
✓
Safety in layers: slow Nav2, an ESP32 watchdog, a physical switch, and you nearby.

To go further

Next track

Track 5 · Modern AI and robotic arm: a language model chooses from permitted actions, local voice, the SO-101 arm with LeRobot, and, for the final project, your mobile base from this track with an arm on top.