Tutorial 8 · ROS 2 Humble · Python

Writing ROS 2 programs in Python on a TurtleBot 4

In tutorial 7 you used the ros2 command line. Now you write your own nodes. You create a workspace, make packages, build them with colcon and run your Python code next to the simulated TurtleBot 4. Your nodes really run, in Python, in your browser. They publish, subscribe, call services and drive the robot, and the robot sees them like any other node.

This tutorial follows the ROS 2 Humble “Beginner: Client libraries” tutorials (Open Robotics, CC BY 4.0), in their Python version. The example code comes from the ros2/examples repository (Apache 2.0) and from those tutorials. Each step also has an exercise on the TurtleBot.

0Before you start

Open the TurtleBot 4 with the terminal →

Sourcing, the most common mistake. After building, a terminal only knows your packages once you have run source install/setup.bash in it. This counts per terminal tab, just like on a real Linux machine. A new tab starts without your workspace, unless you add the source line to ~/.bashrc (see step 2). If you see Package 'py_pubsub' not found, you forgot to source.

1Using colcon to build packages

colcon is the build tool of ROS 2. A workspace is a folder with a src folder for the source code. colcon builds it and creates three folders next to src:

Create a workspace and add sources

mkdir -p ~/ros2_ws/src
cd ~/ros2_ws
git clone https://github.com/ros2/examples src/examples -b humble
Cloning into 'src/examples'...
remote: Enumerating objects: 9374, done.
…
Resolving deltas: 100% (5743/5743), done.

The terminal clones the Python examples (rclpy) of the ROS 2 examples repository. The C++ examples are left out, because they cannot run here. colcon list shows the packages it found:

colcon list
examples_rclpy_minimal_client	src/examples/rclpy/services/minimal_client	(ros.ament_python)
examples_rclpy_minimal_service	src/examples/rclpy/services/minimal_service	(ros.ament_python)
examples_rclpy_minimal_publisher	src/examples/rclpy/topics/minimal_publisher	(ros.ament_python)
examples_rclpy_minimal_subscriber	src/examples/rclpy/topics/minimal_subscriber	(ros.ament_python)

Build the workspace

In the root of the workspace:

colcon build --symlink-install
Starting >>> examples_rclpy_minimal_client
Finished <<< examples_rclpy_minimal_client [0.51s]
Starting >>> examples_rclpy_minimal_service
Finished <<< examples_rclpy_minimal_service [0.51s]
Starting >>> examples_rclpy_minimal_publisher
Finished <<< examples_rclpy_minimal_publisher [0.58s]
Starting >>> examples_rclpy_minimal_subscriber
Finished <<< examples_rclpy_minimal_subscriber [0.53s]

Summary: 4 packages finished [2.19s]

With --symlink-install, the installed Python files point to your source files. When you change a .py file, you do not have to build again. Changes to setup.py (for example a new executable) do need a new build. ls now shows build install log src.

Run the tests

colcon test
colcon test-result --all

Every Python package gets three tests: flake8 (code style), pep257 (docstrings) and copyright. If something fails, colcon test-result --verbose tells you what. A typical failure is Q000 Double quotes found but single quotes preferred: ROS 2 Python code uses single quotes.

Source the environment and try a demo

source install/setup.bash
ros2 run examples_rclpy_minimal_subscriber subscriber_member_function

In a second terminal (+), go to the workspace, source it, and start the publisher:

cd ~/ros2_ws && source install/setup.bash
ros2 run examples_rclpy_minimal_publisher publisher_member_function
[INFO] [1792937767.847000000] [minimal_publisher]: Publishing: "Hello World: 0"
[INFO] [1792937768.340000000] [minimal_publisher]: Publishing: "Hello World: 1"
…

The subscriber in the first terminal prints I heard: "Hello World: …". Stop both with Ctrl+C. Python then shows a KeyboardInterrupt traceback, exactly as on a real ROS 2 Humble. That is normal, not an error in your code.

Tips. A folder with an empty file COLCON_IGNORE is skipped by colcon. colcon build --packages-select NAME builds only that package, and --packages-up-to NAME builds it with its dependencies.

2Creating a workspace: underlay and overlay

