Lecture#

Prerequisites#

One-time workspace and environment setup required before running any code in this lecture.

One-Time Setup

Clone the course workspace and configure your shell so ROS 2 can find all packages automatically.

Clone the course workspace

git clone https://github.com/zeidk/enpm605-spring-2026-ros.git ~/enpm605_ws

Add the setup script to your shell rc file

# Bash users
echo "source ~/enpm605_ws/enpm605.sh" >> ~/.bashrc

# Zsh users
echo "source ~/enpm605_ws/enpm605.sh" >> ~/.zshrc

Reload your shell

source ~/.bashrc   # bash users
source ~/.zshrc    # zsh users

Run the setup function once per terminal

enpm605

Note

The enpm605 function must be run once in every new terminal before using ros2 commands. It sources the ROS 2 base installation and the course workspace in the correct order.

VSCode Extension

Install the Robot Developer Extensions for ROS 2 extension for syntax highlighting, launch file support, and integrated ROS 2 tooling inside VS Code:

code --install-extension ranch-hand-robotics.rde-ros-2

What Is ROS?#

An open-source middleware framework for building, deploying, and connecting robotic software components.

Overview

The Robot Operating System (ROS) is an open-source middleware framework for developing, building, and deploying robotic applications. Prototypes originated from Stanford AI research and were officially released by Willow Garage in 2007. ROS 2 was redesigned from the ground up in 2017 and is currently maintained by Open Robotics.

ROS equation diagram

Fig. 31 ROS equation diagram#

ROS equation diagram
Where Is ROS Used?

ROS is deployed across a wide spectrum of robotic applications.

Transportation

Manufacturing

Specialized Domains

Emerging Areas

Resource: ROS Robotics Companies

ROS 1 Limitations and ROS 2 Improvements
Table 15 ROS 1 vs. ROS 2#

ROS 1 Limitations

ROS 2 Improvements

Limited support for real-time computing

Improved real-time capabilities

Weak networked communication robustness

Better network management via DDS middleware

Insufficient security features

Enhanced security: encryption and authentication

Scalability issues as node count grows

Cross-platform support (Linux, macOS, Windows)

(none)

Quality of Service (QoS) per topic

ROS 2 Architecture#

ROS 2 runs many specialized, independent processes, each doing exactly one thing.

Processes

Definition: Process

A process is a program in execution: a passive program on disk becomes an active process the moment the OS loads it into memory and begins executing it. Each process is assigned its own isolated resources by the operating system:

  • Memory space: code segment, heap, and call stack – private and inaccessible to other processes.

  • Process ID (PID): a unique integer identifier assigned by the OS.

  • CPU time: the OS scheduler allocates time slices across all running processes.

  • File descriptors: open files, sockets, and device handles owned by that process.

Note

Processes communicate only through explicit OS-provided mechanisms (pipes, sockets, shared memory). One process cannot directly read or write another process’s memory. This isolation is what enables fault containment.

Monolithic vs. Distributed Design

Core Questions

  • What goes wrong when all robot software lives in one program?

  • How does a distributed design fix it?

Traditional: Monolithic

  • One large program handles everything.

  • Tight coupling: change one thing, risk breaking everything.

  • Single point of failure.

  • Difficult for teams to work in parallel.

Key implication

Change sensor? Recompile and rerun everything. Add a feature? Risk breaking existing code.

Monolithic design diagram

Fig. 33 Monolithic Design#

Monolithic design diagram

ROS 2: Distributed

  • Each component is a separate process (node).

  • Nodes communicate via message passing over named topics.

  • Fault isolation: one crash does not kill the system.

  • Teams develop nodes in parallel.

Key implication

Modular, scalable, robust, and collaborative: the four pillars of distributed robot software.

Distributed design diagram

Fig. 35 Distributed Design#

Distributed design diagram
Core Components

Communication Primitives

  • Nodes: individual processes performing specific tasks.

  • Topics: named channels for asynchronous data streaming.

  • Services: synchronous request-response communication.

  • Actions: long-running interruptible tasks with feedback.

When to Use Each

  • Topics: continuous data streams (sensors, robot state).

  • Services: one-shot requests (get pose, save map).

  • Actions: long tasks with progress feedback (navigate to goal).

Task Example: Pick Up a Part

Topic, action, and service interaction diagram

Fig. 37 Topic, Action, and Service.#

Topic, action, and service interaction diagram
  • camera_driver (process 1): continuously streams the detected pose of a part on the conveyor belt by publishing to a topic (e.g., /part_pose).

  • manager (process 2): subscribes to /part_pose, computes the target pick-up pose, then sends a goal to the robot arm via an action (long-running: move arm to pose, with feedback on progress), then calls a service once the arm is in position (short, synchronous: trigger the gripper to pick up the part).

  • robot_driver (process 3): receives the action goal, moves the arm to the target pose, and exposes the pick-up service.

Data Distribution Service (DDS)

