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 →
- The terminal works as in tutorial 7. + opens another terminal tab, Ctrl+C stops a program, and Tab completes commands and file names.
- Editing files:
nano file.pyopens a small editor. Tab inserts four spaces, Enter keeps the indentation, Ctrl+S saves and Ctrl+X closes. To paste a whole file, click the copy icon next to the code on this page, press Ctrl+A in the editor and paste with Ctrl+V. - Python: the first time a Python program runs, the terminal starts Python in your browser. That takes a few seconds. After that it is fast. This works best in a recent Chrome or Edge.
- Your workspace is saved in your browser, and is still there when you come back.
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:
build: intermediate files, one folder per packageinstall: the built packages, and thesetup.bashyou sourcelog: what happened during each build
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()
MinimalPublisheris a node: it inherits fromNodeand gives itself the nameminimal_publisher.create_publisher(String, 'topic', 10): it publishesstd_msgs/msg/Stringmessages on/topic. The 10 is the queue size, a Quality of Service setting.create_timer(0.5, self.timer_callback)callstimer_callbackevery half second.rclpy.spin()keeps the node alive and runs its callbacks. Without spin, no callback runs.
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
- Your Python code really runs, with Python 3.13 compiled to WebAssembly (Pyodide).
rclpyis a version written for this simulator, with the same API as Humble's rclpy for nodes, topics, services, parameters, timers, logging, executors and action clients. Error messages and tracebacks match the real ones as closely as possible. - C++ packages (
ament_cmakewithadd_executable) build, but do not run. Interface packages work fully. The C++-only beginner tutorials (Implementing custom interfaces and plugins) are not part of this page. colconruns yoursetup.pyin Python, checksCMakeLists.txtand the interface files like rosidl does, and installs intoinstall/.colcon testruns a subset of flake8: line length, trailing spaces, quotes and tabs.git cloneworks forhttps://github.com/ros2/examples.wgetandcurldownload from sites that allow it, such as raw.githubusercontent.com.pipdoes not work, butnumpyand a few other packages load when a node imports them.- An action server in Python is not supported. Action clients work with the robot's actions.
- A
while True:loop withoutrclpy.spin,spin_onceortime.sleepfreezes the page, because Python and the page share one thread. Reload the page to recover; your files are kept.
Everything you learned here works the same way on a computer with ROS 2 Humble, and on a real TurtleBot 4.
11Cheat sheet
| Goal | Command |
|---|---|
| Workspace | mkdir -p ~/ros2_ws/src && cd ~/ros2_ws |
| New Python package | ros2 pkg create --build-type ament_python --license Apache-2.0 [--node-name N] [--dependencies …] PKG |
| New interface package | ros2 pkg create --build-type ament_cmake --license Apache-2.0 PKG |
| Dependencies | rosdep install -i --from-path src --rosdistro humble -y |
| Build | colcon build [--symlink-install] [--packages-select PKG] |
| Test | colcon test · colcon test-result --verbose |
| Use the workspace | source install/setup.bash (in every new terminal) |
| Run | ros2 run PKG EXE [args] [--ros-args -p name:=value] · ros2 launch PKG FILE · python3 file.py |
| Executables of a package | ros2 pkg executables PKG (from entry_points in setup.py) |
| Interfaces | ros2 interface show T · ros2 interface package PKG |
| Check the system | ros2 doctor [--report] |
| Files | nano · cat · tree · grep · wget · git clone |