Docs Get started Workspace repo
Docs menu

ROS 2 in this workspace

Nodes, topics, parameters, launch files and sourcing, as this workspace uses them, with the commands for inspecting a running car.

ROS 2 is a framework for running many small programs that exchange messages. The car runs about a dozen of them at once. This page covers the parts of ROS 2 the rest of the docs rely on. Lecture 1 (notes), Tutorial 1 (notes) and Lab 1 of the course teach them from scratch, and the site’s hands-on ROS 2 primer builds a first package step by step.

Nodes and topics

A node is one running program with one job: read the LiDAR, choose a steering angle, or talk to the motor controller. Nodes never call each other’s functions. They exchange messages on named channels called topics. A node publishes on a topic without knowing who listens, and subscribes to a topic without knowing who sends.

Nodes connected by topics Data flows one way along the chain: urg_node publishes /scan, gap_follow turns each scan into a command on /drive, and ackermann_mux receives it. urg_node gap_follow ackermann_mux /scan /drive PUBLISHES /SCAN SUBSCRIBES, PUBLISHES SUBSCRIBES TO /DRIVE LIDAR DRIVER DECIDES THE COMMAND PICKS A COMMAND
How to read it: each dark box is a node, and each rounded label on an arrow is a topic. An arrow points from the node that publishes a topic to the node that subscribes to it. gap_follow does not know that urg_node exists: it only needs some node to publish laser scans on /scan.

Because nodes only share topics, one node can be replaced without touching the others. The simulator works this way: racerbot_sim publishes its own /scan and /odom, and the driving nodes above it run unchanged. Any topic can also be inspected from a terminal while the car runs.

Every message has a fixed type that both sides agree on. The workspace uses standard types:

Topic Message type Contents
/scan sensor_msgs/LaserScan One LiDAR sweep: ranges, angle_min, angle_increment
/drive, /teleop ackermann_msgs/AckermannDriveStamped A steering angle in radians and a speed in m/s
/odom nav_msgs/Odometry The car’s estimated pose and velocity
/joy sensor_msgs/Joy Gamepad buttons and axes

Parameters

A parameter is a named setting a node reads when it starts, such as gap_follow’s max_speed. Each package keeps its parameters in YAML files under config/, which the launch file loads, and the YAML’s values replace the defaults declared in the code. To change how a node behaves, edit its YAML, not its Python.

Some parameters can also change while the node runs, through ros2 param set or the dashboard. A change made that way lasts until the node restarts.

Launch files

A launch file is a Python script that starts a set of nodes with their parameters. ros2 launch <package> <file> runs it, and one Ctrl+C stops every node it started.

ros2 launch f1tenth_stack bringup_launch.py

A single node can also be started on its own with ros2 run <package> <executable>. The workspace uses this for tools such as the doctor.

Packages and colcon

A package is one unit of code that is built and installed together. Every package in src/ has a package.xml that names it and its dependencies. The workspace’s own Python packages share one layout:

src/<package>/
├── package.xml           name and dependencies
├── setup.py, setup.cfg   build settings and the executables it installs
├── resource/<package>    empty marker file
├── <package>/            the Python code: the node, plus logic that imports no ROS
├── launch/               launch files
├── config/               YAML parameters
└── test/                 tests

colcon build builds every package into install/. ROS 2 runs what is in install/, not what is in src/. See Set up the workspace for the build commands.

Anatomy of a node

gap_follow_node shows every piece a node in this workspace has. These excerpts are from gap_follow_node.pyGitHub, shortened.

A class that is a node. It subclasses rclpy.node.Node and gives the node its name:

class GapFollowNode(Node):
    def __init__(self):
        super().__init__('gap_follow_node')

Parameters, declared with a default, then read. The default in the code is a fallback. The YAML loaded by the launch file wins, so min_gap_distance is declared as 1.0 here but runs at 2.0, the value in gap_follow.yaml:

        self.declare_parameter('scan_topic', '/scan')
        self.declare_parameter('min_gap_distance', 1.0)
        ...
        self.scan_topic = self.get_parameter('scan_topic').value

Publishers and subscribers. Each names a message type, a topic, and for a subscriber the function to call when a message arrives. The last argument is the queue depth; see QoS and timing.

        self.drive_pub = self.create_publisher(AckermannDriveStamped, self.drive_topic, 10)
        self.scan_sub = self.create_subscription(
            LaserScan, self.scan_topic, self.scan_callback, 10)
        self.odom_sub = self.create_subscription(
            Odometry, self.odom_topic, self.odom_callback, 10)

Callbacks. A callback runs each time its topic delivers a message. A small one stores what it needs for later:

    def odom_callback(self, msg: Odometry):
        self.current_speed = float(msg.twist.twist.linear.x)
        self.last_odom_time = self.get_clock().now()

scan_callback is the large one: each scan runs the whole follow-the-gap pipeline and ends with self.drive_pub.publish(...).

main, the entry point. rclpy.spin waits for messages and calls the callbacks until the node is stopped:

def main(args=None):
    rclpy.init(args=args)
    node = GapFollowNode()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        if rclpy.ok():
            rclpy.shutdown()

How ROS finds it. setup.py registers main as an executable named gap_follow_node, which is what ros2 run gap_follow gap_follow_node and the launch file start:

entry_points={
    'console_scripts': [
        'gap_follow_node = gap_follow.gap_follow_node:main',
    ],
},
Node(
    package='gap_follow',
    executable='gap_follow_node',
    name='gap_follow_node',
    output='screen',
    parameters=[gap_follow_config, ...],
)

The parameters list is applied in order, so later entries override earlier ones: the launch file loads the YAML first, then any value given on the command line, such as max_speed:=1.0.

Sourcing

Every new terminal needs two commands before any ros2 command, in this order:

source /opt/ros/jazzy/setup.bash
source ~/racerbot-ws/install/setup.bash

The first makes ROS 2 itself available. The second adds the workspace’s built packages on top of it. A terminal that skipped either reports Package ... not found for a package that exists. Sourcing sets environment variables in the current shell only, so it does not carry over to a new terminal, SSH session or tmux pane.

ROS domains

ROS 2 nodes find each other automatically on the local network. Two cars, or a laptop and a car, on the same network can join one graph by accident, and then one car’s LB could drive the other. The ROS_DOMAIN_ID environment variable separates graphs: nodes only see nodes with the same ID.

Before running two cars together, check that each car sees only its own gamepad:

ros2 topic info /joy

Publisher count: 1 is correct. If it reads 2, give the cars different ROS_DOMAIN_ID values. The tests use the same mechanism to stay off the car’s graph. See Run the tests.

Inspect a running car

These commands only read. They are safe to run at any time, in any sourced terminal.

Command Shows
ros2 node list Every running node
ros2 topic list Every topic
ros2 topic hz /scan How often a topic publishes. /scan runs at about 40 Hz.
ros2 topic echo /drive --field drive The messages on a topic, here only the drive field
ros2 topic info /drive How many nodes publish and subscribe
ros2 param get /gap_follow_node max_speed One parameter’s current value
ros2 param list /gap_follow_node Every parameter of a node

Checked against car 1 commit 0265263 and car 2 commit b81b291, before the two workspaces merged.