ROS 2 Learning Path  ยท  Illustrated Textbook

Chapter 3: Practical Communication with ARCHO

From concept to real code โ€” Publisher, Service, Action, and Launch
Prerequisite: Chapters 1 and 2
Running project: ARCHO robot
Language: Python (rclpy)
Reading time: 100 to 130 minutes
What we'll cover in this chapter 3.1From a Quiet Node to a Chatty Team 3.2A Real Publisher and Subscriber 3.3Building a Custom Message 3.4Service in Practice: Reset Encoder 3.5Action in Practice: Go to Shelf 3.6Parameters in Practice 3.7A Launch File That Starts Everything 3.8Summary, Glossary, and Exercises

3.1From a Quiet Node to a Chatty Team

In Chapter 2, simple_node just printed a single sentence and stayed quiet. That was fine for learning the basic structure, but a real robot like ARCHO doesn't work that way. Its battery needs to announce its status every few seconds, someone needs to be able to ask the motor to zero the encoders, and when the command "go to shelf 12" arrives, the robot actually needs to move and report progress. In this chapter, we turn exactly the same four communication tools from Chapter 1 โ€” Topic, Service, Action, and Parameter โ€” into real code for the first time.

flowchart RL A["Publisher/Subscriber
battery_monitor"] --> B["Custom Message
archo_interfaces"] B --> C["Service
reset_encoder"] C --> D["Action
move_to_shelf"] D --> E["Parameter YAML"] E --> F["Single Launch File
archo_bringup.launch.py"] style A fill:#eef0ff,stroke:#3d4bf5,color:#211f1a style F fill:#eafaf3,stroke:#0e9e6e,color:#211f1a,font-weight:bold
๐ŸŒ Today's Map for the ARCHO Team

Today we're building three new nodes: battery_monitor (publishes battery status), dashboard (a simple subscriber that displays the status), and motor_controller (a server that both offers a service and runs an action). By the end of the chapter, all of these will be started with a single command.

3.2A Real Publisher and Subscriber

First we'll build the simplest case: a node that publishes ARCHO's battery status every second. We'll start with the standard std_msgs/Float32 message so that in the next section we can see why this isn't enough in the real world.

# archo_bringup/battery_monitor.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import Float32
import random


class BatteryMonitor(Node):
    def __init__(self):
        super().__init__('battery_monitor')
        self.publisher_ = self.create_publisher(Float32, 'battery_level', 10)
        self.timer = self.create_timer(1.0, self.publish_battery)
        self.level = 100.0

    def publish_battery(self):
        self.level = max(0.0, self.level - random.uniform(0.05, 0.2))
        msg = Float32()
        msg.data = self.level
        self.publisher_.publish(msg)
        self.get_logger().info(f'Battery: {self.level:.1f}%')


def main(args=None):
    rclpy.init(args=args)
    rclpy.spin(BatteryMonitor())
    rclpy.shutdown()


if __name__ == '__main__':
    main()

Three things here are new compared to the previous chapter:

LineMeaning
create_publisher(Float32, 'battery_level', 10)Creates a publisher on a topic named battery_level with message type Float32; the number 10 is the queue depth of retained messages (QoS queue depth)
create_timer(1.0, self.publish_battery)Automatically calls the publish_battery function every 1 second
self.publisher_.publish(msg)Actually publishes the message onto the ROS 2 network

Now let's build the subscriber โ€” a simple dashboard that listens on the same topic:

# archo_bringup/dashboard.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import Float32


class Dashboard(Node):
    def __init__(self):
        super().__init__('dashboard')
        self.subscription = self.create_subscription(
            Float32, 'battery_level', self.battery_callback, 10)

    def battery_callback(self, msg):
        if msg.data < 20.0:
            self.get_logger().warning(f'Low battery: {msg.data:.1f}%')
        else:
            self.get_logger().info(f'Dashboard sees: {msg.data:.1f}%')


def main(args=None):
    rclpy.init(args=args)
    rclpy.spin(Dashboard())
    rclpy.shutdown()


if __name__ == '__main__':
    main()
๐Ÿง  Key Point: There Is No Direct Connection

Notice that battery_monitor.py and dashboard.py never know each other's names anywhere. Neither knows the other exists. The only thing that connects them is agreement on a shared name: battery_level. This is exactly the node independence we described in Chapter 1 โ€” you can close dashboard, reopen it, or run ten copies of it, without battery_monitor ever noticing.

dev@archo:~$ ros2 run archo_bringup battery_monitor [INFO] [battery_monitor]: Battery: 99.9% [INFO] [battery_monitor]: Battery: 99.7% dev@archo:~$ ros2 topic echo /battery_level data: 99.7 --- data: 99.5 --- dev@archo:~$ ros2 topic hz /battery_level average rate: 1.001
Easy Exercise

Run both nodes in two separate terminals, then use ros2 topic echo /battery_level in a third terminal to check that messages are actually being published. Then close dashboard and run it again โ€” does battery_monitor need to be run again? Why?

3.3Building a Custom Message

A raw number like Float32 isn't enough for the battery. The ARCHO team wants to send the battery percentage, voltage, and whether it's charging, all at once. This is where we build a custom message โ€” exactly as we became familiar with sensor_msgs/LaserScan in Chapter 1, but this time we design our own format.

๐Ÿ“– Why a Custom Message in a Separate Package?

A common convention in ROS 2 is to keep custom message, service, and action definitions in a separate package โ€” usually with an _interfaces suffix โ€” so that both the logic package (archo_bringup) and any other package that wants to use the same format can import it without an unnecessary dependency on executable code.

archo_ws/src/
โ”œโ”€โ”€ archo_interfaces/
โ”‚   โ”œโ”€โ”€ msg/
โ”‚   โ”‚   โ””โ”€โ”€ BatteryStatus.msg
โ”‚   โ”œโ”€โ”€ srv/
โ”‚   โ”‚   โ””โ”€โ”€ ResetEncoder.srv
โ”‚   โ”œโ”€โ”€ action/
โ”‚   โ”‚   โ””โ”€โ”€ MoveToShelf.action
โ”‚   โ”œโ”€โ”€ CMakeLists.txt
โ”‚   โ””โ”€โ”€ package.xml
โ””โ”€โ”€ archo_bringup/

The BatteryStatus.msg file defines the message structure in plain form:

# archo_interfaces/msg/BatteryStatus.msg
float32 percentage
float32 voltage
bool is_charging

This file is just a form, not executable code. When you build this package, ROS 2 automatically generates the corresponding Python and C++ classes. After building, you can import and use it in Python code like this:

from archo_interfaces.msg import BatteryStatus

msg = BatteryStatus()
msg.percentage = 87.5
msg.voltage = 24.1
msg.is_charging = False
self.publisher_.publish(msg)
โš ๏ธ Common Mistake

After adding or changing a .msg file, don't forget to rebuild: colcon build --packages-select archo_interfaces and then source install/setup.bash. Until you do this, Python cannot find archo_interfaces.msg and will raise an import error.

Medium Exercise

Add a string battery_health field to BatteryStatus.msg (for example with values "good", "warning", "replace") and modify battery_monitor.py so that when the battery percentage drops below 20, it sends battery_health as "warning".

3.4Service in Practice: Reset Encoder

Remember in Chapter 1 we said a service is for tasks that happen just once and on request? "Zeroing the wheel encoders" is exactly one of those tasks. First we define the service format:

# archo_interfaces/srv/ResetEncoder.srv
---
bool success
string message

The --- line separates the request from the response. Here the request is empty (only the call itself is needed, we don't send any data), but the response has two fields: whether it succeeded, and an explanatory message.

Server Side

# archo_bringup/motor_controller.py (Service section)
import rclpy
from rclpy.node import Node
from archo_interfaces.srv import ResetEncoder


class MotorController(Node):
    def __init__(self):
        super().__init__('motor_controller')
        self.encoder_ticks = 15420
        self.srv = self.create_service(
            ResetEncoder, 'reset_encoder', self.handle_reset)

    def handle_reset(self, request, response):
        self.get_logger().info(f'Resetting encoder from {self.encoder_ticks} ticks')
        self.encoder_ticks = 0
        response.success = True
        response.message = 'Encoder reset to zero'
        return response

Client Side (from the command line, for quick testing)

dev@archo:~$ ros2 service list /reset_encoder dev@archo:~$ ros2 service call /reset_encoder archo_interfaces/srv/ResetEncoder "{}" response: archo_interfaces.srv.ResetEncoder_Response(success=True, message='Encoder reset to zero')

And the same request from within another Python node (for example, a maintenance tool):

client = self.create_client(ResetEncoder, 'reset_encoder')
while not client.wait_for_service(timeout_sec=1.0):
    self.get_logger().info('Waiting for reset_encoder service...')

request = ResetEncoder.Request()
future = client.call_async(request)
๐Ÿ”ง Engineering Perspective: Why call_async?

Calling a service in Python is usually done asynchronously so that the node doesn't lock up while waiting for the response, and can keep listening to other topics at the same time. This is one of the important differences between writing a simple script and writing a real, multitasking node.

Medium Exercise

Design a new service called emergency_stop (the .srv file and the server-side implementation). The request can be empty; the response should indicate whether the stop succeeded or not.

3.5Action in Practice: Go to Shelf

Now let's tackle something that actually takes time: the command "go to shelf 12." The action format has three parts โ€” Goal, Feedback, and Result โ€” and they're all written in one file, in that order:

# archo_interfaces/action/MoveToShelf.action
int32 shelf_number
---
bool success
string final_message
---
float32 distance_remaining
string status

Action Server Side

# archo_bringup/motor_controller.py (Action section)
import time
from rclpy.action import ActionServer
from archo_interfaces.action import MoveToShelf


class MotorController(Node):
    def __init__(self):
        super().__init__('motor_controller')
        # ... the previous Service code also stays here ...
        self._action_server = ActionServer(
            self, MoveToShelf, 'move_to_shelf', self.execute_move)

    def execute_move(self, goal_handle):
        target = goal_handle.request.shelf_number
        self.get_logger().info(f'Moving to shelf {target}')
        distance = 18.0
        feedback = MoveToShelf.Feedback()

        while distance > 0:
            if goal_handle.is_cancel_requested:
                goal_handle.canceled()
                result = MoveToShelf.Result()
                result.success = False
                result.final_message = 'Cancelled mid-route'
                return result

            distance -= 3.0
            feedback.distance_remaining = max(distance, 0.0)
            feedback.status = 'moving'
            goal_handle.publish_feedback(feedback)
            time.sleep(0.5)

        goal_handle.succeed()
        result = MoveToShelf.Result()
        result.success = True
        result.final_message = f'Arrived at shelf {target}'
        return result
sequenceDiagram participant C as Client (Mission Manager) participant S as Action Server (motor_controller) C->>S: Goal: shelf_number = 12 S-->>C: Feedback: distance_remaining = 15.0 S-->>C: Feedback: distance_remaining = 9.0 S-->>C: Feedback: distance_remaining = 3.0 Note over C,S: The client can send Cancel Goal at any moment S-->>C: Result: success = true, "Arrived at shelf 12"

And from the command line, for quick testing without writing any client code:

dev@archo:~$ ros2 action send_goal /move_to_shelf archo_interfaces/action/MoveToShelf "{shelf_number: 12}" --feedback Feedback: distance_remaining: 15.0, status: moving Feedback: distance_remaining: 9.0, status: moving Feedback: distance_remaining: 3.0, status: moving Result: success: True, final_message: 'Arrived at shelf 12'
๐ŸŒ Why This Design Matters for the Warehouse

In the next chapter, when we install Nav2 on ARCHO, this exact pattern โ€” Goal/Feedback/Result with cancel support โ€” will be the core infrastructure for the robot's autonomous movement; Nav2's official action, called NavigateToPose, follows this same structure, just with a more realistic goal and feedback (a target pose, actual remaining distance from the map).

Harder Exercise

Write a scenario in which a client sends a MoveToShelf goal, but partway through (when distance_remaining reaches 9) requests a cancel. State what the final result value will be and why.

3.6Parameters in Practice

Now let's turn ARCHO's default movement speed into a parameter instead of writing it as a constant in the code:

class MotorController(Node):
    def __init__(self):
        super().__init__('motor_controller')
        self.declare_parameter('max_speed', 0.8)
        self.declare_parameter('wheel_radius', 0.05)

    def get_max_speed(self):
        return self.get_parameter('max_speed').get_parameter_value().double_value

And the corresponding YAML file, loaded at launch:

# config/motor_params.yaml
motor_controller:
  ros__parameters:
    max_speed: 0.8
    wheel_radius: 0.05
dev@archo:~$ ros2 param list /motor_controller: max_speed wheel_radius dev@archo:~$ ros2 param set /motor_controller max_speed 0.5 Set parameter successful
Easy Exercise

Add a parameter called low_battery_threshold (default 20.0) to battery_monitor, and use it instead of the hardcoded value 20 from the exercise in section 3.3.

3.7A Launch File That Starts Everything

Now let's connect the three nodes, the parameter file, and everything else with a single launch file:

# launch/archo_comms.launch.py
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch_ros.actions import Node


def generate_launch_description():
    params_file = os.path.join(
        get_package_share_directory('archo_bringup'),
        'config', 'motor_params.yaml')

    return LaunchDescription([
        Node(
            package='archo_bringup',
            executable='battery_monitor',
        ),
        Node(
            package='archo_bringup',
            executable='dashboard',
        ),
        Node(
            package='archo_bringup',
            executable='motor_controller',
            parameters=[params_file],
        ),
    ])
ros2 launch archo_bringup archo_comms.launch.py

With one command, all three nodes above come up: battery_monitor starts publishing, dashboard starts listening, and motor_controller prepares both the reset_encoder service and the move_to_shelf action, with parameters loaded from the YAML file.

archo_comms.launch.py battery_monitor Publisher dashboard Subscriber motor_controller Service + Action /battery_level motor_params.yaml โ†’ Parameters
Figure 3.1 โ€” One launch file, three independent nodes connected by a topic, service/action, and parameters.
Harder Exercise

Add a launch argument called use_sim (default false) that, when set to true, also runs an extra node called fake_battery_drain. (Hint: DeclareLaunchArgument and condition=IfCondition(...))

3.8Chapter 3 Summary

From Chapter 1 to here, we've covered a complete journey: you saw the abstract concepts of Node, Topic, Service, Action, and Parameter; in Chapter 2 you built your first quiet node; and in this chapter, for the first time, you wrote three real ARCHO nodes โ€” battery_monitor, dashboard, and motor_controller โ€” that actually talk to each other: one publishes, one listens, one responds to quick requests, and one runs a long-running operation with live progress reporting.

โœ… Learning Checkpoint
  • I can write a real publisher and subscriber with rclpy.
  • I know why we define custom messages in a separate _interfaces package.
  • I can implement a service with a custom request/response.
  • I can write an action server with goal/feedback/result and cancel support.
  • I know how to declare a parameter in code and load it from YAML.
  • I can run multiple nodes and a parameter file with a single launch file.

Connection to the Main Project

ARCHO Project now has a complete, real communication infrastructure. The robot still has no physical body or wheels โ€” that's exactly what we build in the next chapter.

What the Next Chapter Adds

In Chapter 4, we give ARCHO a real body for the first time: a chassis, two drive wheels, a caster wheel, and mounting points for a LiDAR and an IMU โ€” all using ROS 2's robot description language, URDF, and its smarter version, Xacro.

Chapter 3 Glossary

create_publisher / create_subscription
Functions in the Node class for creating a publisher and subscriber on a given topic.
Timer
A mechanism in rclpy that automatically runs a function at a fixed time interval.
Interfaces Package
A package that holds only custom message, service, and action definitions, without executable logic.
ActionServer / call_async
rclpy tools for implementing the server side of an action and for calling a service asynchronously.
declare_parameter
A function that registers a parameter with a default value on a node.
DeclareLaunchArgument
A launch tool for accepting command-line input arguments and using them conditionally.

Chapter 3 Common Mistakes โ€” Summary