The ROS 2 installation in /opt/ros/humble is the underlay. Your workspace is an overlay on top of it: it adds packages, and a package in the overlay replaces the one with the same name in the underlay. The examples you just built also exist in /opt/ros/humble. That makes them good for seeing the difference.

Resolve dependencies

Before you build, check that all dependencies in the package.xml files are installed. Run this from the root of the workspace:

rosdep install -i --from-path src --rosdistro humble -y
#All required rosdeps installed successfully

If a package depends on something unknown, for example a typo such as <depend>rclpyy</depend>, rosdep says so: Cannot locate rosdep definition for [rclpyy].

Modify the overlay

nano src/examples/rclpy/topics/minimal_publisher/examples_rclpy_minimal_publisher/publisher_member_function.py

Change 'Hello World: %d' into 'Hello TurtleBot: %d' and save. Because you built with --symlink-install, you do not need to build again. Open a new terminal, source the overlay and run it:

cd ~/ros2_ws && source install/local_setup.bash
ros2 run examples_rclpy_minimal_publisher publisher_member_function
[INFO] [1792937987.451000000] [minimal_publisher]: Publishing: "Hello TurtleBot: 0"
[INFO] [1792937987.952000000] [minimal_publisher]: Publishing: "Hello TurtleBot: 1"

Now open another new terminal and run the same command without sourcing. It prints Hello World again: that is the underlay, which you did not change. See where a package comes from:

echo $AMENT_PREFIX_PATH
ros2 pkg prefix examples_rclpy_minimal_publisher
/home/ubuntu/ros2_ws/install/examples_rclpy_minimal_publisher

Tired of sourcing in every new terminal? Add it to ~/.bashrc, which every new terminal runs:

echo 'source ~/ros2_ws/install/setup.bash' >> ~/.bashrc

3Creating a package

A package is the unit you build, install and share. A Python package (ament_python) contains:

my_package/
     package.xml          meta information: name, version, dependencies
     resource/my_package  marker file, so ROS 2 can find the package
     setup.cfg            where the executables are installed (lib/my_package)
     setup.py             how to install: the Python modules, data files, executables
     my_package/          the Python code, with __init__.py

Create a package in the src folder:

cd ~/ros2_ws/src
ros2 pkg create --build-type ament_python --license Apache-2.0 --node-name my_node my_package
going to create a new package
package name: my_package
destination directory: /home/ubuntu/ros2_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: []
node_name: my_node
creating folder ./my_package
creating ./my_package/package.xml
creating ./my_package/LICENSE
creating source folder
creating folder ./my_package/my_package
creating ./my_package/setup.py
creating ./my_package/setup.cfg
creating folder ./my_package/resource
creating ./my_package/resource/my_package
creating ./my_package/my_package/__init__.py
creating folder ./my_package/test
creating ./my_package/test/test_copyright.py
creating ./my_package/test/test_flake8.py
creating ./my_package/test/test_pep257.py
creating ./my_package/my_package/my_node.py

Build, source, run

cd ~/ros2_ws && colcon build --packages-select my_package
source install/local_setup.bash
ros2 run my_package my_node
Hi from my_package.

Look at the generated code with cat src/my_package/my_package/my_node.py and the structure with tree src/my_package.

Customize package.xml and setup.py

Open nano src/my_package/package.xml and fill in the <description> and the <maintainer> (your name and email). The same fields appear in setup.py, and they must match. The <*_depend> tags list what the package needs. You will add those in the next steps.

The most important part of setup.py are the entry points. Each line makes a Python function an executable for ros2 run:

entry_points={
    'console_scripts': [
        'my_node = my_package.my_node:main'
    ],
},

my_node is the name for ros2 run, and my_package.my_node:main is the module and the function it calls.

4Writing a simple publisher and subscriber

You write a talker that publishes text on a topic, and a listener that prints it.

Create the package and download the code

cd ~/ros2_ws/src && ros2 pkg create --build-type ament_python --license Apache-2.0 py_pubsub
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
wget https://raw.githubusercontent.com/ros2/examples/humble/rclpy/topics/minimal_subscriber/examples_rclpy_minimal_subscriber/subscriber_member_function.py

