How to Add a Python ROS2 Node to a C++ ROS 2 Package

In this tutorial, I will show you how to add a Python ROS 2 node to a C++ package. We will go through the entire process, step-by-step.

Let’s say you have a ROS 2 package that is a standard C++ ROS 2 package. What this means is that it is of the ament_cmake (CMake) build type. You can verify this by going to the package.xml file inside your package and checking the <buildtool_depend> tag.

For example, the name of my workspace is dev_ws, and I have a package named two_wheeled_robot inside that package. I go to my terminal window, and type:

cd ~/dev_ws/src/two_wheeled_robot
gedit package.xml

Here is what I see:

1-build-tool

I have a Python node that I created called lift_controller.py, and I want to add it to my package. I am using this Python node to lift the platform on top of a mobile robot that I want to visualize in RViz.

Create the Python Node

The first thing I am going to do is to write my Python node. I want to create this code inside a folder named scripts.

cd ~/dev_ws/src/two_wheeled_robot 
mkdir scripts
cd scripts
gedit lift_controller.py

Write your Python code inside that file. Here is my code (don’t worry about trying to understand the code…this is just a demo):

#!/usr/bin/env python3 

# Test code for controlling the lift mechanism on a warehouse robot
# Author: Addison Sears-Collins
# Website: https://automaticaddison.com

# ROS Client Library for Python
import rclpy
 
# Handles the creation of nodes
from rclpy.node import Node
 
# Enables usage of the standard message
import std_msgs.msg

# Enables use of sensor messages
import sensor_msgs.msg 

 
class LiftController(Node):
  """
  Create a LiftController class, which is a subclass of the Node class.
  """
  def __init__(self):
    """
    Class constructor to set up the node
    """
    # Initiate the Node class's constructor and give it a name
    super().__init__('lift_controller')
     
    # Create the publisher. This publisher will publish a JointState message
    # to a topic. The queue size is 10 messages.
    self.publisher_ = self.create_publisher(sensor_msgs.msg.JointState, 'joint_states', 10)

    # Create the subscriber. This subscriber will subscribe to a JointState message
    self.subscription = self.create_subscription(sensor_msgs.msg.JointState, 'joint_states', self.listener_callback, 10)
         
  def listener_callback(self, msg):
    """
    Callback function.
    """
    # Create a JointStates message
    new_msg = sensor_msgs.msg.JointState()
 
    # Set the message's data
    new_msg.header.stamp = self.get_clock().now().to_msg()
    new_msg.name = msg.name
    new_msg.position = msg.position
    new_msg.position[2] = 0.30 
     
    # Publish the message to the topic
    self.publisher_.publish(new_msg)
     
def main(args=None):
 
  # Initialize the rclpy library
  rclpy.init(args=args)
 
  # Create the node
  lift_controller = LiftController()
 
  # Spin the node so the callback function is called.
  rclpy.spin(lift_controller)
 
  # Destroy the node explicitly
  # (optional - otherwise it will be done automatically
  # when the garbage collector destroys the node object)
  lift_controller.destroy_node()
 
  # Shutdown the ROS client library for Python
  rclpy.shutdown()
 
if __name__ == '__main__':
  main()

Save the file and close it.

Make sure this shebang line is on the first line of your Python node.

#!/usr/bin/env python3

Make the file executable using the following command.

chmod +x lift_controller.py

Set Up the Package

Now we need to set up the package so that it can build Python executables.

First, we need to create a folder for any Python libraries or modules that we want to import into our Python node. This folder needs to have an empty __init__.py file inside it. It will also have the same name as the package.

cd ~/dev_ws/src/two_wheeled_robot 
mkdir two_wheeled_robot
touch two_wheeled_robot/__init__.py

If you have a module that you want to import into your Python node, you can place it inside the folder as follows.

touch two_wheeled_robot/module_to_import.py

If you want to import that module into your Python node, you would write this at the top of your Python node:

from two_wheeled_robot.module_to_import import ...

Here is an example folder tree of what my two_weeled_robot package looks like.

two_wheeled_robot/
# --> package information, configuration, and compilation
├── CMakeLists.txt
├── package.xml
# --> Python contents
├── two_wheeled_robot
│   ├── __init__.py
│   └── module_to_import.py
├── scripts
│   └──  lift_controller.py
# --> C++ contents
├── include
│   └── two_wheeled_robot
│       └── cpp_node.hpp
└── src
    └── cpp_node.cpp