DDS is an open, data-centric publish-subscribe middleware standard managed by the Object Management Group (OMG). Specification work began in 2001; version 1.0 was published in 2004. The underlying wire protocol is RTPS (Real-Time Publish-Subscribe), which enables interoperability across vendor implementations.

Application Domains

  • Defense: radar, combat management, UAV telemetry.

  • Air traffic control: real-time flight-data distribution.

  • Autonomous vehicles: high-rate sensor fusion pipelines.

  • Industrial automation: SCADA and factory control systems.

  • Financial trading: low-latency market-data feeds.

  • IoT/Industrial Internet: large-scale sensor networks.

Key Properties

  • Decentralized: no broker or master node required.

  • Automatic discovery: participants announce themselves via multicast; no manual configuration.

  • Transport-independent: runs over UDP, TCP, or shared memory.

  • Language-independent: C, C++, Java, Python, Ada.

  • Fine-grained QoS: 22 configurable policies per topic.

QoS Overview

DDS exposes per-topic QoS policies that control how data is delivered. The four most relevant policies in ROS 2:

  • Reliability: RELIABLE (guaranteed delivery with retransmission) vs. BEST_EFFORT (lossy, lower latency).

  • Durability: TRANSIENT_LOCAL (late-joiners receive last cached value) vs. VOLATILE (no caching).

  • History: KEEP_LAST (buffer last N messages) vs. KEEP_ALL (unbounded retention).

  • Deadline: maximum allowed gap between consecutive messages.

Supported Vendors (Jazzy)

  • Fast DDS (eProsima) – default; Apache 2.0 license.

  • Cyclone DDS (Eclipse) – lightweight, real-time focus; Eclipse license.

  • Connext DDS (RTI) – commercial; safety-certified variants available.

  • GurumDDS (GurumNetworks) – commercial.

Inspect and Switch at Runtime

# Check active RMW implementation
ros2 doctor --report | grep rmw

# Switch vendor (current shell only)
export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp

Publish/Subscribe Model#

Nodes communicate without knowing each other exist; only the topic name and message type must match.

Nodes, Topics, and Messages

Definition: Node

A Node is a small program written in Python or C++ that executes a relatively simple task. Nodes can send data (publisher) and receive data (subscriber).

Definition: Topic

A Topic is a named bus over which nodes exchange messages with a fixed name (e.g., /scan) and a fixed message type.

Definition: Message

A Message is a packet of data: a simple data structure with typed fields. Publishers send messages; subscribers receive them.

Rules and Patterns

Rules

  • Rule 1: publisher and subscriber must use the exact same topic name.

  • Rule 2: publisher and subscriber must agree on the message type.

  • Rule 3: publishers send regardless of whether any subscriber is listening.

  • Rule 4: subscribers can exist with no active publisher.

Patterns

  • One-to-many: camera_node publishes /image_raw; multiple nodes subscribe.

    One-to-many pub/sub pattern

    Fig. 39 camera_node publishes /image_raw; multiple nodes subscribe.#

    One-to-many pub/sub pattern
  • Many-to-one: multiple sensor nodes publish to /diagnostics.

    Many-to-one pub/sub pattern

    Fig. 41 Multiple sensor nodes publish to /diagnostics.#

    Many-to-one pub/sub pattern
  • Multi-topic: a robot driver publishes to /lidar, /cmd_vel, /status.

    Multi-topic pub/sub pattern

    Fig. 43 A robot driver publishes to /lidar, /cmd_vel, /status.#

    Multi-topic pub/sub pattern
  • Bidirectional: a planning node subscribes to /map and publishes to /path.

    Bidirectional pub/sub pattern

    Fig. 45 A planning node subscribes to /map and publishes to /path.#

    Bidirectional pub/sub pattern
Introspection Tools

Use these commands to inspect a live ROS 2 system:

  • ros2 node list: all running nodes.

  • ros2 node info <node>: publishers, subscribers, and services of a node.

  • ros2 topic list: all active topics. Add -t for message types.

  • ros2 topic info <topic> -v: publisher and subscriber counts with QoS.

  • ros2 topic echo <topic>: print messages as they are published.

  • ros2 topic hz <topic>: publishing frequency.

  • ros2 interface show <msg_type>: fields and types of a message.

  • rqt_graph: visual computation graph of nodes and topics.

Running Nodes

Two ways to start nodes

  • ros2 run starts a single node from the command line.

  • ros2 launch starts multiple nodes from a single command.

ros2 run

ros2 run <package> <executable> finds the executable registered in <package> and launches it as a new OS process in the current terminal.

# Terminal 1
ros2 run demo_nodes_py talker

# Terminal 2
ros2 run demo_nodes_cpp listener

ros2 run starts exactly one node per invocation and blocks the terminal until you press Ctrl-C. You need one terminal per node.

Terminal 1 – talker output:

