Interface nav2 with custom ROS2 nodes#

Added in version Jazzy: This section.

Warning

This topic is under construction and this might not even be its final form. Please feel free to open an issue if you spot any typos or other problems.

Objective#

We are going to create custom code to interact with the navigation stack, nav2. This will be based on the demo tb3_simulation_launch.py, but it is generally applicable. In the example, rviz2 is used for interactivity. In this example, we will be able to operate nav2 automatically, without relying on the visualizer.

  1. Create a publisher to send the initial pose.

  2. Create an action client to send navigation goals.

Important

Differently from everything else used in this tutorial, this tb3_simulation_launch.py demo does not always finishes cleanly on my machines. It has hanged sometimes, sometimes Gazebo does not show, and sometimes it does not shutdown properly.

It is highly recommended to use a docker container, such as UoMMScRobotics/sfr_gazebo_nav2, to minimise disappointment.

When dealing with a new stack you might not be familiar with, the first step is to try to make sense of the interfaces available.

Initial pose topic#

When the tb3_simulation_launch.py example is running, if we run ros2 topic list you will see a long list of topics. Let us filter them out with the keyword pose, giving that we’re trying to find things related to pose. We can do so with the command below.

ros2 topic list | grep pose

The output of that will be the following topics.

/amcl_pose
/detected_dock_pose
/dock_pose
/filtered_dock_pose
/goal_pose
/initialpose
/staging_pose

This is convenient for us because /initialpose describes exactly what we want to do in the first step. If you are annoyed by the fact that the words initial and pose are not separated by a _, so am I.

We can check if this is in fact the correct topic used by rviz2. With the demo running, we run, in another terminal, the following command.

ros2 topic echo /initialpose

Then, we send the initial position through rviz2. We can see that in fact rviz2 used this topic to set the initial pose, therefore our guess was correct. Below is not representative output.

Output of ros2 topic echo
header:
  stamp:
    sec: 247
    nanosec: 791000000
  frame_id: map
pose:
  pose:
    position:
      x: -2.1046903133392334
      y: -0.3082020580768585
      z: 0.0
    orientation:
      x: 0.0
      y: 0.0
      z: 0.0
      w: 1.0
  covariance:
  - 0.25
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.25
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.0
  - 0.06853891909122467
---

We can understand more about the topic with the following command.

ros2 topic info /initialpose

This will show us that the topic uses geometry_msgs/msg/PoseWithCovarianceStamped. Thence, all we have to do is create a subscriber with that message type to the topic /initialpose.

Type: geometry_msgs/msg/PoseWithCovarianceStamped
Publisher count: 1
Subscription count: 1

Although it might look somewhat trivial at this stage, there are many ROS2 concepts at play so that these connections can be understood.

So what?#

After going through this investigative process looking through topics and actions, we reached a conclusion of what must be done in a more concrete sense.

  1. Create a publisher to send the initial pose.
    • The message type is geometry_msgs/msg/PoseWithCovarianceStamped.

  2. Create an action client to send navigation goals.
    • The action type is nav2_msgs/action/NavigateToPose.

That’s it!

Create the package#

We’ll start by creating a suitable ROS2 package. We already now, from the previous investigation, what package we need to depend on.

cd ~/ros2_tutorial_workspace/src
ros2 pkg create python_package_that_uses_nav2 \
--build-type ament_python \
--dependencies rclpy geometry_msgs nav2_msgs
ros2 pkg create output
going to create a new package
package name: python_package_that_uses_nav2
destination directory: ~/ros2_tutorial_workspace/src
package format: 3
version: 0.0.0
description: TODO: Package description
maintainer: ['root <murilo.marinho@manchester.ac.uk>']
licenses: ['TODO: License declaration']
build type: ament_python
dependencies: ['rclpy', 'geometry_msgs', 'nav2_msgs']
creating folder ./python_package_that_uses_nav2
creating ./python_package_that_uses_nav2/package.xml
creating source folder
creating folder ./python_package_that_uses_nav2/python_package_that_uses_nav2
creating ./python_package_that_uses_nav2/setup.py
creating ./python_package_that_uses_nav2/setup.cfg
creating folder ./python_package_that_uses_nav2/resource
creating ./python_package_that_uses_nav2/resource/python_package_that_uses_nav2
creating ./python_package_that_uses_nav2/python_package_that_uses_nav2/__init__.py
creating folder ./python_package_that_uses_nav2/test
creating ./python_package_that_uses_nav2/test/test_copyright.py
creating ./python_package_that_uses_nav2/test/test_flake8.py
creating ./python_package_that_uses_nav2/test/test_pep257.py

