Skip to main content

Nodes, topics and services with rclpy

Intermediate45 minBefore this:ROS 2 architecture and DDS · Python classes · Linux terminal

You have the theory of a ROS 2 graph. Now you will make one. By the end of this lesson you will have a real package that publishes a message every half second, a second node that hears it, a service that adds two numbers over the network, and a node that steers the turtle in the classic turtlesim simulator in a circle. These four patterns, publish, subscribe, request and respond, cover most of everyday ROS 2 programming.

This lesson assumes ROS 2 Jazzy is installed as described in Workstation setup, and that you ran source /opt/ros/jazzy/setup.bash in your terminal.

You will be able to

  • Create an ament_python package with the right package.xml, setup.py, setup.cfg and entry points.
  • Write a publisher and a subscriber node with rclpy, and shut them down cleanly.
  • Write a service server and a client that calls it without blocking the executor.
  • Build with colcon, run with ros2 run, and inspect topics and services from the command line.

The shape of a Python package​

A packagethe unit of software distribution in ROS 2: a folder with a manifest (package.xml) and a build recipe, containing nodes, launch files, configuration and so on is a folder ROS can find, build and install. For Python, the layout is:

ros2_ws/
src/
kinemata_basics/
package.xml <- name, version, dependencies
setup.py <- what to install, and the console scripts
setup.cfg <- tells installers to put scripts where ros2 run looks
resource/
kinemata_basics <- an empty marker file ROS uses to find the package
kinemata_basics/
__init__.py <- empty
talker.py
listener.py
add_server.py
add_client.py
circle_driver.py

Note the repeated name: the outer folder is the package, the inner folder with __init__.py is the Python module. Create the skeleton with the ROS tool, then replace the generated files with the ones below:

mkdir -p ~/ros2_ws/src && cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python kinemata_basics

Here are the three metadata files.

package.xml
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>kinemata_basics</name>
<version>0.1.0</version>
<description>Publishers, subscribers and services for the Kinemata course.</description>
<maintainer email="you@example.com">Your Name</maintainer>
<license>Apache-2.0</license>

<depend>rclpy</depend>
<depend>std_msgs</depend>
<depend>geometry_msgs</depend>
<depend>example_interfaces</depend>

<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>

<export>
<build_type>ament_python</build_type>
</export>
</package>
setup.py
from setuptools import find_packages, setup

package_name = "kinemata_basics"

setup(
name=package_name,
version="0.1.0",
packages=find_packages(exclude=["test"]),
data_files=[
("share/ament_index/resource_index/packages", ["resource/" + package_name]),
("share/" + package_name, ["package.xml"]),
],
install_requires=["setuptools"],
zip_safe=True,
maintainer="Your Name",
maintainer_email="you@example.com",
description="Publishers, subscribers and services for the Kinemata course.",
license="Apache-2.0",
tests_require=["pytest"],
entry_points={
"console_scripts": [
"talker = kinemata_basics.talker:main",
"listener = kinemata_basics.listener:main",
"add_server = kinemata_basics.add_server:main",
"add_client = kinemata_basics.add_client:main",
"circle_driver = kinemata_basics.circle_driver:main",
],
},
)
setup.cfg
[develop]
script_dir=$base/lib/kinemata_basics
[install]
install_scripts=$base/lib/kinemata_basics

The key line is each entry point, "talker = kinemata_basics.talker:main". It means "make a command called talker that calls the function main in the module kinemata_basics/talker.py". ros2 run kinemata_basics talker finds that command. Forget to add an entry point, and ros2 run says "No executable found" even though the file exists.

A publisher​

A publisher node creates a publisher object with a message type, a topic name and a queue depth, then sends messages, here from a timer.

kinemata_basics/talker.py
import rclpy
from rclpy.executors import ExternalShutdownException
from rclpy.node import Node
from std_msgs.msg import String


class Talker(Node):
def __init__(self):
super().__init__("talker")
self.publisher = self.create_publisher(String, "chatter", 10)
self.timer = self.create_timer(0.5, self.tick) # 2 Hz
self.count = 0

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


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


if __name__ == "__main__":
main()

The pattern repeats in every node you will ever write: a class inheriting Node, endpoints created in __init__, callbacks as methods, and a main that inits, spins and shuts down. The 10 is the QoS history depth you met in the last lesson.