[INFO] [1741200001.123456789] [talker]: Publishing: 'Hello World: 1'
[INFO] [1741200002.123456789] [talker]: Publishing: 'Hello World: 2'
[INFO] [1741200003.123456789] [talker]: Publishing: 'Hello World: 3'

Each log line contains: severity level ([INFO]), timestamp in seconds since epoch, node name ([talker]), and the user-defined message. The talker publishes one std_msgs/msg/String message per second on the topic /chatter by default.

Terminal 2 – listener output:

[INFO] [1741200001.145678901] [listener]: I heard: [Hello World: 1]
[INFO] [1741200002.145678901] [listener]: I heard: [Hello World: 2]
[INFO] [1741200003.145678901] [listener]: I heard: [Hello World: 3]

The timestamp is slightly later than the talker’s – this is the network and middleware delivery latency. Always check that the counter in the listener matches the talker. A gap indicates dropped messages, a QoS mismatch, or a network issue.

ros2 launch

ros2 launch <package> <launch_file> starts an entire set of nodes defined in a launch file as a single command, replacing the need for multiple terminals.

ros2 launch demo_nodes_py talker_listener.launch.py

All node output appears in the same terminal, prefixed by node name. A single Ctrl-C stops the entire system.

Expected output:

[INFO] [talker-1]: Publishing: 'Hello World: 1'
[INFO] [listener-1]: I heard: [Hello World: 1]
[INFO] [talker-1]: Publishing: 'Hello World: 2'
[INFO] [listener-1]: I heard: [Hello World: 2]

The -1 suffix is appended by the launch system to give each process a unique identifier in the log output.

Table 16 ros2 run vs. ros2 launch#

Criterion

ros2 run

ros2 launch

Nodes started

One

Many

Terminals needed

One per node

One for everything

Output

Single node only

All nodes, prefixed by name

Typical use

Development, debugging

Integration, demos

Stop all nodes

Ctrl-C in each terminal

Single Ctrl-C

Note

Use ros2 run when you want to focus on a single node. Use ros2 launch when you need the full system running together.

ROS 2 Setup#

Creating and building a workspace and Python package.

Workspace

A ROS 2 workspace is a directory containing all packages, dependencies, and build artifacts for a project.

Directory Structure

  • src/: source code for all your packages.

  • build/: intermediate build artifacts.

  • install/: final install tree sourced at runtime.

  • log/: build and test logs.

Make enpm605_ws a ROS Workspace

mkdir ~/enpm605_ws/src
cd ~/enpm605_ws
colcon build
source install/setup.bash
  • mkdir ~/enpm605_ws/src: creates the src directory.

  • colcon build: scans src for packages and builds them, producing build, log, and install.

  • source install/setup.bash: adds the workspace to the current shell’s environment so ros2 run and ros2 launch can find your packages.

Warning

Always run colcon build from the workspace root (enpm605_ws), never from inside src or a package directory.

Note

Workspace Overlays

  • Always source the base ROS 2 installation first, then your workspace. Later sources override earlier ones.

  • Never source two different ROS 2 distributions in the same session.

colcon

colcon (collective construction) is the official build tool for ROS 2, replacing the ROS 1 build tools.

Key Features

  • Language agnostic: builds C++, Python, and other package types.

  • Parallel execution: builds multiple packages simultaneously.

  • Isolated builds: each package built in its own space.

  • Cross-platform: works on Linux, Windows, and macOS.

Installation and Verification

sudo apt install python3-colcon-common-extensions
colcon version-check

Building

  • colcon build: build all packages in the workspace.

  • colcon build --symlink-install: symlink Python and config files; edits take effect without rebuilding.

  • colcon build --packages-select <pkg>: build only one package.

  • colcon build --packages-up-to <pkg>: build a package and all its dependencies.

Inspecting

  • colcon list: list all packages found in src.

  • colcon graph: display the package dependency graph.

Note

--symlink-install creates symbolic links instead of copying files into install. After editing a Python script you do not need to rebuild; changes take effect immediately on the next ros2 run.

Creating a Python Package
cd ~/enpm605_ws/src
ros2 pkg create first_pkg --build-type ament_python --dependencies rclpy
cd ..
colcon build --symlink-install
source install/setup.bash
ros2 pkg list | grep first_pkg

This generates a ready-to-build package with the correct ament_python structure. package.xml is pre-filled with a <depend>rclpy</depend> entry and setup.py has the entry_points section ready for you to register node executables.

Package Layout

first_pkg/
├── first_pkg/
│   └── __init__.py
├── resource/
├── test/
├── package.xml
├── setup.py
└── setup.cfg
  • first_pkg/: importable Python modules go here. Every new .py node file lives in this folder.

  • resource/: marker file required by the ROS 2 package index. Do not delete it.

  • test/: unit test files.

  • package.xml: the package manifest – name, version, license, maintainer, and dependencies.

  • setup.py: registers node executables so ros2 run can find them.

  • setup.cfg: required by the ament build system to locate executables inside install.

package.xml

