Working with ROS2 packages: Part 2

27 Feb 2021 - John Z. Li

Recall that ROS in itself is nothing but a collection of libraries that are arranged to work together. The so called client libraries are provided to users to develop their own ROS packages. This is ROS’s way to call language bindings to their core libraries. Two language bindings, that of C++ and Python are officially supported, although there are community contributed bindings to other languages.

The client libraries for C++ is rclcpp, that is short for “ROS client libraries for CPP”. And the client libraries for Python is called rclpy (ROS client libraries for Python). Both of which are built on top of RCL, that is “ROS core libraries”, which implements features that are supposed to be language-agnostic. The RCL exposes its interfaces via C ABI, and client libraries are actually a thin wrapper of the RCL.

Exchange data between ROS nodes.

Basic data types

In order to different ROS nodes can communicate with each other, they have to agree on the message formats they use. Since ROS2 uses DDS/RTPS to pass information among nodes. (DDS is short for “Data Distribution Service” and RTPS, Real Time Publish Subscribe Protocol, is the wire protocol on which DDS is built on.

DDS defines many commonly encountered data types and how they are serialize and de-serialized. To define a message that can be consumed by DDS, one must specify the type of data that is to be transferred among nodes according to DDS specification. ROS add a layer of indirection by introducing a intermediate representation language to specify the format of the data in a language-agnostic way. The language is called the Interface Definition Language (IDL). The types of IDL is mapped to those of DDS as below:

|Types of IDL            |Types of DDS        |example                   |
|------------------------|--------------------|--------------------------|
|bool                    |boolean             |bool flip_image           |
|byte/int8/uint8         |octet               |byte rx                   |
|char                    |char                |char id                   |
|float32                 |float               |float num                 |
|float64                 |double              |float64 tol               |
|int16                   |short               |int16 count               |
|uint16                  |unsigned short      |uint16 length             |
|int32                   |long                |int32 rc                  |
|uint32                  |unsigned long       |uint32 iteration          |
|int64                   |long long           |int64 key                 |
|uint64                  |unsigned long Long  |uint64 mul                |
|string (unbounded)      |string              |string s                  |
|wstring (unbounded)     |wstring             |wstring user_name         |
|string<=N (bounded)     |string              |string<=10 small_str      |
|T[] (unbounded array)   |sequence            |char[] c_str              |
|T[N] (static array)     |T[N]                |float[4] d4               |
|T[<=N] (bounded array)  |sequence<T,N>       |string[<=5] array_of_str  |

For example, the following means an array of string whose length is less or equal than 10, and each string has 20 wchars at most.

wstring<=20[<=10] array_of_wstr

The IDL also has restrictions on the name of variables. It is required that the name of variables only contains lower case characters of “a-z” and numerical characters “0-9”. A name must start with a non-numerical character, underscore can be used but consecutive underscores or underscore as the last character is forbidden.

Language-wise, if you are using C++, you might want to alias bool to uint8 to avoid the problems caused by the specialization of std::vector<bool>.

Another thing to notice that a type Time is is simply an alias of uint32, and a type of Duration is simply an alias of int32. Their units are unfortunately dependent on the context. They are both defined in package “builtin_interfaces”.

Constants, default value and comments

It is possible to specify the default value of a variable by adding a tailing literal to its definition, for example

# variable count is of type int32 with default value being 1000
int32 count 1000
# variable name if of type "bounded string with no more than 20 characters,
# and its default value is "John Z. Li".
string<=20 name "John Z. Li"
# The above is equivalent to
string<=20 name 'John Z. Li'
int a[<=4] [1, 2, 3, 4]

Notice that string literals are not C/C++ string literals. They are not allowed to contain escaped characters as of now. Also array literals are enclosed by square brackets and elements separated by commas. Comment lines are started by the “sharp(#)” symbol.

Besides setting default values, one can also define constants. Examples are as below (using the “equal(=)” sign:

int32 COUNT=1000
string NAME="John Z. Li"

Notice that names of constants must be of upper case.

Three types of description files.

The purpose of the IDL is to define data types used for message passing between ROS nodes in plain text files, so that corresponding language bindings can be generated automatically. There are three types of description file in ROS2:

Concerning the directory layout, msg files are required to be located in a directory with the name msg/ in the root directory of a package, srv files are required to be located in a directory with the name srv/ in the root directory of a package, and action files are required to be located in a directory with the name action in the root directory of a package. As a good practice, instead of having a directory structure as below (C++ supposed):

/workspace_folder/src/my_cpp_ros_project
├── CMakeLists.txt
├── include
│   └── my_cpp_ros_project
├── package.xml
└── src
└── msg
└── srv
└── action

A better option is to wrap all the description files in a separate folder, and the directory layout will be like:

/workspace_folder/src/my_cpp_ros_project
├── my_package
	├── CMakeLists.txt
	├── include
	│	└── my_package
	├── package.xml
	├── src
├── my_msgs
	└── msg
	└── srv
	└── action
	├── CMakeLists.txt
	├── package.xml

By wrapping all the communication data in a separate package, one can expand the above ROS2 project horizontally by adding other packages in the “my_cpp_ros_project” folder, each package of which can also use the message definitions in the “my_msgs” package. Of course, one should give meaningful names to those projects in real projects.

The file format of msg files.

A msg file consists of IDL definitions separated by line break. For example, The BehaviorTreeStatusChange.msg defined in the “navigation2” project as below:

builtin_interfaces/Time timestamp  # internal behavior tree event timestamp. Typically this is wall clock time
string node_name
string previous_status              # IDLE, RUNNING, SUCCESS or FAILURE
string current_status               # IDLE, RUNNING, SUCCESS or FAILURE

Note that instead of creating your own msg types, one should first check that whether the types he wants have already been defined in some well known ROS packages. It is a good practice to use data types that are shared by the community, so that interfacing with packages written by other people will not need unnecessary castings. The package std_msgs github link is well known, which defines commonly used data structure such as multiarray types, message header types, and types of empty messages. (Note that it is not a good idea to use the Time and Duration types defined in this package, because those two types are now officially defined in package builtin_interfaces in the project “rcl_interfaces” github link. As its name suggests, it is supposed to be part of the ROS core libraries. It contains type definitions of messages (of the types msg, srv and action) that are common to all ROS developers. Especially, one should pay attention to the rcl_interfaces package. If you are dealing with ROS parameters or logging, you are going to need this package. Another relevant project is the “common_msgs”, it github link contain message type definitions for sensor data and navigation, and geometric types on which the former two kinds are built. Obviously, if you are developing navigation algorithms, you should first check whether what you need has already been defined in it.

The file format of srv files.

Recall that a ROS service consists of a request and a reply. Thus, a srv file contains two parts, one for the request message, one for the reply message. The two parts are separated by three dashed (that is, —). For example, the GetCostmap service in package “navigation2” is defined as

# Get the costmap

# Specifications for the requested costmap
nav2_msgs/CostmapMetaData specs
---
nav2_msgs/Costmap map

The two projects, “rcl_interfaces” and “common_msgs” also contain commonly useful definition of services.

The file format of action files.

Recall that a ROS action is like a service except that it usually takes some time to complete and it can be canceled before the action is completed. Besides the request and the result, there is also continuous stream of feedback messages so that the action client can know how the action is going. Thus, a action file consists three parts separated by three dashes (that is, —). For example, the FollowWaypoints action in package “navigation2” is defined as

#goal definition
geometry_msgs/PoseStamped[] poses
---
#result definition
int32[] missed_waypoints
---
#feedback
uint32 current_waypoint

Naming style

Note that all the type definitions of msg, srv and action follow the Camel case style. It is unclear whether this is required by ROS, or is just a convention.

Relevant configuration in CMakeLists.txt and package.xml.

After defining your own msg, srv and actionfiles, you need to add relevant settings in the CMakeLists.txt file and package.xml file so that the building system can find dependencies for them and generate corresponding language specific code.

  <buildtool_depend>ament_cmake</buildtool_depend>
  <buildtool_depend>rosidl_default_generators</buildtool_depend>
  <depend>action_msgs</depend>
  <depend>builtin_interfaces</depend>
  <depend>std_msgs</depend>
  <exec_depend>rosidl_default_runtime</exec_depend>
  <test_depend>ament_lint_common</test_depend>
  <member_of_group>rosidl_interface_packages</member_of_group>

Specify Quality of Service (QoS) of messages

since ROS2 use DDS/RTPS to deliver messages between nodes, how a type of message should be delivered becomes configurable. The main tradeoff is between reliability and delay. Generally, if you require that a type of message must be delivered to the subscribers no matter what (otherwise it is considered a fatal error of the system), it is expected that additional delay might appear with some messages. On the other hand, if you require that a stream of messages is sent in a realtime fashion, and occasionally losing some pieces of information is tolerable, you will want to choose a delivery policy that minimize the delay at the cost of being less reliable. There are also many middle-ground configurations. The configuration w.r.t. a type of message is called its Quality of Service, aka QoS.

Configurable options of QoS

All these options have default values. One should check the documentation of the underlying DDS/RTPS used to find them. Generally, if an option denotes a time period, the default value of it will be indefinitely long.

use profiles to simplify QoS settings.

Usually a user does not need all the configurability provided by the DDS/RTPS middleware. In most cases, we want services to be reliable while sensible to be streamed in realtime. The following three profiles of QoS are most useful in ROS application.

Dealing with QoS constraints violations.

The DDS/RTPS software provide users with mechanics to detect possible violations of QoS constraints in the form of callback functions. The events that might trigger the callback functions are:

The names are quite self-explanatory.

To check that the QoS settings of a robotic system is workable, one can test-run the system along with the libstatistics_collector package github link. This package will create a topic named /statistics to publish lively updated statistics of another topic (of course, by let libstatistics_collector subscribe to the topic that needs to be monitored.).`

An example

Create a ROS workspace and initilize a C++ package inside it, using the method in part 1 of this post. we have a workspace which has the following directory layout.

# folder "src" is in the root of the workspace.
src
└── cpp_pubsub
    ├── CMakeLists.txt
    ├── include
    │   └── cpp_pubsub
    │       └── cpp_pub_sub.hpp
    ├── package.xml
    └── src
        ├── publisher_member_function.cpp
        └── subscriber_member_function.cpp

Few things to notice:

The “publisher_member_function.cpp” file

#include <chrono>
#include <functional>
#include <memory>
#include <string>

#include "rclcpp/publisher.hpp"
#include "rclcpp/qos.hpp"
#include "rclcpp/rclcpp.hpp"
#include "rclcpp/timer.hpp"
#include "std_msgs/msg/string.hpp"
#include "std_msgs/msg/string__struct.hpp"

using namespace std::chrono_literals;

class MinimalPublisher : public rclcpp::Node {
  using MsgString = std_msgs::msg::String;
  using PublisherType = rclcpp::Publisher<MsgString>::SharedPtr;
  using TimerType = rclcpp::TimerBase::SharedPtr;
  using SensorQoS = rclcpp::SensorDataQoS;

public:
  MinimalPublisher(std::string msg_header) : Node{"minimal_publisher"} {

    publisher_ = this->create_publisher<MsgString>("talker_msg", SensorQoS{});
    timer_ =
        this->create_wall_timer(500ms, [this]() { this->timer_callback(); });
    msg_header_ = msg_header;
  }

private:
  void timer_callback() {
    auto message = std_msgs::msg::String();
    message.data = msg_header_ + std::to_string(count_++);
    RCLCPP_INFO(this->get_logger(), "Publishing: '%s'", message.data.c_str());
    publisher_->publish(message);
  }
  TimerType timer_;
  PublisherType publisher_;
  size_t count_ = 0;
  std::string msg_header_ = "Hello World";
};

int main(int argc, char *argv[]) {
  rclcpp::init(argc, argv);
  if (argc == 2)
    rclcpp::spin(std::make_shared<MinimalPublisher>(argv[1]));
  else
    rclcpp::spin(std::make_shared<MinimalPublisher>("Hello world"));
  rclcpp::shutdown();
  return 0;
}

The “subscriber_member_function.cpp” file

#include <functional>
#include <memory>

#include "rclcpp/rclcpp.hpp"
#include "rclcpp/subscription.hpp"
#include "std_msgs/msg/string.hpp"
#include "std_msgs/msg/string__struct.hpp"

using std::placeholders::_1;

class MinimalSubscriber : public rclcpp::Node {
  using MsgString = std_msgs::msg::String;
  using MsgType = MsgString::SharedPtr;
  using SubscriptionType = rclcpp::Subscription<MsgString>::SharedPtr;
  using SensorQoS = rclcpp::SensorDataQoS;

public:
  MinimalSubscriber() : Node("minimal_subscriber") {

    subscription_ = this->create_subscription<MsgString>(
        "talker_msg", SensorQoS{},
        [this](const MsgType msg) { this->topic_callback(msg); });
  }

private:
  void topic_callback(const MsgType msg) const {
    RCLCPP_INFO(this->get_logger(), "I heard: '%s'", msg->data.c_str());
  }
  SubscriptionType subscription_;
};

int main(int argc, char *argv[]) {
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<MinimalSubscriber>());
  rclcpp::shutdown();
  return 0;
}

the “package.xml” file

<?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>cpp_pubsub</name>
        <version>1.0</version>
        <description>
                A simple pub-sub example
        </description>
        <maintainer email="lizhao.johnlee@gmail.com">
                John Z. Li
        </maintainer>
        <license>None</license>

        <buildtool_depend>ament_cmake</buildtool_depend>

        <depend>rclcpp</depend>
        <depend>std_msgs</depend>

        <test_depend>ament_lint_auto</test_depend>
        <test_depend>ament_lint_common</test_depend>

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

The “CmakeLists.txt” file

cmake_minimum_required(VERSION 3.5)
project(cpp_pubsub VERSION 1.0
                  DESCRIPTION "A tiny project"
                  LANGUAGES CXX)

# 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(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)

# find extra headers
include_directories(
  include/${PROJECT_NAME}
)

add_executable(talker src/publisher_member_function.cpp)
ament_target_dependencies(talker rclcpp std_msgs)
add_executable(listener src/subscriber_member_function.cpp)
ament_target_dependencies(listener rclcpp std_msgs)

# use add_library() if you also want to add some libraries.

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()

# install
## copy headers in the include direcoty to /
install(
  DIRECTORY include/
  DESTINATION include/
)

# install the whole project.
# only install those executables that don't rely on ROS to manage its dependences to /bin
# if an executalbe is supposed to be run by "ros2 run", put it to /lib
install(
  TARGETS talker
  # ARCHIVE DESTINATION lib/${PROJECT_NAME}
  # LIBRARY DESTINATION lib/${PROJECT_NAME}
  RUNTIME DESTINATION lib/${PROJECT_NAME}
)
install(
  TARGETS listener
  # ARCHIVE DESTINATION lib/${PROJECT_NAME}
  # LIBRARY DESTINATION lib/${PROJECT_NAME}
  RUNTIME DESTINATION lib/${PROJECT_NAME}
)

# to be found and used by other package
ament_export_include_directories(include)
# if the project also provides a library
# ament_export_libraries(${PROJECT_NAME})
ament_package()

Notice that unlike non-ament CMake projects, the executables should be installed to “lib/${PROJECT_NMAE}” folder in the “install” direcotory of the workspace, so that tools like “ros2 run” can find them.

After building and sourcing the “setup.zsh” script, one can lauch the node “talker” use the following from the root directory of the workspace:

ros2 run cpp_pubsub talker "Hi "

Here “Hi “ is used as the message header of the topic. If you ommit the message header in command line, it will defaulted to “Hello World”.

The topic can be monitorred using

ros2 run cpp_pubsub listener