Update the Package.xml File

Here is my package.xml file. You will see that I have added the ament_cmake_python build tool. I have also added dependencies, including the ROS2 Python library (rclpy).

<?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>two_wheeled_robot</name>
  <version>1.0.0</version>
  <description>A two-wheeled mobile robot with an IMU sensor and LIDAR</description>
  <maintainer email="automaticaddison@todo.todo">AutomaticAddison</maintainer>
  <license>MIT License</license>

  <buildtool_depend>ament_cmake</buildtool_depend>
  <buildtool_depend>ament_cmake_python</buildtool_depend>

  <exec_depend>rclcpp</exec_depend>
  <exec_depend>rclpy</exec_depend>
  <exec_depend>std_msgs</exec_depend>
  <exec_depend>sensor_msgs</exec_depend>
  <exec_depend>joint_state_publisher</exec_depend>
  <exec_depend>robot_state_publisher</exec_depend>
  <exec_depend>rviz</exec_depend>
  <exec_depend>xacro</exec_depend>
  <test_depend>ament_lint_auto</test_depend>
  <test_depend>ament_lint_common</test_depend>

  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>

Update the CMakeLists.txt File

Here is my CMakeLists.txt File.

cmake_minimum_required(VERSION 3.5)
project(two_wheeled_robot)

# Default to C99
if(NOT CMAKE_C_STANDARD)
  set(CMAKE_C_STANDARD 99)
endif()

# Default to C++14
if(NOT CMAKE_CXX_STANDARD)
  set(CMAKE_CXX_STANDARD 14)
endif()

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

# Find dependencies
find_package(ament_cmake REQUIRED)
find_package(ament_cmake_python REQUIRED)
find_package(rclcpp REQUIRED)
find_package(rclpy REQUIRED)
# uncomment the following section in order to fill in
# further dependencies manually.
# find_package(<dependency> REQUIRED)

# Include cpp "include" directory
include_directories(include)

install(
  DIRECTORY config include launch maps media meshes models params rviz scripts src two_wheeled_robot urdf worlds
  DESTINATION share/${PROJECT_NAME}
)

# Install Python modules
ament_python_install_package(${PROJECT_NAME})

# Install Python executables
install(PROGRAMS
  scripts/lift_controller.py
  DESTINATION lib/${PROJECT_NAME}
)

if(BUILD_TESTING)
  find_package(ament_lint_auto REQUIRED)
  # the following line skips the linter which checks for copyrights
  # uncomment the line when a copyright and license is not present in all source files
  #set(ament_cmake_copyright_FOUND TRUE)
  # the following line skips cpplint (only works in a git repo)
  # uncomment the line when this package is not in a git repo
  #set(ament_cmake_cpplint_FOUND TRUE)
  ament_lint_auto_find_test_dependencies()
endif()

ament_package()

I have added dependencies for ament_cmake_python, rclpy, etc.

I added the path to the include directories in case I want to create C++ header files later on. 

I include the extra folders in the directory.

I install Python modules.

I then provide the path to the Python executables.

If you have C++ nodes, these executables would be located under the include_directories(include) line.

# Create Cpp executable
add_executable(cpp_executable src/cpp_node.cpp)
ament_target_dependencies(cpp_executable rclcpp)

# Install Cpp executables
install(TARGETS
  cpp_executable
  DESTINATION lib/${PROJECT_NAME}
)

Build and Run the Node

To build the node, go back to the root of your workspace.

cd ~/dev_ws/

Build the package.

colcon build

Now, open a new terminal window, and run the node.

ros2 run two_wheeled_robot lift_controller.py

To see active ROS 2 topics, you can open a new terminal window and type:

ros2 topic list

References

This blog post over at RoboticsBackend was very helpful.

How to Convert a Xacro File to URDF and Then to SDF

If you are using ROS and Gazebo and have a URDF robot file that has Xacro in it (i.e. XML macros) like this one, you can convert this file to a pure URDF file in Ubuntu Linux using the following command (everything below goes on the same line inside the Linux terminal window):

xacro two_wheeled_robot.xacro > two_wheeled_robot.urdf

You can then convert the URDF file without Xacro into an SDF file if you wish. Here is the command to do that:

gz sdf -p two_wheeled_robot.urdf > two_wheeled_robot.sdf

That’s it!

How to Load a URDF File into RViz – ROS 2