package.xml is the package’s birth certificate (also called the manifest). It defines metadata, dependencies, and build information that ROS 2 tooling reads at build time, install time, and runtime.

  • Dependency resolution: ament reads it to determine the correct build order across all packages in the workspace.

  • Automated installation: rosdep reads it to download and install any missing system dependencies.

  • Package index: metadata published to index.ros.org for distribution.

Fields to edit immediately after creating a package

  • <description>: one-sentence description of what the package does.

  • <maintainer>: your name and email address.

  • <license>: legal terms (Apache-2.0, MIT, BSD-3-Clause, etc.)

  • <version>: semantic version, e.g. 1.0.0.

Dependency tags

  • <depend>pkg</depend>: needed at both build and runtime (covers most cases).

  • <build_depend>pkg</build_depend>: needed only at build time.

  • <exec_depend>pkg</exec_depend>: needed only at runtime.

Install all missing dependencies in one command

cd ~/enpm605_ws
rosdep install --from-paths ./src --ignore-packages-from-source -y

Note

You can inspect any package’s manifest with ros2 pkg xml <package_name>.

setup.py

setup.py is the build script for a Python ROS 2 package. It tells colcon how to install your nodes, launch files, and resource files into the workspace install tree.

  • Entry points: registers executables so ros2 run can find your nodes by name.

  • Data files: declares launch files, config files, and other resources to be copied into install.

  • Package metadata: name and version must match package.xml exactly.

Warning

setup.py and package.xml must always agree on package name and version – a mismatch causes a build error.

Fields to edit after creating a package

  • name: must match the package name in package.xml.

  • version: semantic version, e.g. '1.0.0'.

  • maintainer_email: your email address.

  • description: one-sentence description of the package.

  • license: legal terms (Apache-2.0, MIT, BSD-3-Clause, etc.)

Registering nodes as entry points

Each entry point maps a command name to a Python function: 'talker = my_pkg.talker:main'. The command name is what you pass to ros2 run <pkg> <n>. Multiple nodes are registered as additional entries in the console_scripts list.

Declaring data files

  • ('share/<pkg>/launch', glob('launch/*.launch.py')): installs all launch files into the share directory.

  • ('share/<pkg>/config', glob('config/*.yaml')): installs config files alongside the package.

Note

After adding a new entry point or data file declaration, you must run colcon build again – these are not picked up by --symlink-install.

Writing Nodes#

A node without spinning exits immediately. A node without callbacks has nothing to respond to.

Interfaces

A ROS 2 interface is a typed data contract shared between nodes. Interfaces are defined in .msg, .srv, and .action files and compiled into language-specific code at build time.

Three kinds of interfaces

  • Messages (.msg): one-way data sent over a topic.

  • Services (.srv): request/response pairs – one node calls, another replies.

  • Actions (.action): long-running tasks with goal, feedback, and result.

Publishers and subscribers use messages. Services and actions are covered in a later lecture.

Standard Message Packages

Standard message packages ship precompiled with ROS 2. After sourcing ROS 2 you can import them directly:

  • std_msgs: primitive types – Bool, String, Int8/16/32/64, UInt8/16/32/64, Float32, Float64, Header.

  • geometry_msgs: spatial data – Point, Pose, Twist, Transform, Vector3.

  • sensor_msgs: sensor data – Image, LaserScan, PointCloud2, Imu, NavSatFix.

  • nav_msgs: navigation – Odometry, Path, OccupancyGrid.

Note

These packages are installed under /opt/ros/jazzy/ via sudo apt install ros-jazzy-std-msgs etc. You never build them yourself unless you define custom message types.

From Text Files to Language Code

Each message is defined in a plain-text .msg file that declares field names and types – one per line.

# geometry_msgs/msg/Point.msg
float64 x
float64 y
float64 z

The precompiled Python import:

from geometry_msgs.msg import Point

The .msg definition is language-agnostic – the same file generates Python, C++, and other bindings. ros2 interface show geometry_msgs/msg/Point prints the original .msg source.

Primitive Types vs. std_msgs

In a .msg file, float64, int32, bool, and so on are IDL primitive types – the raw building blocks of the interface definition language, not ROS message types.

  • float64 x in a .msg file means the field x holds a plain 64-bit floating point number – it maps to float in Python and double in C++.

  • std_msgs/Float64 is a wrapper message: a full ROS 2 message whose only field is float64 data. It exists so you can publish a single number on a topic.

  • Use primitive types (float64, int32, etc.) inside your own .msg field definitions.

  • Use std_msgs wrappers only when publishing a bare scalar on a topic and no richer message type exists.

Introspecting Interfaces

ros2 interface list -m                         # all message types
ros2 interface show geometry_msgs/msg/Pose     # fields of a message
ros2 interface package geometry_msgs           # all interfaces in a package

Creating and Populating a Message Object

from geometry_msgs.msg import Pose, Point, Quaternion