[WARNING]: Unknown license 'TODO: License declaration'.  This has been set in the package.xml, but no LICENSE file has been created.
It is recommended to use one of the ament license identifiers:
Apache-2.0
BSL-1.0
BSD-2.0
BSD-2-Clause
BSD-3-Clause
GPL-3.0-only
LGPL-3.0-only
MIT
MIT-0

Files#

In this section, we will be creating or modifying the following highlighted files.

python_package_that_uses_nav2/
|-- package.xml
|-- python_package_that_uses_nav2
|   |-- __init__.py
|   |-- nav2_initial_pose_publisher_node.py
|   `-- nav2_navigate_to_pose_action_client_node.py
|-- resource
|   `-- python_package_that_uses_nav2
|-- setup.cfg
|-- setup.py
`-- test
    |-- test_copyright.py
    |-- test_flake8.py
    `-- test_pep257.py

Sending the initial pose to nav2#

Given that this node is a simple publisher, we can jump straight to it.

nav2_initial_pose_publisher_node.py

 1import time
 2
 3import rclpy
 4from rclpy.node import Node
 5from geometry_msgs.msg import PoseWithCovarianceStamped
 6
 7class Nav2InitialPosePublisherNode(Node):
 8    """A ROS2 Node that publishes the initial pose for nav2."""
 9
10    def __init__(self):
11        super().__init__('nav2_initial_pose_publisher_node')
12
13        self._topic = '/initialpose'
14
15        self.publisher = self.create_publisher(
16            msg_type=PoseWithCovarianceStamped,
17            topic=self._topic,
18            qos_profile=1)
19
20        publisher_count = 0
21        while publisher_count < 2:
22            publisher_count = self.count_publishers(self._topic)
23            print(f"Waiting for publisher to be connected to {self._topic}.")
24            print(f"Publisher count is {publisher_count}.")
25            time.sleep(1)
26
27    def send_initial_pose_with_covariance(self):
28        """Method to create the PoseWithCovarianceStamped."""
29
30        print(f"Publishing pose to topic: {self._topic}.")
31
32        pwcs = PoseWithCovarianceStamped()
33        pwcs.header.stamp = self.get_clock().now().to_msg()
34        pwcs.header.frame_id = 'map'
35
36        pwcs.pose.pose.position.x = -2.1
37        pwcs.pose.pose.position.y = -0.3
38        pwcs.pose.pose.position.z = 0.0
39
40        pwcs.pose.pose.orientation.w = 1.0
41        pwcs.pose.pose.orientation.x = 0.0
42        pwcs.pose.pose.orientation.y = 0.0
43        pwcs.pose.pose.orientation.z = 0.0
44
45        pwcs.pose.covariance[0] = 0.25
46        pwcs.pose.covariance[7] = 0.25
47        pwcs.pose.covariance[35] = 0.06853891909122467
48
49        self.publisher.publish(pwcs)
50
51def main(args=None):
52    """
53    The main function.
54    :param args: Not used directly by the user, but used by ROS2 to configure
55    certain aspects of the Node.
56    """
57    try:
58        rclpy.init(args=args)
59        node = Nav2InitialPosePublisherNode()
60        node.send_initial_pose_with_covariance()
61        rclpy.spin(node)
62    except KeyboardInterrupt:
63        pass
64    except Exception as e:
65        print(e)
66
67if __name__ == '__main__':
68    main()

There might be two minor novelties in this example. The first one is similar to what we did in tf2 when guaranteeing that the publisher was connected before attempting to publish for the first time. In this case, because in this demo rviz2 is already connected to that topic, we check that there are two subscribers connected before proceeding with the initialisation.

        publisher_count = 0
        while publisher_count < 2:
            publisher_count = self.count_publishers(self._topic)
            print(f"Waiting for publisher to be connected to {self._topic}.")
            print(f"Publisher count is {publisher_count}.")
            time.sleep(1)

