Vaibhav Shende Vaibhav Shende

ROS 2 Launch Files: Part 3 - Substitutions, Conditions & Event Handlers

Master the advanced patterns that make ROS 2 launch files powerful: dynamic substitutions, conditional logic, and reactive event handlers.

ROS 2

ROS 2 Launch Files: Part 3 - Substitutions, Conditions & Event Handlers

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/main

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

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

Part 3 Summary: The Complete Picture

You now understand:

  1. Substitutions - Dynamic values (LaunchConfiguration, FindPackageShare, etc.)
  2. Conditions - Conditional execution (IfCondition, UnlessCondition, etc.)
  3. Event Handlers - Reactive patterns (OnProcessStart, OnProcessExit, etc.)
  4. 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 execution

Debugging 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

TypeImportUsage
LaunchConfigurationlaunchLaunchConfiguration('param_name')
FindPackageSharelaunch_ros.substitutionsFindPackageShare('package_name')
PathJoinSubstitutionlaunch.substitutionsPathJoinSubstitution([path1, path2])
EnvironmentVariablelaunch.substitutionsEnvironmentVariable('VAR_NAME')
Commandlaunch.substitutionsCommand(['cmd', 'arg'])
TextSubstitutionlaunch.substitutionsTextSubstitution(text='string')
PythonExpressionlaunch.substitutionsPythonExpression(['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. 🤖