Detailed content
🏗️ 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.
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.
# 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
🚀 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.
# 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()),
])
# 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
🔄 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.
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.
| Item | Simulated | Real |
|---|---|---|
| /scan | Gazebo plugin | LiDAR driver |
| /odom + TF odom | wheel plugin | odometry node (ESP32 ticks) |
| /cmd_vel | moves the model | micro-ROS → H-bridge → motors |
| use_sim_time | True | False |
| Error | almost zero | slipping, 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.
# 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
🗺️ 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
Full battery, area prepared
A low battery changes the motor speed and worsens odometry. Block off stairs, clear the floor, and let people know.
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.
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.
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.
# 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
🚪 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).
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.
#!/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
🛡️ 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.
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
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.
Publish the battery voltage over micro-ROS and have the script send the robot to “base” when it drops below a threshold.
Combine this with detection from Track 3: at each waypoint, take a photo and look for an object with YOLO26.
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
✅ Module and Track 4 summary
To go further
- Nav2: First-Time Robot Setup Guide — TF, URDF, odometry, and sensors for your own robot
- Nav2: Keepout Zones — forbidden zones without painting them onto the map
- ROS 2: launch tutorials — arguments, conditions, and inclusions (replace with jazzy if that's your version)
- slam_toolbox — lifelong mode for maps that change over time
- micro-ROS — agent, transports, and examples for ESP32
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.