def main(args=None):
    pose = Pose()

    pose.position = Point()
    pose.position.x = 1.0
    pose.position.y = 2.5
    pose.position.z = 0.0

    pose.orientation = Quaternion()
    pose.orientation.x = 0.0
    pose.orientation.y = 0.0
    pose.orientation.z = 0.0
    pose.orientation.w = 1.0  # identity rotation

    print(pose)

Pose is a composite message: it contains two nested message fields. Nested types must be imported and instantiated separately. orientation.w = 1.0 is the identity quaternion (no rotation).

Minimal Node

Write a minimal procedural node that logs a message and exits.

File: first_pkg/minimal_node.py

import rclpy

def main(args=None):
    rclpy.init(args=args)
    node = rclpy.create_node("minimal_node")
    node.get_logger().info("Hello from ROS 2")
    rclpy.shutdown()

if __name__ == "__main__":
    main()
  • rclpy.init(): initializes ROS 2 runtime. Must be called first.

  • rclpy.create_node(): shorthand for a simple node without subclassing.

  • node.get_logger().info(): logs an info-level message with timestamp. In ROS 2, never use print() – the logger routes messages to the terminal, log files, and ros2 topic echo /rosout simultaneously. See ROS 2 Logging.

  • rclpy.shutdown(): cleanly destroys all nodes and releases resources.

Register in setup.py

entry_points={
    'console_scripts': [
        'minimal_node = first_pkg.minimal_node:main',
    ],
},

Build and Run

cd ~/enpm605_ws
colcon build --packages-select first_pkg --symlink-install
source install/setup.bash
ros2 run first_pkg minimal_node

Expected output:

[INFO] [<timestamp>] [minimal_node]: Hello from ROS 2

Note

minimal_node exits immediately after rclpy.shutdown() – it does not spin. Run ros2 node list right after launching it and you will see nothing, because the process has already terminated. This is expected behavior for a procedural node with no spin loop.

OOP Node Design

Real-world ROS 2 code almost always uses class-based nodes. Publishers, subscribers, timers, and state are cleanly encapsulated in one class. Inheriting from Node gives the full ROS 2 API via self.

File layout

  • first_pkg/advanced_node.py: the node class only – no main()

  • scripts/run_advanced_node.py: the entry point – instantiates the node.

first_pkg/advanced_node.py:

import rclpy
from rclpy.node import Node

class AdvancedNode(Node):
    def __init__(self, node_name: str):
        super().__init__(node_name)
        self.get_logger().info(
            f"Hello from {self.get_name()}")

The class inherits from Node and calls super().__init__(node_name) to register with the ROS 2 runtime. No main() function – the class only defines behavior. self.get_name() returns the node name passed at instantiation time.

scripts/run_advanced_node.py:

import rclpy
from first_pkg.advanced_node import AdvancedNode

def main(args=None):
    rclpy.init(args=args)
    node = AdvancedNode("advanced_node")
    rclpy.shutdown()

if __name__ == "__main__":
    main()

Register in setup.py:

'advanced_node = scripts.run_advanced_node:main',
Spinning

Spinning keeps a node alive and responsive. rclpy.spin(node) blocks until the node is shut down.

Without spinning, the node exits immediately and no callbacks ever run.

Threads

Definition: Thread

A thread is the smallest unit of execution inside a process. A process can have multiple threads running concurrently, sharing the same memory.

Browser process with multiple threads

Fig. 47 Example: A browser process with multiple threads.#

Browser process with multiple threads

The Main Thread

Every Python program starts with one thread (the main thread). When you call rclpy.spin(node), that main thread is handed over to the ROS 2 executor, which runs it in a loop checking for work to do.

  • rclpy.spin() blocks the main thread – no code after it runs until the node is shut down (e.g., Ctrl-C).

  • The ROS executor uses this thread to fire callbacks, service handlers, and timer functions one at a time.

Main thread blocked by spin loop

Fig. 49 Main thread blocked by spin loop, executor dispatching callbacks.#

Main thread blocked by spin loop

Why Must You Spin?

Spinning activates the ROS 2 executor. Without it, the node is registered but completely passive – nothing ever runs.

  • Incoming messages: subscriber callbacks are only invoked while spinning.

  • Timer callbacks: create_timer() registers a timer, but the timer only fires while the executor is running.

  • Service requests: a service server only processes requests while spinning.

  • Action servers and clients: goal handling, feedback, and result delivery all require an active executor.

  • Parameter updates: parameter change callbacks are also dispatched by the executor.

rclpy.spin(node) is the event loop of a ROS 2 node. Without it, the node is a program that starts, does nothing, and exits.

Update scripts/run_advanced_node.py to spin

import rclpy
from first_pkg.advanced_node import AdvancedNode

def main(args=None):
    rclpy.init(args=args)
    node = AdvancedNode("advanced_node")
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        node.get_logger().info("Shutting down.")
    finally:
        node.destroy_node()
        rclpy.shutdown()