The folder now holds __init__.py publisher_member_function.py subscriber_member_function.py. Read the publisher with cat publisher_member_function.py. Without its license header, it is:

py_pubsub/publisher_member_function.pyimport 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()

The subscriber is almost the same. Instead of a publisher and a timer, it has create_subscription(String, 'topic', self.listener_callback, 10), and the callback logs 'I heard: "%s"' % msg.data.

Add the dependencies and entry points

The code imports rclpy and std_msgs. Declare them in package.xml (cd .., then nano package.xml), under the license line:

  <exec_depend>rclpy</exec_depend>
  <exec_depend>std_msgs</exec_depend>

In setup.py, add the two executables to console_scripts:

    entry_points={
        'console_scripts': [
                'talker = py_pubsub.publisher_member_function:main',
                'listener = py_pubsub.subscriber_member_function:main',
        ],
    },

setup.cfg is already right. It makes setuptools install the executables in lib/py_pubsub, where ros2 run looks for them.

Build and run

cd ~/ros2_ws && rosdep install -i --from-path src --rosdistro humble -y
colcon build --packages-select py_pubsub

Open a new terminal, source the workspace and start the talker:

cd ~/ros2_ws && source install/setup.bash && ros2 run py_pubsub talker
[INFO] [1792937250.300000000] [minimal_publisher]: Publishing: "Hello World: 0"
[INFO] [1792937250.783000000] [minimal_publisher]: Publishing: "Hello World: 1"
[INFO] [1792937251.283000000] [minimal_publisher]: Publishing: "Hello World: 2"

And in another terminal, the listener:

cd ~/ros2_ws && source install/setup.bash && ros2 run py_pubsub listener
[INFO] [1792937254.443000000] [minimal_subscriber]: I heard: "Hello World: 8"
[INFO] [1792937254.788000000] [minimal_subscriber]: I heard: "Hello World: 9"

Your nodes are now part of the ROS graph. In a third terminal, try ros2 node list, ros2 topic info /topic -v, ros2 topic echo /topic or rqt_graph, all from tutorial 7.

On the robot: a node that drives. A node that subscribes to /scan and publishes on /cmd_vel is a real robot program. Create a package with the dependencies, and add this file:

cd ~/ros2_ws/src && ros2 pkg create --build-type ament_python --license Apache-2.0 turtlebot_py --dependencies rclpy geometry_msgs sensor_msgs irobot_create_msgs
turtlebot_py/turtlebot_py/obstacle_avoider.pyimport math

import rclpy
from rclpy.node import Node
from rclpy.qos import qos_profile_sensor_data

from geometry_msgs.msg import Twist
from sensor_msgs.msg import LaserScan


class ObstacleAvoider(Node):

    def __init__(self):
        super().__init__('obstacle_avoider')
        self.declare_parameter('speed', 0.2)
        self.declare_parameter('stop_distance', 0.5)
        self.publisher_ = self.create_publisher(Twist, 'cmd_vel', 10)
        self.subscription = self.create_subscription(
            LaserScan, 'scan', self.scan_callback, qos_profile_sensor_data)
        self.nearest = float('inf')
        self.timer = self.create_timer(0.1, self.drive)

    def scan_callback(self, msg):
        # The rays in a cone of 30 degrees in front of the robot
        front = []
        for i, r in enumerate(msg.ranges):
            angle = msg.angle_min + i * msg.angle_increment
            if abs(angle) < math.radians(15) and msg.range_min < r < msg.range_max:
                front.append(r)
        self.nearest = min(front) if front else float('inf')

    def drive(self):
        cmd = Twist()
        if self.nearest > self.get_parameter('stop_distance').value:
            cmd.linear.x = self.get_parameter('speed').value
        else:
            cmd.angular.z = 0.8
            self.get_logger().info('Obstacle at %.2f m: turning' % self.nearest,
                                   throttle_duration_sec=1.0)
        self.publisher_.publish(cmd)


def main(args=None):
    rclpy.init(args=args)
    node = ObstacleAvoider()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    node.publisher_.publish(Twist())     # stop the robot
    node.destroy_node()
    rclpy.try_shutdown()


if __name__ == '__main__':
    main()

