Introduction: The Really Powerful Stuff
Parts 1 and 2 covered the mechanics. This part covers the magic: how to make launch files that adapt to different environments, respond to events, and manage complex robots elegantly.
If you haven’t read Parts 1-2, you’ll be confused here. Go back and read them first.
Part of the ROS 2 Wisdom Hierarchy
Level 1 (Parts 1-2): "How do I launch a node?"
Level 2 (This part): "How do I make it intelligent?"
Level 3 (Not covered): "Why did it work yesterday but not today?"
1. Substitutions: The Key to Reusable Launch Files
Substitutions are how you inject dynamic values into your launch file at runtime. They’re the glue that connects configuration to execution.
The Philosophy
# Wrong: Hardcoded values
Node(package='lidar', executable='driver', namespace='turtlebot') # Only works for turtlebot!
# Right: Configurable values
robot_name = LaunchConfiguration('robot_name')
Node(package='lidar', executable='driver', namespace=robot_name) # Works for any robot
# But LaunchConfiguration is not magic. How does it get the value?
# Answer: You have to declare it and pass it at runtime
DeclareLaunchArgument('robot_name', default_value='turtlebot')Substitution Types You’ll Actually Use
1. LaunchConfiguration: Reference Arguments
from launch import LaunchConfiguration, DeclareLaunchArgument
def generate_launch_description():
# Declare the argument
arg = DeclareLaunchArgument('robot_name', default_value='robot')
# Reference it with LaunchConfiguration
robot_name = LaunchConfiguration('robot_name')
# Use it in actions
node = Node(
package='driver',
executable='main',
namespace=robot_name, # This gets substituted at runtime
)
return LaunchDescription([arg, node])At runtime:
ros2 launch my_pkg launch.py robot_name:=turtlebot
# robot_name = 'turtlebot'
# namespace becomes 'turtlebot'
# Node is created at /turtlebot/main2. FindPackageShare: Get Package Paths
The most important substitution for real-world projects.
from launch.substitutions import FindPackageShare, PathJoinSubstitution
def generate_launch_description():
# Get the installation directory of a package
pkg_dir = FindPackageShare('my_robot_bringup')
# Build paths to config files
params_file = PathJoinSubstitution([
pkg_dir,
'config',
'robot_params.yaml'
])
node = Node(
package='controller',
executable='controller_node',
parameters=[params_file], # Use the config file
)
return LaunchDescription([node])Why this matters:
# Wrong: Hardcoded path breaks when package is installed differently
parameters=['/opt/ros/humble/share/my_robot_bringup/config/params.yaml']
# Right: Works anywhere the package is installed
parameters=[PathJoinSubstitution([
FindPackageShare('my_robot_bringup'),
'config',
'params.yaml'
])]3. PathJoinSubstitution: Build Paths Dynamically
Concatenate path components.
from launch.substitutions import PathJoinSubstitution, FindPackageShare
pkg = FindPackageShare('my_pkg')
# Build complex paths
config = PathJoinSubstitution([pkg, 'config', 'params.yaml'])
worlds = PathJoinSubstitution([pkg, 'worlds', 'office.world'])
meshes = PathJoinSubstitution([pkg, 'meshes', 'robot.dae'])
# Use in nodes
node = Node(
package='pkg',
executable='exe',
env={'CONFIG_PATH': config}, # Pass as environment variable
)4. EnvironmentVariable: Read System State
from launch.substitutions import EnvironmentVariable
def generate_launch_description():
# Read from environment
ros_log_level = EnvironmentVariable('ROS_LOG_LEVEL', default='info')
ros_domain_id = EnvironmentVariable('ROS_DOMAIN_ID', default='0')
node = Node(
package='pkg',
executable='exe',
env={
'ROS_LOG_LEVEL': ros_log_level,
'ROS_DOMAIN_ID': ros_domain_id,
}
)
return LaunchDescription([node])Use case: Different machines might have different environment setups. Read them at launch time.
5. Command: Execute Shell Commands
from launch.substitutions import Command
def generate_launch_description():
# Execute a command and use its output
hostname = Command(['hostname'])
node = Node(
package='pkg',
executable='exe',
env={
'HOSTNAME': hostname, # Gets the computer's hostname
}
)
return LaunchDescription([node])Advanced example:
# Get the URDF for the robot based on ROS_DISTRO
urdf = Command([
'xacro',
FindPackageShare('my_robot_description'),
'robots',
'my_robot.xacro'
])6. TextSubstitution: Static Text (For Concatenation)
Rarely used, but useful for building strings.
from launch.substitutions import TextSubstitution
namespace = TextSubstitution(text='robot/')
node = Node(
package='pkg',
executable='exe',
remappings=[
('/cmd_vel', [namespace, 'cmd_vel']), # /robot/cmd_vel
]
)Real-World Example: Substitution Pattern
from launch import (
LaunchDescription,
LaunchConfiguration,
DeclareLaunchArgument,
)
from launch.substitutions import (
FindPackageShare,
PathJoinSubstitution,
Command,
EnvironmentVariable,
)
from launch_ros.actions import Node
def generate_launch_description():
# ========== ARGUMENTS ==========
robot_name = DeclareLaunchArgument(
'robot_name',
default_value='turtlebot',
description='Name of robot'
)
sim_mode = DeclareLaunchArgument(
'sim',
default_value='true',
choices=['true', 'false'],
description='Simulation or hardware'
)
# ========== SUBSTITUTIONS ==========
robot_name_cfg = LaunchConfiguration('robot_name')
sim_cfg = LaunchConfiguration('sim')
pkg_share = FindPackageShare('my_robot_bringup')
# Build configuration paths
params_file = PathJoinSubstitution([
pkg_share,
'config',
'robot.yaml'
])
nav_params = PathJoinSubstitution([
pkg_share,
'config',
'nav2.yaml'
])
# Get system information
hostname = Command(['hostname'])
# ========== NODES ==========
controller = Node(
package='controller',
executable='controller_node',
name='controller',
namespace=robot_name_cfg, # Dynamic namespace
output='screen',
parameters=[
params_file, # Dynamic config file
{
'sim_time': sim_cfg, # Dynamic parameter
'hostname': hostname, # Dynamic system info
}
],
)
nav2 = Node(
package='nav2_bringup',
executable='nav2_node',
name='nav2',
namespace=robot_name_cfg,
parameters=[nav_params],
)
return LaunchDescription([
robot_name,
sim_mode,
controller,
nav2,
])At runtime:
# Default: turtlebot, simulation mode
ros2 launch my_robot_bringup my_launch.py
# Custom: robot1, hardware mode
ros2 launch my_robot_bringup my_launch.py robot_name:=robot1 sim:=false
# All substitutions are evaluated to their actual values2. Conditions: Controlling Execution Flow
Conditions determine whether an action gets executed.
IfCondition vs UnlessCondition
from launch.conditions import IfCondition, UnlessCondition
from launch import LaunchConfiguration, DeclareLaunchArgument
def generate_launch_description():
use_sim = DeclareLaunchArgument('sim', default_value='true')
use_sim_cfg = LaunchConfiguration('sim')
# Only execute if sim=true
simulator = Node(
package='gazebo',
executable='gazebo',
condition=IfCondition(use_sim_cfg) # Launches if 'true'
)
# Only execute if sim=false (hardware drivers)
real_hardware = Node(
package='hardware_driver',
executable='hardware_driver',
condition=UnlessCondition(use_sim_cfg) # Launches if 'false'
)
return LaunchDescription([
use_sim,
simulator,
real_hardware,
])Execution:
ros2 launch my_pkg launch.py sim:=true
|- IfCondition(sim:=true) -> Execute simulator
`- UnlessCondition(sim:=true) -> Skip hardware
ros2 launch my_pkg launch.py sim:=false
|- IfCondition(sim:=false) -> Skip simulator
`- UnlessCondition(sim:=false) -> Execute hardware
Complex Conditions: LaunchConfigurationEquals
from launch.conditions import LaunchConfigurationEquals
from launch import LaunchConfiguration, DeclareLaunchArgument
def generate_launch_description():
robot_type = DeclareLaunchArgument(
'robot',
default_value='wheeled',
choices=['wheeled', 'legged', 'aerial']
)
robot_cfg = LaunchConfiguration('robot')
# Only launch wheeled controller if robot=wheeled
wheeled = Node(
package='wheeled_control',
executable='wheeled',
condition=LaunchConfigurationEquals(
launch_configuration='robot',
value='wheeled'
)
)
# Only launch legged controller if robot=legged
legged = Node(
package='legged_control',
executable='legged',
condition=LaunchConfigurationEquals(
launch_configuration='robot',
value='legged'
)
)
# Only launch aerial controller if robot=aerial
aerial = Node(
package='aerial_control',
executable='aerial',
condition=LaunchConfigurationEquals(
launch_configuration='robot',
value='aerial'
)
)
return LaunchDescription([
robot_type,
wheeled,
legged,
aerial,
])Python Logic: PythonExpression
For really complex conditions, use Python expressions.
from launch.substitutions import PythonExpression
from launch.conditions import IfCondition
from launch import LaunchConfiguration, DeclareLaunchArgument
def generate_launch_description():
use_nav = DeclareLaunchArgument('nav', default_value='true')
use_sim = DeclareLaunchArgument('sim', default_value='true')
use_nav_cfg = LaunchConfiguration('nav')
use_sim_cfg = LaunchConfiguration('sim')
# Only launch nav2 if BOTH nav=true AND sim=true
complex_condition = PythonExpression([
"('true' == '", use_nav_cfg, "') and ",
"('true' == '", use_sim_cfg, "')"
])
nav2_sim = Node(
package='nav2_sim',
executable='nav2_sim_node',
condition=IfCondition(complex_condition)
)
return LaunchDescription([
use_nav,
use_sim,
nav2_sim,
])Result:
sim:=true, nav:=true → Launch ✅
sim:=true, nav:=false → Skip ✗
sim:=false, nav:=true → Skip ✗
sim:=false, nav:=false → Skip ✗
3. Event Handlers: Reacting to Node Lifecycle
Event handlers let you launch nodes in sequence or react to node failures.
The Event Types
from launch.event_handlers import (
OnProcessStart, # Node started
OnProcessExit, # Node exited
OnProcessIO, # Node output
OnShutdown, # System shutting down
)
from launch.actions import RegisterEventHandler, LogInfo
from launch_ros.actions import Node
def generate_launch_description():
main_node = Node(package='main', executable='main')
dependent_node = Node(package='dependent', executable='dep')
# ========== EVENT HANDLERS ==========
# 1. Launch dependent node AFTER main starts
launch_dependent = RegisterEventHandler(
event_handler=OnProcessStart(
target_action=main_node,
on_start=[
LogInfo(msg='Main node started!'),
dependent_node, # Launch this after main starts
]
)
)
# 2. Log when main node exits
log_exit = RegisterEventHandler(
event_handler=OnProcessExit(
target_action=main_node,
on_exit=[
LogInfo(msg='Main node exited!'),
]
)
)
# 3. Cleanup on system shutdown
cleanup = RegisterEventHandler(
event_handler=OnShutdown(
on_shutdown=[
LogInfo(msg='System shutting down...'),
]
)
)
return LaunchDescription([
main_node,
launch_dependent,
log_exit,
cleanup,
])Timing:
T0: Launch starts
|- main_node launched
T1: OnProcessStart(main_node) fires
|- LogInfo: "Main node started!"
`- dependent_node launched
T2: Both nodes running
|- (user presses Ctrl+C)
T3: Shutdown signal received
|- OnProcessExit(main_node) fires
| `- LogInfo: "Main node exited!"
|- OnShutdown fires
| `- LogInfo: "System shutting down..."
T4: System fully shut down
Real-World Example: Sequential Launch
from launch import LaunchDescription
from launch.actions import RegisterEventHandler, LogInfo
from launch.event_handlers import OnProcessStart, OnProcessExit
from launch_ros.actions import Node
def generate_launch_description():
"""
Launch nodes in sequence, not in parallel.
Sequence:
1. Hardware drivers (motors, sensors)
2. (Wait for drivers to initialize)
3. Navigation stack
4. (Wait for nav)
5. Application logic
"""
# Step 1: Hardware drivers
motor_driver = Node(
package='motor_controller',
executable='motor_node',
name='motors'
)
lidar_driver = Node(
package='lidar_driver',
executable='lidar_node',
name='lidar'
)
# Step 2: Navigation (starts after drivers initialize)
nav2 = Node(
package='nav2_bringup',
executable='nav2_node',
name='nav2'
)
# Step 3: Application (starts after nav initializes)
app = Node(
package='my_app',
executable='app_node',
name='app'
)
# ========== EVENT HANDLERS ==========
# Wait for both drivers before starting nav
wait_for_drivers = RegisterEventHandler(
event_handler=OnProcessStart(
target_action=lidar_driver, # When lidar is ready
on_start=[
LogInfo(msg='Drivers initialized! Starting navigation...'),
nav2,
]
)
)
# Wait for nav before starting app
wait_for_nav = RegisterEventHandler(
event_handler=OnProcessStart(
target_action=nav2,
on_start=[
LogInfo(msg='Navigation initialized! Starting application...'),
app,
]
)
)
return LaunchDescription([
motor_driver,
lidar_driver,
wait_for_drivers,
wait_for_nav,
])4. Putting It All Together: Complete Production System
from launch import (
LaunchDescription,
LaunchConfiguration,
DeclareLaunchArgument,
IncludeLaunchDescription,
)
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import (
FindPackageShare,
PathJoinSubstitution,
Command,
)
from launch.conditions import IfCondition
from launch.actions import RegisterEventHandler, LogInfo, GroupAction
from launch.event_handlers import OnProcessStart
from launch_ros.actions import Node, PushROSNamespace
from launch_ros.substitutions import FindPackagePrefix
def generate_launch_description():
"""
Complete autonomous mobile robot (AMR) bringup.
Features:
- Multi-robot support (multiple instances)
- Simulation or hardware mode
- Sequential startup with event handlers
- Modular configuration
- Full conditional logic
"""
# ========== ARGUMENTS ==========
robot_name = DeclareLaunchArgument(
'name',
default_value='amr_1',
description='Robot name'
)
sim_mode = DeclareLaunchArgument(
'sim',
default_value='true',
choices=['true', 'false'],
description='Simulation mode'
)
use_nav = DeclareLaunchArgument(
'nav',
default_value='true',
choices=['true', 'false'],
description='Enable navigation'
)
# ========== SUBSTITUTIONS ==========
name_cfg = LaunchConfiguration('name')
sim_cfg = LaunchConfiguration('sim')
nav_cfg = LaunchConfiguration('nav')
pkg_share = FindPackageShare('amr_bringup')
config = PathJoinSubstitution([
pkg_share,
'config',
'robot.yaml'
])
nav_config = PathJoinSubstitution([
pkg_share,
'config',
'nav2.yaml'
])
# ========== CORE HARDWARE NODES ==========
# Robot state publisher (always runs)
state_pub = Node(
package='robot_state_publisher',
executable='robot_state_publisher',
name='state_publisher',
namespace=name_cfg,
parameters=[{'use_sim_time': sim_cfg}]
)
# Motor controller
motors = Node(
package='motor_controller',
executable='motor_node',
name='motors',
namespace=name_cfg,
output='screen',
parameters=[config],
)
# Sensor drivers (grouped under robot namespace)
sensors = GroupAction(
actions=[
PushROSNamespace(name_cfg),
Node(
package='lidar_driver',
executable='lidar_node',
name='lidar',
output='screen',
),
Node(
package='imu_driver',
executable='imu_node',
name='imu',
),
]
)
# ========== CONDITIONAL: SIMULATION ==========
gazebo = Node(
package='gazebo_ros',
executable='gazebo',
name='gazebo',
condition=IfCondition(sim_cfg),
output='screen',
)
# ========== CONDITIONAL: NAVIGATION ==========
nav2 = Node(
package='nav2_bringup',
executable='nav2_node',
name='nav2',
namespace=name_cfg,
condition=IfCondition(nav_cfg),
parameters=[nav_config],
)
# ========== EVENT HANDLERS: SEQUENTIAL STARTUP ==========
# Start sensors after motors initialize
start_sensors = RegisterEventHandler(
event_handler=OnProcessStart(
target_action=motors,
on_start=[
LogInfo(msg=['Motor controller ready, starting sensors...']),
sensors,
]
)
)
# Start navigation after sensors initialize
start_nav = RegisterEventHandler(
event_handler=OnProcessStart(
target_action=motors, # This is a bit hacky; ideally reference sensors
on_start=[
LogInfo(msg=['Sensors ready, starting navigation...']),
nav2,
]
)
)
# ========== INCLUDE EXTERNAL LAUNCHES ==========
perception = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
PathJoinSubstitution([
pkg_share,
'launch',
'perception.py'
])
),
launch_arguments={
'robot_name': name_cfg,
}.items(),
)
# ========== ASSEMBLE ==========
return LaunchDescription([
# Arguments
robot_name,
sim_mode,
use_nav,
# Core hardware
state_pub,
motors,
start_sensors,
# Conditional nodes
gazebo,
start_nav,
# External launches
perception,
])Usage:
# Default: robot_1, simulation, navigation enabled
ros2 launch amr_bringup robot_bringup.py
# Hardware mode: robot_2, no simulation, with navigation
ros2 launch amr_bringup robot_bringup.py \
name:=robot_2 \
sim:=false \
nav:=true
# Show all arguments
ros2 launch amr_bringup robot_bringup.py --show-argsPart 3 Summary: The Complete Picture
You now understand:
- Substitutions - Dynamic values (LaunchConfiguration, FindPackageShare, etc.)
- Conditions - Conditional execution (IfCondition, UnlessCondition, etc.)
- Event Handlers - Reactive patterns (OnProcessStart, OnProcessExit, etc.)
- Advanced Patterns - Combining everything into production systems
The Substitution Evaluation Pipeline
Understanding when substitutions are evaluated is crucial:
1. Launch file is parsed (Python code runs)
`- Actions are created
`- Substitutions are embedded in actions (NOT yet evaluated)
2. LaunchDescription is returned
`- Contains actions with unevaluated substitutions
3. Launch manager receives LaunchDescription
`- Iterates through actions
4. For each action:
a) Evaluate all substitutions in that action
b) Execute the action with evaluated values
c) Fire event handlers if applicable
5. All actions executed
`- System running
Example:
arg = DeclareLaunchArgument('name', default_value='robot')
name_cfg = LaunchConfiguration('name')
node = Node(
package='pkg',
executable='exe',
namespace=name_cfg # NOT evaluated yet!
)
# At this point in the Python code, 'name_cfg' is still a LaunchConfiguration object
# It doesn't become 'robot' until the launch manager evaluates it during executionDebugging Substitutions
# 1. Show the actual launch description
ros2 launch my_pkg launch.py --show
# 2. Launch with verbose output (shows substitution evaluation)
ros2 launch my_pkg launch.py -v
# 3. Check what substitutions actually became
# (Unfortunately ROS 2 doesn't print this directly)
# Solution: Add LogInfo actions
from launch.actions import LogInfo
from launch import LaunchConfiguration
LogInfo(msg=['Robot name is: ', LaunchConfiguration('name')])Common Mistakes & How to Fix Them
Mistake 1: Substitution Not Declared
# Wrong: Using a substitution that was never declared
name_cfg = LaunchConfiguration('robot_name')
Node(namespace=name_cfg)
# Right: Declare it first
DeclareLaunchArgument('robot_name', default_value='robot')
name_cfg = LaunchConfiguration('robot_name')
Node(namespace=name_cfg)Mistake 2: Condition Always True/False
# Wrong: Hardcoded condition (always true)
condition=IfCondition('true')
# Right: Use a substitution
use_sim = LaunchConfiguration('use_sim')
condition=IfCondition(use_sim)Mistake 3: Order Matters in Event Handlers
# Wrong: Referencing a node before it is created
RegisterEventHandler(...target_action=undefined_node...)
# Right: Create the node first
main_node = Node(...)
handler = RegisterEventHandler(...target_action=main_node...)
ld = LaunchDescription([main_node, handler])Quick Reference: All Substitutions
| Type | Import | Usage |
|---|---|---|
| LaunchConfiguration | launch | LaunchConfiguration('param_name') |
| FindPackageShare | launch_ros.substitutions | FindPackageShare('package_name') |
| PathJoinSubstitution | launch.substitutions | PathJoinSubstitution([path1, path2]) |
| EnvironmentVariable | launch.substitutions | EnvironmentVariable('VAR_NAME') |
| Command | launch.substitutions | Command(['cmd', 'arg']) |
| TextSubstitution | launch.substitutions | TextSubstitution(text='string') |
| PythonExpression | launch.substitutions | PythonExpression(['expr']) |
You’ve Made It!
If you understand everything in Parts 1-3, you can write production-grade ROS 2 launch files. Congratulations.
Now go forth and launch robots. And please, write documentation for your launch files so future developers don’t have to suffer like we did.
Resources:
This guide was written at 2 AM after a launch file worked in one environment but not another. If you find it helpful, please share it. If you find errors, please fix them. If you have experienced launch file pain, you are not alone. 🤖