if __name__ == "__main__":
    main()
  • rclpy.spin(node): blocks the main thread, processing callbacks as they arrive.

  • except KeyboardInterrupt: catches Ctrl-C from the terminal and logs a clean shutdown message. Without this, the traceback would be printed to the terminal.

  • finally: runs regardless of how the try block exits. This guarantees cleanup always happens.

  • node.destroy_node(): releases all ROS 2 resources held by the node (publishers, subscribers, timers).

  • rclpy.shutdown(): shuts down the ROS 2 runtime. Always the last call.

Spinning Alternatives

# Blocks forever -- standard choice for most nodes
rclpy.spin(node)

# Processes one batch of callbacks then returns
rclpy.spin_once(node)

# Blocks until a Future completes -- used for async service calls
rclpy.spin_until_future_complete(node, future)

# Manual spin loop using spin_once
while rclpy.ok():
    rclpy.spin_once(node, timeout_sec=0.1)
    # do other work here

Note

Use rclpy.spin(node) for all standard nodes. Only use spin_once() if you have a specific reason to interleave ROS 2 processing with non-ROS work. The spin loop always lives in the entry point script, not in the node class – this keeps the class reusable by any executor or script without modification.

Timers and Callbacks

A timer schedules a callback at a fixed interval. Callbacks only run while the node is spinning.

class AdvancedNode(Node):
    def __init__(self, node_name: str):
        super().__init__(node_name)
        self._counter = 0
        self._timer = self.create_timer(1.0, self._timer_callback)

    def _timer_callback(self):
        self.get_logger().info(f"Count: {self._counter}")
        self._counter += 1
  • self._counter = 0: instance variable holding state across callback invocations.

  • self.create_timer(1.0, self._timer_callback): registers a timer that fires every 1.0 second and calls _timer_callback.

  • self._timer_callback(): called by the ROS 2 executor on each tick – only runs while the node is spinning.

  • self.get_logger().info(): logs the current counter value with a timestamp; never use print() in ROS 2 nodes.

  • self._counter += 1: state is preserved between calls because self persists for the lifetime of the node.

Publishers

A publisher sends messages regardless of whether anyone is listening. The topic name and message type are the only contract.

File: first_pkg/publisher_demo.py

Workflow

  1. Create a publisher object: specify message type, topic name, and QoS queue depth.

  2. Create a message instance and populate its fields.

  3. Publish inside a timer callback at a fixed rate.

Create a Publisher Object

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

class PublisherDemo(Node):
    def __init__(self, node_name: str):
        super().__init__(node_name)
        self._publisher = self.create_publisher(
            Int64,      # message type
            "counter",  # topic name
            10          # QoS queue depth
        )
  • create_publisher() takes three arguments: message type, topic name, and queue depth – all three are required.

  • The topic name string: by convention use snake_case; a leading "/" is added automatically by ROS 2.

  • Queue depth 10: up to 10 undelivered messages are buffered; older ones are dropped when the queue is full.

  • Store the result in self._publisher so it is not garbage collected when __init__ returns.

Create a Message Instance

from std_msgs.msg import Int64

# Option A: create once in __init__, reuse in every callback (preferred)
self._message = Int64()

# Option B: create a new instance on every publish
def _timer_callback(self):
    msg = Int64()
    msg.data = self._counter
    self._publisher.publish(msg)

# Option A usage in callback:
def _timer_callback(self):
    self._message.data = self._counter
    self._publisher.publish(self._message)

Option A (preferred): create the object once in __init__ and reuse it – avoids repeated memory allocation on every timer tick.

Publish Inside a Timer Callback

class PublisherDemo(Node):
    def __init__(self, node_name: str):
        super().__init__(node_name)
        self._counter = 0
        self._message = Int64()
        self._publisher = self.create_publisher(Int64, "counter", 10)
        self._timer = self.create_timer(2.0, self._timer_callback)

    def _timer_callback(self):
        self._message.data = self._counter
        self._publisher.publish(self._message)
        self.get_logger().info(f"Publishing: {self._counter}")
        self._counter += 1

The callback runs in the executor thread – never block it with time.sleep() or heavy computation. Always publish inside a callback rather than a while loop.

Run and Inspect

# Terminal 1
colcon build --symlink-install --packages-select first_pkg
source install/setup.bash
ros2 run first_pkg publisher_demo

# Terminal 2
ros2 node list
ros2 node info /publisher_demo
ros2 topic list -t
ros2 topic echo /counter
ros2 topic hz /counter
rqt_graph
Quality of Service (QoS)

QoS is the set of policies that govern how messages are delivered between publishers and subscribers. It is the contract between the two parties on reliability, history, and durability.

Warning

Publisher and subscriber QoS policies must be compatible or DDS silently refuses the connection – no error, no data.