Add 'obstacle_avoider = turtlebot_py.obstacle_avoider:main', to the console_scripts of its setup.py, build and run it. It works best in the corridor lane: indexTurtleBot4.php?lane=corridor.

cd ~/ros2_ws && colcon build --packages-select turtlebot_py && source install/setup.bash && ros2 run turtlebot_py obstacle_avoider
[INFO] [1792937888.198000000] [obstacle_avoider]: Obstacle at 0.50 m: turning
[INFO] [1792937889.200000000] [obstacle_avoider]: Obstacle at 0.46 m: turning

Note the try / except KeyboardInterrupt. Ctrl+C now stops the robot properly instead of printing a traceback. The LiDAR sends angle_min = −π, and the ray at angle 0 points straight ahead.

5Writing a simple service and client

A service node adds two numbers. A client sends it two numbers from the command line.

cd ~/ros2_ws/src && ros2 pkg create --build-type ament_python --license Apache-2.0 py_srvcli --dependencies rclpy example_interfaces

--dependencies adds <depend>rclpy</depend> and <depend>example_interfaces</depend> to package.xml. example_interfaces holds AddTwoInts.srv (see ros2 interface show example_interfaces/srv/AddTwoInts):

int64 a
int64 b
---
int64 sum

The service node

nano py_srvcli/py_srvcli/service_member_function.py
py_srvcli/service_member_function.pyfrom example_interfaces.srv import AddTwoInts

import rclpy
from rclpy.node import Node


class MinimalService(Node):

    def __init__(self):
        super().__init__('minimal_service')
        self.srv = self.create_service(AddTwoInts, 'add_two_ints', self.add_two_ints_callback)

    def add_two_ints_callback(self, request, response):
        response.sum = request.a + request.b
        self.get_logger().info('Incoming request\na: %d b: %d' % (request.a, request.b))

        return response


def main():
    rclpy.init()

    minimal_service = MinimalService()

    rclpy.spin(minimal_service)

    rclpy.shutdown()


if __name__ == '__main__':
    main()

The client node

nano py_srvcli/py_srvcli/client_member_function.py
py_srvcli/client_member_function.pyimport sys

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


class MinimalClientAsync(Node):

    def __init__(self):
        super().__init__('minimal_client_async')
        self.cli = self.create_client(AddTwoInts, 'add_two_ints')
        while not self.cli.wait_for_service(timeout_sec=1.0):
            self.get_logger().info('service not available, waiting again...')
        self.req = AddTwoInts.Request()

    def send_request(self, a, b):
        self.req.a = a
        self.req.b = b
        return self.cli.call_async(self.req)


def main():
    rclpy.init()

    minimal_client = MinimalClientAsync()
    future = minimal_client.send_request(int(sys.argv[1]), int(sys.argv[2]))
    rclpy.spin_until_future_complete(minimal_client, future)
    response = future.result()
    minimal_client.get_logger().info(
        'Result of add_two_ints: for %d + %d = %d' %
        (int(sys.argv[1]), int(sys.argv[2]), response.sum))

    minimal_client.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

The client waits until the service exists, sends the request with call_async() and spins until the answer is there.

Entry points, build, run

    entry_points={
        'console_scripts': [
            'service = py_srvcli.service_member_function:main',
            'client = py_srvcli.client_member_function:main',
        ],
    },
cd ~/ros2_ws && colcon build --packages-select py_srvcli && source install/setup.bash

Start the client first, to see it wait:

ros2 run py_srvcli client 2 3
[INFO] [1792937472.280000000] [minimal_client_async]: service not available, waiting again...
[INFO] [1792937473.289000000] [minimal_client_async]: service not available, waiting again...

Then start the service in a second terminal (cd ~/ros2_ws && source install/setup.bash && ros2 run py_srvcli service). The client gets its answer and stops:

[INFO] [1792937475.195000000] [minimal_service]: Incoming request
a: 2 b: 3

[INFO] [1792937475.196000000] [minimal_client_async]: Result of add_two_ints: for 2 + 3 = 5

The service also answers the command line: ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 40, b: 2}". Try ros2 run py_srvcli client 7 with only one number. You get an IndexError traceback: sys.argv[2] does not exist.