In this tutorial, I will show you how to load a URDF File into RViz. Universal Robot Description Format (URDF) is the standard ROS format for robot modeling.

Prerequisites

  • ROS 2 Foxy Fitzroy installed on Ubuntu Linux 20.04 
    • If you are using another ROS 2 distribution, you will need to replace ‘foxy’ with the name of your distribution everywhere I mention ‘foxy’ in this tutorial. 
    • I highly recommend you get the newest version of ROS 2. If you are using a newer version of ROS 2, you can still follow all the steps in this tutorial.
  • You have already created a ROS 2 workspace. The name of our workspace is “dev_ws”, which stands for “development workspace.”

You can find the entire code for this project here on my Google Drive.

What is URDF?

A URDF (Universal Robot Description Format) file is an XML file that describes what a robot should look like in real life. It contains the complete physical description of the robot.

You can learn more about URDF files here.

Install Important ROS 2 Packages

First, we need to install some important ROS 2 packages that we will use in this tutorial. Open a new terminal window, and type the following commands, one right after the other.

sudo apt install ros-foxy-joint-state-publisher-gui
sudo apt install ros-foxy-xacro

The format of the commands above is:

sudo apt install ros-<ros2-distro>-joint-state-publisher-gui
sudo apt install ros-<ros2-distro>-xacro

You will need to replace <ros2-distro> with the ROS 2 distribution you are using. In this case, I am using ROS 2 Foxy Fitzroy, which is ‘foxy’ for short.

Create a ROS 2 Package

Let’s create a ROS 2 package inside our workspace. My workspace is named dev_ws.

In a new terminal window, move to the src (source) folder of your workspace.

cd ~/dev_ws/src

Now create the package using the following command.

ros2 pkg create --build-type ament_cmake two_wheeled_robot

Create Extra Folders

Move inside the package.

cd ~/dev_ws/src/two_wheeled_robot/

Create some extra folders with the following names. We will not use all of these folders. I just like to have these folders in every package I create in case I need to use them.

mkdir config launch maps meshes models params rviz urdf worlds

Type the following command to verify the folders were created.

dir

Now build the package by typing the following command:

cd ~/dev_ws
colcon build

Let’s add a script to our bashrc file which enables us to use the following command to move to the package from any directory inside our terminal window.

colcon_cd two_wheeled_robot

Where ‘cd’ means “change directory”.

Note that in ROS 1, we typed roscd to change directories. In ROS 2, we use the colcon_cd command instead of roscd.

Open a terminal window, and type the following commands, one right after the other:

echo "source /usr/share/colcon_cd/function/colcon_cd.sh" >> ~/.bashrc
echo "export _colcon_cd_root=~/dev_ws" >> ~/.bashrc

Now open a new terminal window.

To go directly to the basic_mobile_robot ROS 2 package, type:

colcon_cd two_wheeled_robot

This command above will get you straight to the two_wheeled_robot package from within any directory in your Linux system. The format is:

colcon_cd [ROS 2 package name]

Create the URDF File

In this section, we will build our mobile robot step-by-step. Our robot will be defined in a Universal Robot Descriptor File (URDF), the XML file that represents a robot model.

Open a new terminal window, and type:

colcon_cd two_wheeled_robot
cd urdf

Make sure you have the Gedit text editor installed.

sudo apt-get install gedit

Create a new file named two_wheeled_robot.urdf.

gedit two_wheeled_robot.urdf

Inside this file, we define what our robot will look like (i.e. visual properties), how the robot will behave when it bumps into stuff (i.e. collision properties), and its mass (i.e. inertial properties). 

Type this code inside the URDF file.

Save and close the file.

Now go to the meshes folder.

cd ~/dev_ws/src/two_wheeled_robot/meshes

Add this STL file to your meshes folder. A mesh is a file that allows your robot to look more realistic (rather than just using basic shapes like boxes and spheres).

Rather than use the STL files I provided, you can use a program like SolidWorks to generate your own STL files. You can also use a program called Blender to create DAE files. URDF files support meshes in either STL or DAE format.

Add Dependencies

Let’s add some packages that our project will depend on. Go to the package.xml file.

cd ~/dev_ws/src/two_wheeled_robot/

OR

colcon_cd two_wheeled_robot 
gedit package.xml

After the <buildtool_depend> tag, add the following lines:

<exec_depend>joint_state_publisher</exec_depend>
<exec_depend>robot_state_publisher</exec_depend>
<exec_depend>rviz</exec_depend>
<exec_depend>xacro</exec_depend>

