gwordal

Lesson 2 of 5 · 28 min

Nodes, topics and your first workspace

Topics are the bloodstream of a ROS 2 system: almost all sensor data and most commands travel over them. In this lesson you will build a workspace, write a publisher and a subscriber in Python, inspect them from the command line, and learn the one setting (QoS) that explains most "my subscriber receives nothing" mysteries.

The workspace and colcon

ROS code lives in a workspace, a folder with a fixed layout. You edit only src; the build tool creates the rest.

source /opt/ros/jazzy/setup.bash
mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python --license Apache-2.0 first_steps --dependencies rclpy std_msgs

The build tool is colcon. It finds every package under src, works out the dependency order, and builds them into build, install and log. The reason for this extra layer is that a robot project usually has dozens of packages, some Python, some C++, that depend on each other.

ros2 pkg create made src/first_steps/ with a package.xml (name, version, dependencies), a setup.py (how Python installs the package) and an inner first_steps/ folder for your code. The --dependencies flag already wrote rclpy and std_msgs into package.xml.

A publisher

Create ~/ros2_ws/src/first_steps/first_steps/talker.py:

import rclpy
from rclpy.node import Node
from std_msgs.msg import String


class Talker(Node):
    def __init__(self):
        super().__init__('talker')
        # message type, topic name, queue depth
        self.pub = self.create_publisher(String, 'chatter', 10)
        self.count = 0
        # call self.tick every 0.5 s
        self.timer = self.create_timer(0.5, self.tick)

    def tick(self):
        msg = String()
        msg.data = f'hello {self.count}'
        self.pub.publish(msg)
        self.get_logger().info(f'published: {msg.data}')
        self.count += 1


def main(args=None):
    rclpy.init(args=args)
    node = Talker()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.try_shutdown()


if __name__ == '__main__':
    main()

The important call is rclpy.spin(node). ROS 2 is callback driven: you describe what should happen when a timer fires or a message arrives, and spin is the loop that waits for those events and runs your callbacks. There is no while True in your code, and that is what lets one process run many node behaviors cleanly.

A subscriber

Create listener.py next to it:

import rclpy
from rclpy.node import Node
from std_msgs.msg import String


class Listener(Node):
    def __init__(self):
        super().__init__('listener')
        self.sub = self.create_subscription(String, 'chatter', self.on_msg, 10)

    def on_msg(self, msg):
        self.get_logger().info(f'heard: {msg.data}')


def main(args=None):
    rclpy.init(args=args)
    node = Listener()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.try_shutdown()


if __name__ == '__main__':
    main()

Notice that the subscriber never mentions the talker. It only names the topic chatter and the type String. If either differs, nothing connects and no error is raised, which is the first thing to check when a link seems dead.

Register, build, run

Open setup.py and make the entry_points section expose your two scripts as commands:

    entry_points={
        'console_scripts': [
            'talker = first_steps.talker:main',
            'listener = first_steps.listener:main',
        ],
    },

Then build from the workspace root, not from src:

cd ~/ros2_ws
colcon build --symlink-install
source install/setup.bash
ros2 run first_steps talker

--symlink-install links your Python files instead of copying them, so edits take effect without rebuilding. You must source install/setup.bash in every new terminal (and again after adding a new package) so the shell learns where the package lives. In a second terminal, source both setup files and run ros2 run first_steps listener.

Inspecting a topic

With both nodes running, use a third terminal:

ros2 topic list -t
ros2 topic echo /chatter
ros2 topic hz /chatter
ros2 topic info /chatter -v

echo prints messages, hz measures the real publishing rate (you should see about 2 Hz), and info -v lists every publisher and subscriber with its QoS settings. When a system "feels slow", hz tells you whether the problem is the publisher's rate or something downstream. You can also publish by hand: ros2 topic pub -r 1 /chatter std_msgs/msg/String "data: manual".

QoS basics

The depth 10 in create_publisher was the simplest part of a bigger idea: Quality of Service. Because DDS runs over real networks, ROS 2 lets each endpoint state how much delivery guarantee it needs. The main policies:

PolicyOptionsMeaning
ReliabilityRELIABLE, BEST_EFFORTRetry lost messages, or drop them and move on
DurabilityVOLATILE, TRANSIENT_LOCALWhether late joiners receive the last stored message
History and depthKEEP_LAST nHow many messages to queue

A camera at 30 Hz should be best effort: a retransmitted old frame is worse than a missing one. A map that is published once should be transient local, so a node that starts later still gets it. Sensor drivers use a ready-made profile:

from rclpy.qos import qos_profile_sensor_data

self.sub = self.create_subscription(
    String, 'chatter', self.on_msg, qos_profile_sensor_data)

The rule that causes confusion: a subscriber can only connect if its request is compatible with the offer. A best-effort publisher with a reliable subscriber produces silence, with no error on the topic. Check it with ros2 topic info /chatter -v and match them, or from the command line: ros2 topic echo /chatter --qos-reliability best_effort.

Check yourself

You run colcon build and then open a new terminal, but ros2 run first_steps talker says package not found. What did you miss?

Check yourself

A publisher uses BEST_EFFORT reliability and your subscriber requests RELIABLE. What happens?