On the robot: call a robot service. The TurtleBot's /e_stop service is a irobot_create_msgs/srv/EStop. Add a client to turtlebot_py that switches it on or off, with estop = turtlebot_py.estop_client:main as its entry point. Run it while the obstacle avoider drives: ros2 run turtlebot_py estop on stops the motors.

turtlebot_py/turtlebot_py/estop_client.pyimport sys

from irobot_create_msgs.srv import EStop
import rclpy
from rclpy.node import Node


def main():
    rclpy.init()
    node = Node('estop_client')
    cli = node.create_client(EStop, 'e_stop')
    while not cli.wait_for_service(timeout_sec=1.0):
        node.get_logger().info('service not available, waiting again...')
    req = EStop.Request()
    req.e_stop_on = sys.argv[1] == 'on'
    future = cli.call_async(req)
    rclpy.spin_until_future_complete(node, future)
    result = future.result()
    node.get_logger().info('success: %s, message: %s' % (result.success, result.message))
    node.destroy_node()
    rclpy.shutdown()
[INFO] [1792937891.292000000] [estop_client]: success: True, message: E-stop on: the motors are off

6Creating custom msg and srv files

Until now you used existing message types. Now you define your own: Num.msg, Sphere.msg and AddThreeInts.srv. Interfaces always live in an ament_cmake package, but you use them from Python.

cd ~/ros2_ws/src && ros2 pkg create --build-type ament_cmake --license Apache-2.0 tutorial_interfaces
cd tutorial_interfaces && mkdir msg srv

Create the three files with nano:

msg/Num.msgint64 num
msg/Sphere.msggeometry_msgs/Point center
float64 radius
srv/AddThreeInts.srvint64 a
int64 b
int64 c
---
int64 sum

CMakeLists.txt

Add these lines to CMakeLists.txt, before ament_package():

find_package(geometry_msgs REQUIRED)
find_package(rosidl_default_generators REQUIRED)

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/Num.msg"
  "msg/Sphere.msg"
  "srv/AddThreeInts.srv"
  DEPENDENCIES geometry_msgs # Add packages that above messages depend on, in this case geometry_msgs for Sphere.msg
)

Try to build now, before you change package.xml:

cd ~/ros2_ws && colcon build --packages-select tutorial_interfaces
Starting >>> tutorial_interfaces
--- stderr: tutorial_interfaces
CMake Error at /opt/ros/humble/share/rosidl_cmake/cmake/rosidl_generate_interfaces.cmake:265 (message):
  Packages installing interfaces must include
  '<member_of_group>rosidl_interface_packages</member_of_group>' in their
  package.xml
…
Failed   <<< tutorial_interfaces [0.51s, exited with code 1]

package.xml

The build tells you what is missing. Add these lines to src/tutorial_interfaces/package.xml, inside <package>:

  <depend>geometry_msgs</depend>
  <buildtool_depend>rosidl_default_generators</buildtool_depend>
  <exec_depend>rosidl_default_runtime</exec_depend>
  <member_of_group>rosidl_interface_packages</member_of_group>
colcon build --packages-select tutorial_interfaces
Starting >>> tutorial_interfaces
Finished <<< tutorial_interfaces [2.01s]

Summary: 1 package finished [2.01s]

Check the new interfaces

source install/setup.bash && ros2 interface show tutorial_interfaces/msg/Sphere
geometry_msgs/Point center
	float64 x
	float64 y
	float64 z
float64 radius

Use Num in the publisher

Change py_pubsub/publisher_member_function.py on the lines marked # CHANGE. Change the listener the same way (Num, and 'I heard: "%d"' % msg.num):

py_pubsub/publisher_member_function.pyimport rclpy
from rclpy.node import Node

from tutorial_interfaces.msg import Num                            # CHANGE


class MinimalPublisher(Node):

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

    def timer_callback(self):
        msg = Num()                                                # CHANGE
        msg.num = self.i                                           # CHANGE
        self.publisher_.publish(msg)
        self.get_logger().info('Publishing: "%d"' % msg.num)       # CHANGE
        self.i += 1