Save the file, and close it.

1-package_xml

We will be using the Joint State Publisher and the Robot State Publisher. We will also be using RViz to visualize our robot model.

Create the Launch File

Let’s create a launch file. Open a new terminal window, and type:

colcon_cd two_wheeled_robot
cd launch
gedit two_wheeled_robot.launch.py

Copy and paste this code into the file.

# Author: Addison Sears-Collins
# Date: September 14, 2021
# Description: Launch a two-wheeled robot URDF file using Rviz.
# https://automaticaddison.com

import os
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.conditions import IfCondition, UnlessCondition
from launch.substitutions import Command, LaunchConfiguration
from launch_ros.actions import Node
from launch_ros.substitutions import FindPackageShare

def generate_launch_description():

  # Set the path to this package.
  pkg_share = FindPackageShare(package='two_wheeled_robot').find('two_wheeled_robot')

  # Set the path to the RViz configuration settings
  default_rviz_config_path = os.path.join(pkg_share, 'rviz/rviz_basic_settings.rviz')

  # Set the path to the URDF file
  default_urdf_model_path = os.path.join(pkg_share, 'urdf/two_wheeled_robot.urdf')

  ########### YOU DO NOT NEED TO CHANGE ANYTHING BELOW THIS LINE ##############  
  # Launch configuration variables specific to simulation
  gui = LaunchConfiguration('gui')
  urdf_model = LaunchConfiguration('urdf_model')
  rviz_config_file = LaunchConfiguration('rviz_config_file')
  use_robot_state_pub = LaunchConfiguration('use_robot_state_pub')
  use_rviz = LaunchConfiguration('use_rviz')
  use_sim_time = LaunchConfiguration('use_sim_time')

  # Declare the launch arguments  
  declare_urdf_model_path_cmd = DeclareLaunchArgument(
    name='urdf_model', 
    default_value=default_urdf_model_path, 
    description='Absolute path to robot urdf file')
    
  declare_rviz_config_file_cmd = DeclareLaunchArgument(
    name='rviz_config_file',
    default_value=default_rviz_config_path,
    description='Full path to the RVIZ config file to use')
    
  declare_use_joint_state_publisher_cmd = DeclareLaunchArgument(
    name='gui',
    default_value='True',
    description='Flag to enable joint_state_publisher_gui')
  
  declare_use_robot_state_pub_cmd = DeclareLaunchArgument(
    name='use_robot_state_pub',
    default_value='True',
    description='Whether to start the robot state publisher')

  declare_use_rviz_cmd = DeclareLaunchArgument(
    name='use_rviz',
    default_value='True',
    description='Whether to start RVIZ')
    
  declare_use_sim_time_cmd = DeclareLaunchArgument(
    name='use_sim_time',
    default_value='True',
    description='Use simulation (Gazebo) clock if true')
   
  # Specify the actions

  # Publish the joint state values for the non-fixed joints in the URDF file.
  start_joint_state_publisher_cmd = Node(
    condition=UnlessCondition(gui),
    package='joint_state_publisher',
    executable='joint_state_publisher',
    name='joint_state_publisher')

  # A GUI to manipulate the joint state values
  start_joint_state_publisher_gui_node = Node(
    condition=IfCondition(gui),
    package='joint_state_publisher_gui',
    executable='joint_state_publisher_gui',
    name='joint_state_publisher_gui')

  # Subscribe to the joint states of the robot, and publish the 3D pose of each link.
  start_robot_state_publisher_cmd = Node(
    condition=IfCondition(use_robot_state_pub),
    package='robot_state_publisher',
    executable='robot_state_publisher',
    parameters=[{'use_sim_time': use_sim_time, 
    'robot_description': Command(['xacro ', urdf_model])}],
    arguments=[default_urdf_model_path])

  # Launch RViz
  start_rviz_cmd = Node(
    condition=IfCondition(use_rviz),
    package='rviz2',
    executable='rviz2',
    name='rviz2',
    output='screen',
    arguments=['-d', rviz_config_file])
  
  # Create the launch description and populate
  ld = LaunchDescription()

  # Declare the launch options
  ld.add_action(declare_urdf_model_path_cmd)
  ld.add_action(declare_rviz_config_file_cmd)
  ld.add_action(declare_use_joint_state_publisher_cmd)
  ld.add_action(declare_use_robot_state_pub_cmd)  
  ld.add_action(declare_use_rviz_cmd) 
  ld.add_action(declare_use_sim_time_cmd)

  # Add any actions
  ld.add_action(start_joint_state_publisher_cmd)
  ld.add_action(start_joint_state_publisher_gui_node)
  ld.add_action(start_robot_state_publisher_cmd)
  ld.add_action(start_rviz_cmd)

  return ld