A subscriber​

A subscriber supplies a callback that ROS calls for every incoming message.

kinemata_basics/listener.py
import rclpy
from rclpy.executors import ExternalShutdownException
from rclpy.node import Node
from std_msgs.msg import String


class Listener(Node):
def __init__(self):
super().__init__("listener")
self.subscription = self.create_subscription(String, "chatter", self.on_message, 10)

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


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


if __name__ == "__main__":
main()

The talker and listener never mention each other. They share only a topic name and a type. Start them in either order, kill one, restart it, and the other carries on.

A service​

Topics are streams. A servicea request and response pair: one node asks, another answers exactly once is a question. The server offers a named function; a client calls it and waits for the answer. We use AddTwoInts from the example_interfaces package, which is defined as a request with integers a and b and a response with an integer sum.

kinemata_basics/add_server.py
import rclpy
from example_interfaces.srv import AddTwoInts
from rclpy.executors import ExternalShutdownException
from rclpy.node import Node


class AddServer(Node):
def __init__(self):
super().__init__("add_server")
self.srv = self.create_service(AddTwoInts, "add_two_ints", self.handle_add)

def handle_add(self, request, response):
response.sum = request.a + request.b
self.get_logger().info(f"{request.a} + {request.b} = {response.sum}")
return response


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


if __name__ == "__main__":
main()

The client must not block the executor, so it sends the request with call_async and waits for the future. Because this client has no spinning node of its own competing with other work, spin_until_future_complete is safe here.

kinemata_basics/add_client.py
import sys

import rclpy
from example_interfaces.srv import AddTwoInts
from rclpy.node import Node


def main(args=None):
rclpy.init(args=args)
node = Node("add_client")
client = node.create_client(AddTwoInts, "add_two_ints")

while not client.wait_for_service(timeout_sec=1.0):
node.get_logger().info("waiting for add_two_ints...")

request = AddTwoInts.Request()
request.a = int(sys.argv[1]) if len(sys.argv) > 2 else 3
request.b = int(sys.argv[2]) if len(sys.argv) > 2 else 4

future = client.call_async(request)
rclpy.spin_until_future_complete(node, future)
result = future.result()
if result is not None:
node.get_logger().info(f"{request.a} + {request.b} = {result.sum}")
else:
node.get_logger().error(f"call failed: {future.exception()}")

node.destroy_node()
rclpy.try_shutdown()


if __name__ == "__main__":
main()
Warning

Never call client.call(request) from inside a callback of a single-threaded node. It blocks the only thread, so the response callback can never run, and the node hangs. Use call_async and a future, as above, or a multi-threaded executor with a separate callback group.

Driving a robot: Twist on cmd_vel​

By convention, mobile robots in ROS accept velocity commands as geometry_msgs/msg/Twist on a topic named cmd_vel. A Twist has a linear velocity (m/s) and an angular velocity (rad/s), each with x, y, z. A differential-drive robot uses linear.x (forward) and angular.z (turning). Driving at constant values for both traces a circle of radius v/ωv/\omega.

kinemata_basics/circle_driver.py
import rclpy
from geometry_msgs.msg import Twist
from rclpy.executors import ExternalShutdownException
from rclpy.node import Node


class CircleDriver(Node):
def __init__(self):
super().__init__("circle_driver")
# turtlesim listens on /turtle1/cmd_vel
self.pub = self.create_publisher(Twist, "turtle1/cmd_vel", 10)
self.timer = self.create_timer(0.1, self.tick) # 10 Hz

def tick(self):
msg = Twist()
msg.linear.x = 1.0 # m/s (turtlesim units)
msg.angular.z = 0.8 # rad/s -> circle of radius 1/0.8 = 1.25
self.pub.publish(msg)


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


if __name__ == "__main__":
main()

The same node will drive a real robot, or a Gazebo model in the simulation module, with no changes except what is listening on cmd_vel.

Info

Need a message type of your own? Message and service definitions (.msg, .srv) are built by CMake-based packages using rosidl_default_generators, so they live in a separate package, usually called something_interfaces. Python-only packages then depend on it. We keep to the standard types in this module; the ROS 2 docs have a tutorial on creating custom interfaces when you need one.