The Four Core QoS Policies

  • Reliability: RELIABLE retransmits dropped messages and guarantees delivery. BEST_EFFORT skips retransmission for lower latency. Use RELIABLE for commands and critical data; BEST_EFFORT for high-frequency sensor streams.

  • Durability: TRANSIENT_LOCAL caches the last message so late-joining subscribers receive it immediately. VOLATILE sends nothing to late joiners. Use TRANSIENT_LOCAL for topics published once but needed by nodes that start later (e.g. robot description, map).

  • History: KEEP_LAST buffers the last N messages (depth). KEEP_ALL retains every message at the cost of unbounded memory.

  • Deadline: maximum allowed gap between consecutive messages. If no message arrives within the interval, the middleware triggers a callback – useful for detecting sensor failures.

Default vs. Explicit QoS in Python

from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy, HistoryPolicy

# Passing an integer sets queue depth with all-default policies
self._publisher = self.create_publisher(Int64, "counter", 10)

# Equivalent explicit profile
qos = QoSProfile(
    depth=10,
    reliability=ReliabilityPolicy.RELIABLE,
    durability=DurabilityPolicy.VOLATILE,
    history=HistoryPolicy.KEEP_LAST,
)
self._publisher = self.create_publisher(Int64, "counter", qos)

Predefined QoS Profiles

from rclpy.qos import qos_profile_sensor_data
from rclpy.qos import qos_profile_system_default

# Sensor data: BEST_EFFORT, VOLATILE, depth 5
self._publisher = self.create_publisher(LaserScan, "scan", qos_profile_sensor_data)

# System default: RELIABLE, VOLATILE, depth 10
self._publisher = self.create_publisher(Int64, "counter", qos_profile_system_default)
  • qos_profile_sensor_data: best effort, volatile, depth 5. Designed for high-frequency sensor streams where losing an occasional message is acceptable.

  • qos_profile_system_default: reliable, volatile, depth 10. The implicit default when you pass an integer.

  • qos_profile_services_default: reliable, volatile. Used internally by services and actions.

Note

Prefer predefined profiles over manual QoSProfile construction when your use case matches one of them.

QoS Compatibility Rules

A publisher and subscriber only connect if their policies are compatible. Incompatible QoS causes a silent failure – no error, no warning, no data.

  • Reliability: a RELIABLE subscriber will not connect to a BEST_EFFORT publisher. The reverse works.

  • Durability: a TRANSIENT_LOCAL subscriber will not connect to a VOLATILE publisher. The reverse works.

  • Deadline: subscriber deadline must be greater than or equal to publisher deadline.

Diagnosing QoS Mismatches

ros2 topic info /counter -v
ros2 doctor --report | grep qos

Warning

A subscriber that receives nothing is often a QoS mismatch, not a bug in your code. Always check QoS compatibility before debugging elsewhere.

Subscribers

A subscriber is unaware of which publisher sends the messages. Its sole objective is to receive and process them.

File: first_pkg/subscriber_demo.py

Workflow

  1. Create a subscription object: specify message type, topic name, callback, and QoS queue depth.

  2. Define a callback function to process each incoming message.

  3. Spin the node – the ROS 2 executor delivers messages to the callback as they arrive.

Create a Subscription Object

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

class SubscriberDemo(Node):
    def __init__(self, node_name: str):
        super().__init__(node_name)
        self._subscriber = self.create_subscription(
            Int64,                        # message type
            "counter",                    # topic name
            self._subscriber_callback,    # callback
            10                            # QoS queue depth
        )
        self.get_logger().info("Subscriber initialized.")
  • create_subscription() takes four arguments: message type, topic name, callback, and queue depth – all four are required.

  • Topic name and message type must match the publisher exactly – a mismatch causes a silent failure with no data received.

  • The callback is passed by reference as self._subscriber_callback, not called – no parentheses.

  • Store the result in self._subscriber so it is not garbage collected when __init__ returns.

Define the Callback

from std_msgs.msg import Int64

# Named callback (preferred)
def _subscriber_callback(self, msg: Int64):
    self.get_logger().info(f"Received: {msg.data}")

# Equivalent lambda (for simple single-expression callbacks only)
self._subscriber = self.create_subscription(
    Int64,
    "counter",
    lambda msg: self.get_logger().info(f"Received: {msg.data}"),
    10,
)
  • The callback receives a single argument: the incoming message object. Type-hint it (msg: Int64) for IDE autocompletion on fields.

  • Named callback (preferred): easier to read, test, and extend. Use whenever the body is more than one expression.

  • Lambda: acceptable for trivial one-liners but becomes unreadable quickly.

  • Keep callbacks fast – a slow callback blocks the executor and causes queue buildup on the subscriber side.

Complete Subscriber Node

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

class SubscriberDemo(Node):
    def __init__(self, node_name: str):
        super().__init__(node_name)
        self._subscriber = self.create_subscription(
            Int64, "counter",
            self._subscriber_callback, 10
        )
        self.get_logger().info("Subscriber initialized.")

    def _subscriber_callback(self, msg: Int64):
        self.get_logger().info(f"Received: {msg.data}")