Save the file, and close it.

You can read more about Launch files here.

Add the RViz Configuration File

Let’s add a configuration file that will initialize RViz with the proper settings so we can view the robot as soon as RViz launches.

Open a new terminal window. Type:

colcon_cd two_wheeled_robot
cd rviz
gedit rviz_basic_settings.rviz

Add this code.

Save the file, and close it.

Build the Package

Now we need to build the package. Open a new terminal window, and type:

colcon_cd two_wheeled_robot
gedit CMakeLists.txt

Add the following snippet to CMakeLists.txt file above the if(BUILD_TESTING) line.

install(
  DIRECTORY config launch maps meshes models params rviz src urdf worlds
  DESTINATION share/${PROJECT_NAME}
)

Save the file and close it.

2-cmakelists

Build the project.

cd ~/dev_ws/
colcon build

In the future, if you would like to build the two_wheeled_robot package only, you can type:

colcon build --packages-select two_wheeled_robot

Launch the Robot in RViz

Open a new terminal, and launch the robot.

cd ~/dev_ws/
ros2 launch two_wheeled_robot two_wheeled_robot.launch.py
3-mobile-robot-1

——

By the way, if you want to see the available arguments you can pass to the launch file from the terminal window, type:

ros2 launch -s two_wheeled_robot two_wheeled_robot.launch.py
4-show-options

And if you want to set the value of an argument (e.g. launch the robot without RViz), you can do something like this:

ros2 launch two_wheeled_robot two_wheeled_robot.launch.py use_rviz:='False'

——

Here is the output after we launch the robot in RViz:

The joint state publisher GUI has sliders that enable you to move the wheels.

If something goes wrong with the launch, close everything down, restart Linux, and launch again.

Check whether you have properly set up the collision properties by enabling Collision Enabled under RobotModel on the left pane.

You can see in the image that I have modeled the base_link as a box for collisions. I could have just as easily used the STL model itself (which was used for visualization) as the collision geometry, but I decided to go with the simpler box representation to give me more flexibility to alter the collision areas if necessary. Also, Gazebo (a popular robotics simulator) recommends using simpler collision geometry to increase performance.

5-collision-enabled

View the Coordinate Frames

Let’s see the coordinate frames. We first need to install the necessary software.

sudo apt install ros-foxy-tf2-tools

Check out the coordinate frames.

colcon_cd two_wheeled_robot
ros2 run tf2_tools view_frames.py

As of ROS Galactic, this command is:

ros2 run tf2_tools view_frames

In the current working directory, you will have a file called frames.pdf. Open that file.

evince frames.pdf

Here is what my coordinate transform (i.e. tf) tree looks like:

6-frames-pdf

If we want to see the coordinate transformation from one link to another, we can type the following command. For example, what is the position and orientation of the front caster wheel relative to the base_link of the robot?

The syntax is:

ros2 run tf2_ros tf2_echo <parent frame> <child frame>

We open a terminal window, and type:

ros2 run tf2_ros tf2_echo base_link front_caster

Here is the output. With respect to the base_link reference frame, the front caster wheel is located at (x=0.217 meters, y=0 meters, and z=-0.1 meters). 

7-tf2-echo

The rotation value is in quaternion format. You can see that the rotation values are all 0s, indicating that the orientation of both coordinate frames is equivalent (i.e. the x, y, and z axes of both coordinate frames point in the same direction).

Remember that, by convention:

  • x-axis is red
  • y-axis is green
  • z-axis is blue 

To see an image of the architecture of our ROS system, open a new terminal window, and type the following command:

rqt_graph

Select the “Nodes/Topics (all) option in the upper-left part of the screen, and then click the refresh arrow right next to it.

8-rqt-graph

You can see how the joint state publisher GUI manipulates the joint states (i.e. the angle of each wheel joint). This data feeds into the robot state publisher. The robot state publisher publishes the coordinate frame relationships to tf, and the updated robot model feeds into RViz, the robot model visualization program.

That’s it! 

When you’re done, press CTRL+C in all terminal windows to shut everything down.