Build, run and inspect​

cd ~/ros2_ws
colcon build --packages-select kinemata_basics --symlink-install
source install/setup.bash

ros2 run kinemata_basics talker # terminal 1
ros2 run kinemata_basics listener # terminal 2

--symlink-install links Python files instead of copying them, so edits take effect without rebuilding. (New entry points still need a rebuild.) You must source install/setup.bash in every new terminal.

Now inspect the live graph from a third terminal:

ros2 node list
ros2 topic list -t
ros2 topic echo /chatter
ros2 topic hz /chatter
ros2 topic pub --once /chatter std_msgs/msg/String "{data: 'from the CLI'}"
ros2 interface show std_msgs/msg/String
ros2 service list -t
ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 5, b: 7}"

Hands-on lab​

  1. Create the package, copy in the files above, and build it. Run the talker and the listener in two terminals. Confirm that each message appears in both.
  2. In a third terminal run ros2 topic hz /chatter. You should see about 2 Hz. Change the timer period in talker.py to 0.1 (no rebuild needed thanks to --symlink-install) and watch the rate change.
  3. Remap the topic: ros2 run kinemata_basics listener --ros-args -r chatter:=other. The listener now hears nothing. Run the talker with -r chatter:=other too, and they reconnect.
  4. Start add_server, then run ros2 run kinemata_basics add_client 20 22. Then call the service from the command line with ros2 service call.
  5. Install turtlesim with sudo apt install ros-jazzy-turtlesim, run ros2 run turtlesim turtlesim_node, then ros2 run kinemata_basics circle_driver. The turtle should loop in a circle.

You know it worked when messages flow in both directions, the topic rate matches your timer, and the turtle circles.

Exercises​

Add a counter serviceBeginner

Write a node with a std_srvs/srv/Trigger service called reset_counter that resets the talker's counter to 0. What are the request and response fields?

Show solutionHide solution

Trigger has an empty request, and a response with success (bool) and message (string). In the talker, create the service with self.create_service(Trigger, "reset_counter", self.reset) and define def reset(self, request, response): self.count = 0; response.success = True; response.message = "reset"; return response. Add <depend>std_srvs</depend> to package.xml and from std_srvs.srv import Trigger at the top of the file.

Stop when toldIntermediate

Change circle_driver so it subscribes to a std_msgs/msg/Bool topic drive_enable and publishes zero velocity when the last value was False.

Show solutionHide solution

Store a flag, self.enabled = True, and create a subscription whose callback sets self.enabled = msg.data. In tick, publish the normal Twist only if self.enabled; otherwise publish a default Twist() (all zeros). Publishing an explicit zero is safer than falling silent, because many robot bases keep moving until told otherwise.

Why a queue of 10?Intermediate

What happens if the listener is busy for a second and the talker publishes at 2 Hz with queue depth 10? And if the depth is 1?

Show solutionHide solution

With depth 10, the two messages published during the delay all fit in the queue and are delivered afterwards in order. With depth 1, only the newest is kept, so the older one is dropped. Pick small depths for "latest value wins" data such as sensor readings, and larger depths when every message matters.

Check your understanding​

1What does the line "talker = kinemata_basics.talker:main" in setup.py do?
2Why use call_async for a service client inside a node callback?
3A Twist message published on cmd_vel with linear.x = 0.5 and angular.z = 0 makes a differential-drive robot…
4What does colcon build --symlink-install do for Python packages?
0 of 4 answered

Takeaways

  • A Python package needs package.xml, setup.py, setup.cfg, a resource marker file and a module folder.
  • Entry points in setup.py are what make ros2 run package executable work.
  • Publishers send typed messages to a named topic; subscribers register callbacks; neither knows the other exists.
  • A service is a request and response pair; clients should use call_async so they never block the executor.
  • Mobile robots accept geometry_msgs/msg/Twist on cmd_vel: linear.x forward, angular.z turning.
  • Use ros2 node/topic/service/interface commands to inspect a live graph while you build.

Stuck? Ask Rivet, the course assistant, bottom-right.

Go deeper​

Next: Actions, parameters and launch files

Finished the lab and the quiz?

Mark it done to track your progress through the course.