def main(args=None):
    rclpy.init(args=args)
    minimal_publisher = MinimalPublisher()
    rclpy.spin(minimal_publisher)
    minimal_publisher.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

Add <exec_depend>tutorial_interfaces</exec_depend> to py_pubsub/package.xml, build and run:

colcon build --packages-select py_pubsub && ros2 run py_pubsub talker
[INFO] [1792937598.442000000] [minimal_publisher]: Publishing: "0"
[INFO] [1792937598.943000000] [minimal_publisher]: Publishing: "1"

In the same way, change py_srvcli to use AddThreeInts: import it from tutorial_interfaces.srv, use the service name add_three_ints and add request.c.

Message fields are typed. radius is a float64, so s.radius = 3 fails with AssertionError: The 'radius' field must be of type 'float'. Write 3.0. The same goes for Twist: cmd.linear.x = 1 fails, cmd.linear.x = 1.0 works.

7Using parameters in a class

cd ~/ros2_ws/src && ros2 pkg create --build-type ament_python --license Apache-2.0 python_parameters --dependencies rclpy
python_parameters/python_parameters_node.pyimport rclpy
import rclpy.node
from rcl_interfaces.msg import ParameterDescriptor


class MinimalParam(rclpy.node.Node):
    def __init__(self):
        super().__init__('minimal_param_node')

        my_parameter_descriptor = ParameterDescriptor(description='This parameter is mine!')
        self.declare_parameter('my_parameter', 'world', my_parameter_descriptor)

        self.timer = self.create_timer(1, self.timer_callback)

    def timer_callback(self):
        my_param = self.get_parameter('my_parameter').get_parameter_value().string_value

        self.get_logger().info('Hello %s!' % my_param)

        my_new_param = rclpy.parameter.Parameter(
            'my_parameter',
            rclpy.Parameter.Type.STRING,
            'world'
        )
        all_new_parameters = [my_new_param]
        self.set_parameters(all_new_parameters)


def main():
    rclpy.init()
    node = MinimalParam()
    rclpy.spin(node)


if __name__ == '__main__':
    main()

declare_parameter creates the parameter with a default value. The type comes from that value, so here it is a string. Each second, the timer prints the value and sets it back to world. Add the entry point 'minimal_param_node = python_parameters.python_parameters_node:main',, then build, source and run:

ros2 run python_parameters minimal_param_node
[INFO] [1792937521.248000000] [minimal_param_node]: Hello world!

Change it from the console

ros2 param describe /minimal_param_node my_parameter
Parameter name: my_parameter
  Type: string
  Description: This parameter is mine!
  Constraints:
ros2 param set /minimal_param_node my_parameter earth
Set parameter successful

The node prints Hello earth! once and then Hello world! again. Try ros2 param set /minimal_param_node my_parameter 5. 5 is an integer, the parameter is a string, so the node refuses it. You can also set the parameter at start-up: ros2 run python_parameters minimal_param_node --ros-args -p my_parameter:=mars.

Change it in a launch file

mkdir ~/ros2_ws/src/python_parameters/launch && nano ~/ros2_ws/src/python_parameters/launch/python_parameters_launch.py
python_parameters/launch/python_parameters_launch.pyfrom launch import LaunchDescription
from launch_ros.actions import Node


def generate_launch_description():
    return LaunchDescription([
        Node(
            package='python_parameters',
            executable='minimal_param_node',
            name='custom_minimal_param_node',
            output='screen',
            emulate_tty=True,
            parameters=[
                {'my_parameter': 'earth'}
            ]
        )
    ])

The launch file must be installed. At the top of setup.py, add import os and from glob import glob, and add this line to data_files:

        (os.path.join('share', package_name, 'launch'), glob('launch/*')),
cd ~/ros2_ws && colcon build --packages-select python_parameters && source install/setup.bash
ros2 launch python_parameters python_parameters_launch.py
[INFO] [launch]: All log files can be found below /home/ubuntu/.ros/log/…
[INFO] [launch]: Default logging verbosity is set to INFO
[INFO] [minimal_param_node-1]: process started with pid [6755]
[minimal_param_node-1] [INFO] [1792937532.187000000] [custom_minimal_param_node]: Hello earth!
[minimal_param_node-1] [INFO] [1792937533.191000000] [custom_minimal_param_node]: Hello world!