The second part that might be unfamiliar is the covariance used in the message. The covariance matrix holds the variance in the diagonal and off the diagonal any pair-wise variances between different states. All of this to say that the covariance will be used to say how confident we are in a given field of the pose and if their variation is correlated. In this case, we choose the same, or similar, values that were sent by rviz2. This is enough for our purposes. The covariance is highlighted below.

    def send_initial_pose_with_covariance(self):
        """Method to create the PoseWithCovarianceStamped."""

        print(f"Publishing pose to topic: {self._topic}.")

        pwcs = PoseWithCovarianceStamped()
        pwcs.header.stamp = self.get_clock().now().to_msg()
        pwcs.header.frame_id = 'map'

        pwcs.pose.pose.position.x = -2.1
        pwcs.pose.pose.position.y = -0.3
        pwcs.pose.pose.position.z = 0.0

        pwcs.pose.pose.orientation.w = 1.0
        pwcs.pose.pose.orientation.x = 0.0
        pwcs.pose.pose.orientation.y = 0.0
        pwcs.pose.pose.orientation.z = 0.0

        pwcs.pose.covariance[0] = 0.25
        pwcs.pose.covariance[7] = 0.25
        pwcs.pose.covariance[35] = 0.06853891909122467

        self.publisher.publish(pwcs)

It is important not to be distracted by the nested pose. The first one os the PoseWithCovariance, part of the PoseWithCovarianceStamped. The second one is the Pose part of the PoseWithCovariance.

That’s it. This node is relatively simple: a publisher that publishes a single message. There will be other ways to achieve this. The one shown herein is the most standard one according to what has been shown in the tutorial.

Handling nav2 pose navigation actions#

I’m conflicted whether this one is even easier. I suppose it’s more complex, being an action client. However, it follows the same formula as we did in the action tutorial.

nav2_navigate_to_pose_action_client_node.py

 1import rclpy
 2from rclpy.action import ActionClient
 3from rclpy.action.client import ClientGoalHandle
 4from rclpy.node import Node
 5from rclpy.task import Future
 6
 7from geometry_msgs.msg import Pose, PoseStamped
 8from nav2_msgs.action import NavigateToPose
 9
10class Nav2NavigateToPoseActionClient(Node):
11    """A ROS2 Node with an Action Client for Nav2NavigateToPoseActionClient."""
12
13    def __init__(self):
14        super().__init__('nav2_navigate_to_pose_action_client')
15
16        self.action_client = ActionClient(self, NavigateToPose, '/navigate_to_pose')
17
18        self.send_goal_future = None # This will be used in `send_goal`
19        self.get_result_future = None # This will be used in 'goal_response_callback'
20
21    def send_goal_async(self, desired_pose: Pose, behaviour_tree: str) -> None:
22        goal_msg = NavigateToPose.Goal()
23        goal_msg.pose.header.stamp = self.get_clock().now().to_msg()
24        goal_msg.pose.header.frame_id = 'map'
25        goal_msg.pose.pose = desired_pose
26        goal_msg.behavior_tree = behaviour_tree
27
28        while not self.action_client.wait_for_server(timeout_sec=1.0):
29            self.get_logger().info(f'action {self.action_client} not available, waiting...')
30
31        self.get_logger().info(f'Sending goal: {goal_msg}.')
32
33        self.send_goal_future = self.action_client.send_goal_async(goal_msg, feedback_callback=self.action_feedback_callback)
34        self.send_goal_future.add_done_callback(self.goal_response_callback)
35
36    def goal_response_callback(self, future: Future) -> None:
37        goal: ClientGoalHandle = future.result()
38
39        if not goal.accepted:
40            self.get_logger().info('Goal was rejected by the server.')
41            return
42        self.get_logger().info('Goal was accepted by the server.')
43
44        self.get_result_future = goal.get_result_async()
45        self.get_result_future.add_done_callback(self.action_result_callback)
46
47    def action_result_callback(self, future: Future) -> None:
48        result: NavigateToPose.Result = future.result()
49        self.get_logger().info(f'Final position was: {result.result}.')
50
51    def action_feedback_callback(self, feedback_msg: NavigateToPose.Feedback) -> None:
52        feedback = feedback_msg.feedback
53        self.get_logger().info(f'Received feedback distance: {feedback.distance_remaining}.')
54
55
56def main(args=None):
57    """
58    The main function.
59    :param args: Not used directly by the user, but used by ROS2 to configure certain aspects of the Node.
60    """
61    try:
62        rclpy.init(args=args)
63
64        node = Nav2NavigateToPoseActionClient()
65
66        desired_pose = Pose()
67        desired_pose.position.x = 1.0
68        desired_pose.position.y = -1.0
69        desired_pose.orientation.w = 1.0
70
71        node.send_goal_async(desired_pose, "")
72
73        rclpy.spin(node)
74    except KeyboardInterrupt:
75        pass
76    except Exception as e:
77        print(e)
78
79
80if __name__ == '__main__':
81    main()

