How to Build and Use a Custom ROS 2 Launch File for a Field Robot
Adrian Krzemiński,

TL;DR: A ROS 2 launch file starts and configures multiple nodes, parameters, and remappings from a single command. For a field robot like the Leo Rover, a well-structured ROS 2 launch file brings up drivers, sensors (IMU, camera, LiDAR), the robot state publisher, and navigation stack with a consistent launch graph and configuration. This ROS 2 launch file tutorial walks you through Python and XML formats, shows a working example for a UGV with sensors and Nav2, and explains arguments, conditions, and namespaces you actually need outdoors.
Why a Custom ROS 2 Launch File Matters for a Field Robot
Field robots rarely run a single node. A typical Leo Rover deployment in research conditions needs the motor driver, odometry publisher, IMU filter, camera pipeline, optional LiDAR, transform tree, and Nav2, all started with the right parameters. Typing seven ros2 run commands per session is not reproducible. A custom ROS 2 launch file solves three concrete problems:
- Reproducibility: same parameters and remappings every boot.
- Configurability: switch sensors, simulation, or namespaces with one CLI argument.
- Composition: include other launch files (Nav2, SLAM Toolbox, robot_localization) without rewriting them.
This ROS 2 launch file tutorial uses ROS 2 Humble syntax, which is still common in ROS 2 field robot deployments. Check the Leo Rover image documentation for the ROS 2 distribution shipped with your image. The same patterns work on Jazzy with minor API tweaks.
What a ROS 2 Launch File Actually Is
In ROS 1, launch files were XML only. In ROS 2, the launch system is a Python framework (launch and launch_ros) with frontends for Python, XML, and YAML. The Python frontend gives you full programmatic control: conditionals, OpaqueFunction, environment variables, and dynamic parameter loading. XML and YAML are declarative and shorter for simple cases.
XML vs Python Launch Files
Both formats compile to the same internal representation. Choose based on the complexity of your logic.
| Feature | XML | Python |
| Readability for short launches | High | Medium |
| Conditional logic | Limited (if/unless) |
Full Python |
| Dynamic parameter generation | No | Yes (OpaqueFunction) |
| Including other launches | Yes | Yes |
| Loops over sensors | No | Yes |
| Best for | Static bringup | Multi-config field robots |
For Leo Rover field deployments where you toggle simulation, LiDAR presence, or namespace per mission, Python is the practical choice.
How to Structure a Launch File for the Leo Rover
A maintainable bringup follows a layered pattern. Each layer is a separate launch file, and a top-level file includes them.
description.launch.py: robot_state_publisher with URDF/Xacro.drivers.launch.py: motor controller, IMU, camera.localization.launch.py: robot_localization EKF fusing wheel odom + IMU.navigation.launch.py: Nav2 bringup with your params.bringup.launch.py: top-level, includes the four above with arguments.
A Working Python Launch File for Leo Rover
The example below starts the Leo Rover description, an RPLIDAR, an EKF for sensor fusion, and Nav2. It exposes arguments for namespace, LiDAR enable, and use_sim_time.
bringup.launch.py:
# bringup.launch.py - top-level Leo Rover field bringup
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription, GroupAction
from launch.conditions import IfCondition
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration, Command, FindExecutable
from launch_ros.actions import Node, PushRosNamespace
from ament_index_python.packages import get_package_share_directory
import os
def generate_launch_description():
# Declare CLI arguments - override at runtime with name:=value
namespace_arg = DeclareLaunchArgument(
'namespace', default_value='',
description='Top-level namespace for multi-robot setups')
use_lidar_arg = DeclareLaunchArgument(
'use_lidar', default_value='true',
description='Start RPLIDAR node')
use_sim_time_arg = DeclareLaunchArgument(
'use_sim_time', default_value='false',
description='Use simulation clock from /clock')
# Substitutions resolved at launch time
namespace = LaunchConfiguration('namespace')
use_lidar = LaunchConfiguration('use_lidar')
use_sim_time = LaunchConfiguration('use_sim_time')
# Package paths
leo_desc_dir = get_package_share_directory('leo_description')
leo_bringup_dir = get_package_share_directory('leo_bringup')
nav2_bringup_dir = get_package_share_directory('nav2_bringup')
tf_remaps = [('/tf', 'tf'), ('/tf_static', 'tf_static')]
# Robot description (URDF from xacro)
robot_description = Command([
FindExecutable(name='xacro'), ' ',
os.path.join(leo_desc_dir, 'urdf', 'leo.urdf.xacro')
])
robot_state_publisher = Node(
package='robot_state_publisher',
executable='robot_state_publisher',
parameters=[{
'use_sim_time': use_sim_time,
'robot_description': robot_description
}],
remappings=tf_remaps,
output='screen')
# RPLIDAR A2 - conditional on use_lidar argument
rplidar = Node(
package='rplidar_ros',
executable='rplidar_node',
parameters=[{
'serial_port': '/dev/ttyUSB0',
'serial_baudrate': 115200,
'frame_id': 'laser',
'scan_mode': 'Standard'
}],
condition=IfCondition(use_lidar),
output='screen')
# EKF for odom+IMU fusion - params from YAML
ekf = Node(
package='robot_localization',
executable='ekf_node',
name='ekf_filter_node',
parameters=[
os.path.join(leo_bringup_dir, 'config', 'ekf.yaml'),
{'use_sim_time': use_sim_time}
],
remappings=tf_remaps,
output='screen')
# Include Nav2 bringup
nav2 = IncludeLaunchDescription(
PythonLaunchDescriptionSource(
os.path.join(nav2_bringup_dir, 'launch', 'navigation_launch.py')),
launch_arguments={
'use_sim_time': use_sim_time,
'params_file': os.path.join(leo_bringup_dir, 'config', 'nav2_params.yaml')
}.items())
# Group everything under the namespace
group = GroupAction([
PushRosNamespace(namespace),
robot_state_publisher,
rplidar,
ekf,
nav2
])
return LaunchDescription([
namespace_arg,
use_lidar_arg,
use_sim_time_arg,
group
])
Run it with overrides for a no-LiDAR test:
ros2 launch leo_bringup bringup.launch.py use_lidar:=false namespace:=rover1
The Same Bringup in XML
For comparison, here is a reduced XML version. It is shorter but cannot express the conditional file-loading logic as cleanly.
<!-- bringup.launch.xml -->
<launch>
<arg name="use_lidar" default="true"/>
<arg name="use_sim_time" default="false"/>
<node pkg="robot_state_publisher" exec="robot_state_publisher">
<param name="use_sim_time" value="$(var use_sim_time)"/>
<param name="robot_description"
value="$(command 'xacro $(find-pkg-share leo_description)/urdf/leo.urdf.xacro')"/>
</node>
<node pkg="rplidar_ros" exec="rplidar_node"
if="$(var use_lidar)">
<param name="serial_port" value="/dev/ttyUSB0"/>
<param name="serial_baudrate" value="115200"/>
<param name="frame_id" value="laser"/>
<param name="scan_mode" value="Standard"/>
</node>
<node pkg="robot_localization" exec="ekf_node" name="ekf_filter_node">
<param from="$(find-pkg-share leo_bringup)/config/ekf.yaml"/>
<param name="use_sim_time" value="$(var use_sim_time)"/>
</node>
<include file="$(find-pkg-share nav2_bringup)/launch/navigation_launch.py">
<arg name="use_sim_time" value="$(var use_sim_time)"/>
<arg name="params_file" value="$(find-pkg-share leo_bringup)/config/nav2_params.yaml"/>
</include>
</launch>
How to Pass Parameters Cleanly
Hard-coding parameters inside the launch file works for prototypes. For field robots, externalize everything to YAML and load it per node. The EKF config ekf.yaml for Leo Rover typically looks like this:
# ekf.yaml - robot_localization EKF for Leo Rover
ekf_filter_node:
ros__parameters:
frequency: 30.0
two_d_mode: true
publish_tf: true
odom_frame: odom
base_link_frame: base_footprint
world_frame: odom
odom0: /wheel_odom
odom0_config: [false, false, false,
false, false, false,
true, false, false,
false, false, true,
false, false, false]
imu0: /imu/data
imu0_config: [false, false, false,
false, false, true,
false, false, false,
false, false, true,
true, false, false]
Keeping parameters in YAML lets you swap configurations per mission (indoor vs outdoor EKF tuning, for example) without touching launch logic.
How to Test and Debug Your Launch File
Before deploying to the field, validate the launch graph offline. Useful commands during development:
ros2 launch --show-args leo_bringup bringup.launch.pylists all declared arguments.ros2 launch --print-description leo_bringup bringup.launch.pyprints the launch description without launching it.ros2 node listafter launch verifies every expected node is alive.ros2 topic echo /tfconfirms the transform tree is being published; with the EKF alone, check the chain fromodomtobase_footprint, and with localization or SLAM running, checkmaptoodomas well.ros2 param dump /ekf_filter_nodechecks parameters loaded correctly.
If a node dies silently, set output='screen' and add emulate_tty=True to the Node action to see colored stderr in your terminal.
When to Use Composition Instead of Separate Processes
Each Node action spawns a separate process. For sensor-heavy field robots, intra-process communication via composable nodes cuts serialization overhead. Replace Node with ComposableNodeContainer when latency matters, for example in a camera-to-image-proc pipeline on the Leo Rover camera stream. The launch file structure stays the same; you swap the action type.
For research applications described on Fictionlab’s research page, composition is worth the effort when you run perception at 30 Hz or higher on the rover’s onboard computer.
Frequently Asked Questions
What is the difference between ros2 launch and ros2 run?
ros2 run starts a single executable from a package. ros2 launch executes a launch file that can start many nodes, include other launches, set parameters, and apply conditions. Use run for quick tests, launch for any real deployment.
Can I mix Python and XML launch files in one project?
Yes. A Python launch file can include XML launches with FrontendLaunchDescriptionSource, and XML can include Python with <include>. Many ROS 2 packages ship XML launches that you wrap in a Python top-level file.
How do I delay a node until another is ready?
Use RegisterEventHandler with OnProcessStart or OnProcessExit, or wrap the node in a TimerAction with a delay in seconds. Nav2 lifecycle nodes have their own activation sequence handled by the lifecycle manager, so you do not need delays there.
Why does my launch file fail with “package not found”?
The package is not in the current workspace or not sourced. Run source install/setup.bash after every colcon build. Verify with ros2 pkg prefix leo_description.
How do I launch the same robot stack on multiple Leo Rovers?
Use the namespace argument shown in the example, then launch each rover with a unique value: namespace:=rover1, namespace:=rover2. Combine with PushRosNamespace inside a GroupAction so topics and services are scoped per robot. TF frame IDs are message data, not automatically renamed by namespaces, so for true multi-robot operation you also need robot-specific frame names or frame prefixes in the URDF, EKF, and Nav2 parameters.
Should I put my launch files in a dedicated bringup package?
Yes. A common pattern is <robot>_bringup containing only launch files and YAML configs, separate from <robot>_description (URDF, meshes) and driver packages. This keeps integration logic isolated from reusable components.
Does the launch system work the same on ROS 2 Jazzy?
The core API is stable. Minor changes occur, but Python launch files written for Humble usually run on Jazzy with no edits. Always check the release notes before migrating production field robots.
Where to Go Next
The patterns above form the foundation of every Leo Rover bringup. To see official reference documentation and adapt the launch files to your sensor payload, consult docs.leorover.tech.