Note: This is part of a series where I follow along with the ROS 2 Humble and Gazebo Fortress tutorials and demos. This is not original content.

Today we’ll follow along with this tutorial from the ROS 2 Humble docs. Like yesterday’s post, we’ll be creating a publisher and a subscriber, but this time in Python.

Shall we?

Basics Link to heading

Once again we’re going to be creating a workspace, creating a package, and then writing code for ROS 2 nodes which will send and receive messages via a topic. The sender is the publisher and the receiver is the subscriber.

Creating a workspace and a package Link to heading

Let’s start by creating a new workspace and a new package within said workspace:

mkdir -p ~/pubsub_py_ws
mkdir -p ~/pubsub_py_ws/src
cd ~/pubsub_py_ws/src
ros2 pkg create --build-type ament_python --license Apache-2.0 py_pubsub

Per usual, some messages will be printed out to let us know everything worked as expected:

going to create a new package
package name: py_pubsub
destination directory: /home/ubuntu/pubsub_py_ws/src
package format: 3
version: 0.0.0
description: TODO: Package description
maintainer: ['ubuntu <ubuntu@todo.todo>']
licenses: ['Apache-2.0']
build type: ament_python
dependencies: []
creating folder ./py_pubsub
creating ./py_pubsub/package.xml
creating source folder
creating folder ./py_pubsub/py_pubsub
creating ./py_pubsub/setup.py
creating ./py_pubsub/setup.cfg
creating folder ./py_pubsub/resource
creating ./py_pubsub/resource/py_pubsub
creating ./py_pubsub/py_pubsub/__init__.py
creating folder ./py_pubsub/test
creating ./py_pubsub/test/test_copyright.py
creating ./py_pubsub/test/test_flake8.py
creating ./py_pubsub/test/test_pep257.py

We need to navigate down a couple levels in the directory hierarchy (right now we are in ~/pubsub_py_ws/src) and fetch the exmaple code from the good folks who write and maintain ROS:

cd py_pubsub/py_pubsub
wget https://raw.githubusercontent.com/ros2/examples/humble/rclpy/topics/minimal_publisher/examples_rclpy_minimal_publisher/publisher_member_function.py

A new file publisher_member_function.py should now be located in ~/pubsub_py_ws/src/py_pubsub/py_pubsub.

Here’s the full code:

# Copyright 2016 Open Source Robotics Foundation, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import rclpy
from rclpy.node import Node

from std_msgs.msg import String


class MinimalPublisher(Node):

    def __init__(self):
        super().__init__('minimal_publisher')
        self.publisher_ = self.create_publisher(String, 'topic', 10)
        timer_period = 0.5  # seconds
        self.timer = self.create_timer(timer_period, self.timer_callback)
        self.i = 0

    def timer_callback(self):
        msg = String()
        msg.data = 'Hello World: %d' % self.i
        self.publisher_.publish(msg)
        self.get_logger().info('Publishing: "%s"' % msg.data)
        self.i += 1


def main(args=None):
    rclpy.init(args=args)

    minimal_publisher = MinimalPublisher()

    rclpy.spin(minimal_publisher)

    # Destroy the node explicitly
    # (optional - otherwise it will be done automatically
    # when the garbage collector destroys the node object)
    minimal_publisher.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

As before, there are some import statements at the top which fetch important ROS builtins:

import rclpy
from rclpy.node import Node

from std_msgs.msg import String

Again, we will use the “string” message type, one of the standard ROS 2 message types.

Again, we set up a MinimalPublisher class that sets a counter and creates a timer which triggers twice a second. The code is extremely similar to the C++ code we wrote yesterday (as it should be! this code does the exact same thing).

Adding dependencies Link to heading

Just as before, we need to update a couple different files to make sure ROS 2 will pull in the required dependencies for our package. In this casee, we need to do a couple of things:

  1. Add dependencies to package.xml
  2. Add an entry point to indicate where our Python code should start running from
  3. Confirm there are no issues with setup.cfg (it should have been generated correctly already when we created our pack age)

Below are my copies of those files, which should work just fine.

Complete code for package.xml Link to heading