On the robot: the obstacle avoider of step 4 already has two parameters. While it drives, run ros2 param set /obstacle_avoider speed 0.3 or ros2 param set /obstacle_avoider stop_distance 0.8 and watch the robot change its behaviour.

8Getting started with ros2doctor

ros2 doctor checks your ROS 2 setup and the running system:

ros2 doctor
/opt/ros/humble/lib/python3.10/site-packages/ros2doctor/api/topic.py: 42: UserWarning: Publisher without subscriber detected on /battery_state.
/opt/ros/humble/lib/python3.10/site-packages/ros2doctor/api/topic.py: 42: UserWarning: Subscriber without publisher detected on /cmd_vel.
…

All 5 checks passed

The warnings are about topics nobody listens to, or nobody publishes on. On a robot that is normal: nobody needs the battery state at this moment. Start your obstacle avoider and run ros2 doctor again. The warnings for /cmd_vel and /scan are gone. ros2 doctor --report gives the full report: network, platform, middleware, ROS 2 version and all topics.

9Extra: drive with actions

The official beginner tutorials stop here. The TurtleBot is driven mostly with actions (tutorial 7, step 5), so here is how to use them from Python. ActionClient sends a goal and gets feedback and a result. This node drives a square with /drive_distance and /rotate_angle:

turtlebot_py/turtlebot_py/square_actions.pyimport math

from irobot_create_msgs.action import DriveDistance, RotateAngle
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node


class SquareDriver(Node):

    def __init__(self):
        super().__init__('square_actions')
        self.drive = ActionClient(self, DriveDistance, 'drive_distance')
        self.rotate = ActionClient(self, RotateAngle, 'rotate_angle')

    def feedback(self, msg):
        self.get_logger().info('remaining: %.2f m' % msg.feedback.remaining_travel_distance,
                               throttle_duration_sec=1.0)

    def run(self, side):
        self.drive.wait_for_server()
        for i in range(4):
            goal = DriveDistance.Goal(distance=side, max_translation_speed=0.25)
            result = self.drive.send_goal(goal, feedback_callback=self.feedback)
            self.get_logger().info('side %d done (status %d)' % (i + 1, result.status))
            self.rotate.send_goal(RotateAngle.Goal(angle=math.pi / 2, max_rotation_speed=1.0))
        self.get_logger().info('square done')


def main():
    rclpy.init()
    node = SquareDriver()
    node.run(0.4)
    node.destroy_node()
    rclpy.shutdown()

Entry point: 'square = turtlebot_py.square_actions:main',.

[INFO] [1792937931.270000000] [square_actions]: remaining: 0.40 m
[INFO] [1792937932.510000000] [square_actions]: remaining: 0.26 m
[INFO] [1792937934.423000000] [square_actions]: side 1 done (status 4)
…
[INFO] [1792937950.204000000] [square_actions]: square done

Status 4 means SUCCEEDED, 6 means ABORTED (for example after a bump), and 5 means CANCELED.

10What is simulated, and what not

Everything you learned here works the same way on a computer with ROS 2 Humble, and on a real TurtleBot 4.

11Cheat sheet

GoalCommand
Workspacemkdir -p ~/ros2_ws/src && cd ~/ros2_ws
New Python packageros2 pkg create --build-type ament_python --license Apache-2.0 [--node-name N] [--dependencies …] PKG
New interface packageros2 pkg create --build-type ament_cmake --license Apache-2.0 PKG
Dependenciesrosdep install -i --from-path src --rosdistro humble -y
Buildcolcon build [--symlink-install] [--packages-select PKG]
Testcolcon test · colcon test-result --verbose
Use the workspacesource install/setup.bash (in every new terminal)
Runros2 run PKG EXE [args] [--ros-args -p name:=value] · ros2 launch PKG FILE · python3 file.py
Executables of a packageros2 pkg executables PKG (from entry_points in setup.py)
Interfacesros2 interface show T · ros2 interface package PKG
Check the systemros2 doctor [--report]
Filesnano · cat · tree · grep · wget · git clone

Open the TurtleBot 4 with the terminal →