def main(args=None):
    rclpy.init(args=args)
    node = SubscriberDemo("subscriber_demo")
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()

if __name__ == "__main__":
    main()

Note

If the callback is never invoked, check topic name, message type, and QoS compatibility before assuming the code is broken.

Communication Scenarios#

What actually happens to messages when publishers and subscribers run at different speeds?

Setup for all scenarios

  • Publisher rate: 2 Hz (one message every 0.5 s).

  • QoS queue depth: 3 on both publisher and subscriber.

  • Policy: KEEP_LAST – oldest message dropped when queue is full.

Scenario 1 – No Subscriber

Setup: publisher at 2 Hz, no subscriber connected, QoS depth 3.

Table 17 Publisher at 2 Hz, no subscriber connected, QoS depth 3#

Time

Publisher action

Result

t = 0.0 s

Publishes msg1

No matching subscriber – discarded by DDS

t = 0.5 s

Publishes msg2

No matching subscriber – discarded by DDS

t = 1.0 s

Publishes msg3

No matching subscriber – discarded by DDS

t = 1.5 s

Publishes msg4

No matching subscriber – discarded by DDS

  • DDS does not buffer messages for a subscriber that does not exist. Messages are discarded immediately.

  • The publisher continues running normally with no performance impact.

  • Messages are lost forever unless TRANSIENT_LOCAL durability is configured – in which case the last message is cached and delivered to any subscriber that joins later.

Note

This is the expected behavior for VOLATILE durability (the default). If you need late-joining subscribers to receive the last value, switch to TRANSIENT_LOCAL on both publisher and subscriber.

Scenario 2 – Fast Subscriber

Setup: publisher at 2 Hz, callback duration 0.1 s, QoS depth 3 on both sides.

Table 18 Publisher at 2 Hz, callback duration 0.1 s, QoS depth 3#

Time

Publisher

Queue

Callback

Notes

t = 0.0 s

Publishes msg1

[msg1]

Starts on msg1

Queue: 1

t = 0.1 s

[]

Completes

Queue: 0, ready

t = 0.5 s

Publishes msg2

[msg2]

Starts on msg2

Queue: 1

t = 0.6 s

[]

Completes

Queue: 0, ready

t = 1.0 s

Publishes msg3

[msg3]

Starts on msg3

Queue: 1

t = 1.1 s

[]

Completes

Queue: 0, ready

  • The queue never builds up – it holds at most one message at a time.

  • Latency between publish and processing is minimal.

  • The system is healthy – no messages are dropped.

  • This is the ideal scenario for real-time sensor processing.

Note

Design your callbacks to be faster than the publish rate. If the callback cannot keep up, offload heavy computation to a separate thread.

Scenario 3 – Slow Subscriber

Setup: publisher at 2 Hz, callback duration 1.7 s, QoS depth 3 on both sides.

Table 19 Publisher at 2 Hz, callback duration 1.7 s, QoS depth 3#

Time

Publisher

Queue

Callback

Notes

t = 0.0 s

Publishes msg1

[msg1]

Starts on msg1

Queue: 1

t = 0.5 s

Publishes msg2

[msg2]

Still on msg1

Queue: 1

t = 1.0 s

Publishes msg3

[msg2, msg3]

Still on msg1

Queue: 2

t = 1.5 s

Publishes msg4

[msg2, msg3, msg4]

Still on msg1

Queue full (depth 3)

t = 1.7 s

[msg3, msg4]

Starts on msg2

msg2 dequeued

t = 2.0 s

Publishes msg5

[msg3, msg4, msg5]

Still on msg2

Queue full again

t = 2.5 s

Publishes msg6

[msg4, msg5, msg6]

Still on msg2

msg3 dropped

t = 3.4 s

[msg4, msg5, msg6]

Starts on msg3

msg3 dequeued

t = 3.5 s

Publishes msg7

[msg5, msg6, msg7]

Still on msg3

msg4 dropped

  • Once the queue reaches depth 3, every new message evicts the oldest one – KEEP_LAST policy.

  • The subscriber always processes stale data – it is never caught up with real time.

  • Increasing queue depth delays the first drop but does not solve the underlying problem.

Summary of Scenarios
Table 20 Comparison of publisher-subscriber timing scenarios at 2 Hz, QoS depth 3#

Scenario

Callback time

Messages lost

Latency

No subscriber

All

N/A

Fast subscriber

0.1 s

None

Minimal

Slow subscriber

1.7 s

Yes (oldest)

Grows over time

Diagnostic Commands

ros2 topic hz /counter        # check publish rate
ros2 topic echo /counter      # watch messages in real time
ros2 topic info /counter -v   # check QoS on both sides

Note

If ros2 topic hz shows the expected rate but your node output is slower, your callback is the bottleneck – not the publisher. Offload heavy work to a separate thread or reduce the publish rate.