<?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>py_pubsub</name>
  <version>0.0.0</version>
  <description>TODO: Package description</description>
  <maintainer email="ubuntu@todo.todo">ubuntu</maintainer>
  <license>Apache-2.0</license>

  <exec_depend>rclpy</exec_depend>
  <exec_depend>std_msgs</exec_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>

Complete code for setup.py Link to heading

from setuptools import find_packages, setup

package_name = 'py_pubsub'

setup(
    name=package_name,
    version='0.0.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='YourName',
    maintainer_email='you@email.com',
    description='Examples of minimal publisher/subscriber using rclpy',
    license='Apache License 2.0',
    tests_require=['pytest'],
    entry_points={
        'console_scripts': [
                'talker = py_pubsub.publisher_member_function:main',
        ],
    },
)

Complete code for setup.cfg Link to heading

[develop]
script_dir=$base/lib/py_pubsub
[install]
install_scripts=$base/lib/py_pubsub

Writing the subscriber code Link to heading

Now we return to ~/pubsub_py_ws/src/py_pubsub/py_pubsub and add the code for the subscriber:

cd ~/pubsub_py_ws/src/py_pubsub/py_pubsub
wget https://raw.githubusercontent.com/ros2/examples/humble/rclpy/topics/minimal_subscriber/examples_rclpy_minimal_subscriber/subscriber_member_function.py

Behold! A new subscriber_member_function.py appears. Here it is:

import rclpy
from rclpy.node import Node

from std_msgs.msg import String


class MinimalSubscriber(Node):

    def __init__(self):
        super().__init__('minimal_subscriber')
        self.subscription = self.create_subscription(
            String,
            'topic',
            self.listener_callback,
            10)
        self.subscription  # prevent unused variable warning

    def listener_callback(self, msg):
        self.get_logger().info('I heard: "%s"' % msg.data)


def main(args=None):
    rclpy.init(args=args)

    minimal_subscriber = MinimalSubscriber()

    rclpy.spin(minimal_subscriber)

    # Destroy the node explicitly
    # (optional - otherwise it will be done automatically
    # when the garbage collector destroys the node object)
    minimal_subscriber.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

I won’t go through it in detail as it’s very similar to yesterday’s C++ subscriber example.

Update setup.py again Link to heading

One thing we must do is make sure that we add another entrypoint to setup.py for our listener code, so that it starts when we run our node. Here’s the updated setup.py in its entirety (not we have only added a single line to the entry_points field…that’s all we need to do):

from setuptools import find_packages, setup

package_name = 'py_pubsub'

setup(
    name=package_name,
    version='0.0.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='YourName',
    maintainer_email='you@email.com',
    description='Examples of minimal publisher/subscriber using rclpy',
    license='Apache License 2.0',
    tests_require=['pytest'],
    entry_points={
        'console_scripts': [
                'talker = py_pubsub.publisher_member_function:main',
                'listener = py_pubsub.subscriber_member_function:main',
        ],
    },
)

Build and run Link to heading

Once again we need to return to the parent directory (in my case ~/pubsub_py_ws/) and pull in any dependencies (our dependencies are simple, so it’s likely they are already installed, but let’s be sure):

cd ~/pubsub_py_ws/
rosdep install -i --from-path src --rosdistro humble -y

And then…

colcon build --packages-select py_pubsub

Which will output the usual messages letting us know what’s going on with the (very short) build process:

Starting >>> py_pubsub
Finished <<< py_pubsub [1.12s]          

Summary: 1 package finished [1.48s]

Testing things out Link to heading

As before, we need to start up two terminals, source our overlay workspace with source install/setup.bash in each, and then run our “talker” and “listener”. Respectively.

Here goes.

First, the talker…

source install/setup.bash
ros2 run py_pubsub talker

Which outputs:

[INFO] [1723720591.421781152] [minimal_publisher]: Publishing: "Hello World: 0"
[INFO] [1723720591.910507279] [minimal_publisher]: Publishing: "Hello World: 1"
[INFO] [1723720592.410504728] [minimal_publisher]: Publishing: "Hello World: 2"
[INFO] [1723720592.910505471] [minimal_publisher]: Publishing: "Hello World: 3"
[INFO] [1723720593.410500300] [minimal_publisher]: Publishing: "Hello World: 4"
[INFO] [1723720593.910504476] [minimal_publisher]: Publishing: "Hello World: 5"
[INFO] [1723720594.410517831] [minimal_publisher]: Publishing: "Hello World: 6"
[INFO] [1723720594.910596502] [minimal_publisher]: Publishing: "Hello World: 7"
[INFO] [1723720595.410561800] [minimal_publisher]: Publishing: "Hello World: 8"
[INFO] [1723720595.910863764] [minimal_publisher]: Publishing: "Hello World: 9"
[INFO] [1723720596.410524523] [minimal_publisher]: Publishing: "Hello World: 10"
[INFO] [1723720596.910598926] [minimal_publisher]: Publishing: "Hello World: 11"
[INFO] [1723720597.410591834] [minimal_publisher]: Publishing: "Hello World: 12"
[INFO] [1723720597.910608853] [minimal_publisher]: Publishing: "Hello World: 13"
[INFO] [1723720598.410591086] [minimal_publisher]: Publishing: "Hello World: 14"
[INFO] [1723720598.910569253] [minimal_publisher]: Publishing: "Hello World: 15"
[INFO] [1723720599.410811425] [minimal_publisher]: Publishing: "Hello World: 16"
[INFO] [1723720599.910854850] [minimal_publisher]: Publishing: "Hello World: 17"
[INFO] [1723720600.410799816] [minimal_publisher]: Publishing: "Hello World: 18"

And then the listener…

source install/setup.bash
ros2 run py_pubsub listener

Which outputs:

[INFO] [1723720596.922454042] [minimal_subscriber]: I heard: "Hello World: 11"
[INFO] [1723720597.410871075] [minimal_subscriber]: I heard: "Hello World: 12"
[INFO] [1723720597.910885299] [minimal_subscriber]: I heard: "Hello World: 13"
[INFO] [1723720598.410852076] [minimal_subscriber]: I heard: "Hello World: 14"
[INFO] [1723720598.910832905] [minimal_subscriber]: I heard: "Hello World: 15"
[INFO] [1723720599.411145130] [minimal_subscriber]: I heard: "Hello World: 16"
[INFO] [1723720599.911123100] [minimal_subscriber]: I heard: "Hello World: 17"
[INFO] [1723720600.411113397] [minimal_subscriber]: I heard: "Hello World: 18"

Naturally the listener does not start from message 0….because I started it a little while after the publisher was already running (previous messages are not stored, the listener will simply pick up on the first available message that is sent to the topic after it enters a running state).

Other stuff… Link to heading

An interesting thing to note is how much “noisier” the exit process is for the Python talker and listener, when compared to their C++ equivalents.

For instance, when hit Ctrl+C to stop the Python “talker”, I see this message:

^CTraceback (most recent call last):
  File "/home/ubuntu/pubsub_py_ws/install/py_pubsub/lib/py_pubsub/listener", line 33, in <module>
    sys.exit(load_entry_point('py-pubsub==0.0.0', 'console_scripts', 'listener')())
  File "/home/ubuntu/pubsub_py_ws/install/py_pubsub/lib/python3.10/site-packages/py_pubsub/subscriber_member_function.py", line 41, in main
    rclpy.spin(minimal_subscriber)
  File "/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/__init__.py", line 222, in spin
    executor.spin_once()
  File "/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/executors.py", line 739, in spin_once
    self._spin_once_impl(timeout_sec)
  File "/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/executors.py", line 728, in _spin_once_impl
    handler, entity, node = self.wait_for_ready_callbacks(timeout_sec=timeout_sec)
  File "/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/executors.py", line 711, in wait_for_ready_callbacks
    return next(self._cb_iter)
  File "/opt/ros/humble/local/lib/python3.10/dist-packages/rclpy/executors.py", line 608, in _wait_for_ready_callbacks
    wait_set.wait(timeout_nsec)
KeyboardInterrupt
[ros2run]: Interrupt

This is not a problem, it’s just extra detail from Python about why the program quit…which might even be useful for me when I’m debugging things later on.

That’s it! See you next time.