We can highlight two possible pinch points. Starting by the main() function, we instantiate the node and create a desired pose, highlighted below. Indeed, we are going to use a stamped pose. However, it is simpler to grab the stamp from a node, so we do that in a method. We send an empty behaviour tree, because otherwise would be out of the scope of this tutorial.

        node = Nav2NavigateToPoseActionClient()

        desired_pose = Pose()
        desired_pose.position.x = 1.0
        desired_pose.position.y = -1.0
        desired_pose.orientation.w = 1.0

        node.send_goal_async(desired_pose, "")

Then, in our implementation we populate the fields of the goal with the pose we just created and the stamp obtained from the node. We receive an empty behaviour tree and that is also added to the goal.

    def send_goal_async(self, desired_pose: Pose, behaviour_tree: str) -> None:
        goal_msg = NavigateToPose.Goal()
        goal_msg.pose.header.stamp = self.get_clock().now().to_msg()
        goal_msg.pose.header.frame_id = 'map'
        goal_msg.pose.pose = desired_pose
        goal_msg.behavior_tree = behaviour_tree

Besides the slightly different action type, the process to make an action client is mostly unchanged.

Adjusting the setup.py#

setup.py

 1from setuptools import find_packages, setup
 2
 3package_name = 'python_package_that_uses_nav2'
 4
 5setup(
 6    name=package_name,
 7    version='0.0.0',
 8    packages=find_packages(exclude=['test']),
 9    data_files=[
10        ('share/ament_index/resource_index/packages',
11            ['resource/' + package_name]),
12        ('share/' + package_name, ['package.xml']),
13    ],
14    install_requires=['setuptools'],
15    zip_safe=True,
16    maintainer='root',
17    maintainer_email='murilo.marinho@manchester.ac.uk',
18    description='TODO: Package description',
19    license='TODO: License declaration',
20    extras_require={
21        'test': [
22            'pytest',
23        ],
24    },
25    entry_points={
26        'console_scripts': [
27            'nav2_initial_pose_publisher_node = python_package_that_uses_nav2.nav2_initial_pose_publisher_node:main',
28            'nav2_navigate_to_pose_action_client_node = python_package_that_uses_nav2.nav2_navigate_to_pose_action_client_node:main'
29        ],
30    },
31)

Build and source#

Before we proceed, let us build and source once.

cd ~/ros2_tutorial_workspace
colcon build
source install/setup.bash

Note

For additional explanation and troubleshooting tips, see Always source after you build.

Warning

colcon will not work properly if your terminal has an active venv.

Testing#

The first step is to run the same demo as before, with tb3_simulation_launch.py. We can do that with the command below.

ros2 launch nav2_bringup \
tb3_simulation_launch.py \
use_sim_time:=True \
headless:=False \
sigterm_timeout:=120

Then, in another terminal, we can run the following command. This one will be rather tolerant of what it’s started because it will wait for the correct number of publishers before publishing.

ros2 run python_package_that_uses_nav2 nav2_initial_pose_publisher_node

The following command should only be attempted after the initial pose was set. The navigation stack will not accept goals unless an initial estimate is given first.

ros2 run python_package_that_uses_nav2 nav2_navigate_to_pose_action_client_node

The qualitative behaviour can be seen in the following video.

Exercises

Other possible interactions with these interfaces could be as follows.

  • What would change if the same node was supposed to both send the initial pose and, after that is done, send the goal?

  • What would have to be modified if you had three goals, goal a, goal b, and goal c. Suppose that we initially send goal a. If the goal is reached, then go to goal b. If not reached, go to goal c. This is a very basic state machine. An embryo, but somewhat the motivation for behaviour tress.