Interface Gazebo 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.

In this section, our intention is to control things in Gazebo from a ROS2 package, using nodes and launch files as needed. For things that are well supported in Gazebo, this will work fine. For anything new, you might need to dive more deeply into Gazebo plugins, which is considerably outside the scope of a tutorial like this one.

There is a lot of sample code available online for Gazebo integration. You will also see frequent patterns of having multiple packages in a single project, to correctly organize the package according to Gazebo logic. This logic tends to be reflected in other packages, such as examples using nav2.

Most of the sample code I found relates to the use of Gazebo plugins with specific functionality, such as gz::sim::systems::DiffDrive, gz::sim::systems::JointStatePublisher, and gz::sim::systems::OdometryPublisher.

Instead of repeating any of that, the idea here is to show how a simple node can perform simple communication with Gazebo. Using this logic, you can find out how to communicate with any existing plugins, updated plugins, or new plugins when they become available.

Note

I haven’t yet found a comprehensive list of Gazebo plugins and their pairing xml descriptions to add them to a .sdf. Most of the information is spread around examples and takes a bit a trial-and-error to find dependencies between plugins.

Some common patterns are:

  • Information as comments of the .hh files, see this example.

  • Information available when you run Gazebo with gz sim -v4.

Please feel free to correct me here.

Setting up the scene#

We will use a slightly modified version of the shapes_with_tf2_publisher.sdf created previously. Let’s add it to our Gazebo folder.

We will use two systems in this example. We will use gz::sim::systems::UserCommands to allow us to set the pose of entities and gz::sim::systems::ApplyLinkWrench to allow us to set wrenches to links. This is illustrative because poses use Gazebo services and wrenches use Gazebo topics.

We will modify our shapes_with_tf2_publisher.sdf to add the following lines inside the <world> tag.

        <physics type="ode">
          <max_step_size>0.004</max_step_size>
          <real_time_factor>1.0</real_time_factor>
          <real_time_update_rate>250</real_time_update_rate>
        </physics>
        <plugin name='gz::sim::systems::Physics' filename='gz-sim-physics-system'/>
        <plugin filename="gz-sim-scene-broadcaster-system"  name="gz::sim::systems::SceneBroadcaster"/>
        <plugin filename="gz-sim-apply-link-wrench-system"  name="gz::sim::systems::ApplyLinkWrench"/>
        <plugin filename="gz-sim-user-commands-system" name="gz::sim::systems::UserCommands"/>

Here’s a brief explanation of why we need each one of these.

Note

More information is available in the gz-sim/src/systems folder at gazebosim/gz-sim.

gz::sim::systems::Physics

The physics behavior needed by the other systems.

gz::sim::systems::SceneBroadcaster

Needed by the other systems [doc].

gz::sim::systems::ApplyLinkWrench

Creates the /wrench topic [doc].

gz::sim::systems::UserCommands

Creates the /set_pose service [doc].

We start by adding the following file to your ~/gazebo_tutorial_workspace/scenes folder.

shapes_with_tf2_and_wrench.sdf

Contents of shapes_with_tf2_and_wrench.sdf
  1<?xml version="1.0" ?>
  2<sdf version="1.11">
  3    <!--
  4      Try moving a model using the command in the following CDATA block::
  5    -->
  6    <![CDATA[
  7    gz service -s /world/shapes/set_pose \
  8               --reqtype gz.msgs.Pose --reptype gz.msgs.Boolean \
  9               --timeout 300 --req 'name: "box", position: {z: 5.0}'
 10  ]]>
 11    <world name="shapes_with_tf2_and_wrench">
 12
 13        <physics type="ode">
 14          <max_step_size>0.004</max_step_size>
 15          <real_time_factor>1.0</real_time_factor>
 16          <real_time_update_rate>250</real_time_update_rate>
 17        </physics>
 18        <plugin name='gz::sim::systems::Physics' filename='gz-sim-physics-system'/>
 19        <plugin filename="gz-sim-scene-broadcaster-system"  name="gz::sim::systems::SceneBroadcaster"/>
 20        <plugin filename="gz-sim-apply-link-wrench-system"  name="gz::sim::systems::ApplyLinkWrench"/>
 21        <plugin filename="gz-sim-user-commands-system" name="gz::sim::systems::UserCommands"/>
 22
 23        <scene>
 24            <ambient>1.0 1.0 1.0</ambient>
 25            <background>0.8 0.8 0.8</background>
 26        </scene>
 27
 28        <light type="directional" name="sun">
 29            <cast_shadows>true</cast_shadows>
 30            <pose>0 0 10 0 0 0</pose>
 31            <diffuse>0.8 0.8 0.8 1</diffuse>
 32            <specular>0.2 0.2 0.2 1</specular>
 33            <attenuation>
 34                <range>1000</range>
 35                <constant>0.9</constant>
 36                <linear>0.01</linear>
 37                <quadratic>0.001</quadratic>
 38            </attenuation>
 39            <direction>-0.5 0.1 -0.9</direction>
 40        </light>
 41
 42        <model name="ground_plane">
 43            <static>true</static>
 44            <link name="link">
 45                <collision name="collision">
 46                    <geometry>
 47                        <plane>
 48                            <normal>0 0 1</normal>
 49                            <size>100 100</size>
 50                        </plane>
 51                    </geometry>
 52                </collision>
 53                <visual name="visual">
 54                    <geometry>
 55                        <plane>
 56                            <normal>0 0 1</normal>
 57                            <size>100 100</size>
 58                        </plane>
 59                    </geometry>
 60                    <material>
 61                        <ambient>0.8 0.8 0.8 1</ambient>
 62                        <diffuse>0.8 0.8 0.8 1</diffuse>
 63                        <specular>0.8 0.8 0.8 1</specular>
 64                    </material>
 65                </visual>
 66            </link>
 67        </model>
 68
 69        <model name="box">
 70            <pose>0 0 0.5 0 0 0</pose>
 71            <link name="box_link">
 72                <inertial>
 73                    <inertia>
 74                        <ixx>0.16666</ixx>
 75                        <ixy>0</ixy>
 76                        <ixz>0</ixz>
 77                        <iyy>0.16666</iyy>
 78                        <iyz>0</iyz>
 79                        <izz>0.16666</izz>
 80                    </inertia>
 81                    <mass>1.0</mass>
 82                </inertial>
 83                <collision name="box_collision">
 84                    <geometry>
 85                        <box>
 86                            <size>1 1 1</size>
 87                        </box>
 88                    </geometry>
 89                </collision>
 90
 91                <visual name="box_visual">
 92                    <geometry>
 93                        <box>
 94                            <size>1 1 1</size>
 95                        </box>
 96                    </geometry>
 97                    <material>
 98                        <ambient>1 0 0 1</ambient>
 99                        <diffuse>1 0 0 1</diffuse>
100                        <specular>1 0 0 1</specular>
101                    </material>
102                </visual>
103            </link>
104
105            <plugin
106                    filename="gz-sim-pose-publisher-system"
107                    name="gz::sim::systems::PosePublisher">
108                <use_pose_vector_msg>true</use_pose_vector_msg>
109                <publish_link_pose>false</publish_link_pose>
110                <publish_collision_pose>false</publish_collision_pose>
111                <publish_visual_pose>false</publish_visual_pose>
112                <publish_nested_model_pose>true</publish_nested_model_pose>
113            </plugin>
114
115        </model>
116
117        <model name="cylinder">
118            <pose>0 -1.5 0.5 0 0 0</pose>
119            <link name="cylinder_link">
120                <inertial>
121                    <inertia>
122                        <ixx>0.1458</ixx>
123                        <ixy>0</ixy>
124                        <ixz>0</ixz>
125                        <iyy>0.1458</iyy>
126                        <iyz>0</iyz>
127                        <izz>0.125</izz>
128                    </inertia>
129                    <mass>1.0</mass>
130                </inertial>
131                <collision name="cylinder_collision">
132                    <geometry>
133                        <cylinder>
134                            <radius>0.5</radius>
135                            <length>1.0</length>
136                        </cylinder>
137                    </geometry>
138                </collision>
139
140                <visual name="cylinder_visual">
141                    <geometry>
142                        <cylinder>
143                            <radius>0.5</radius>
144                            <length>1.0</length>
145                        </cylinder>
146                    </geometry>
147                    <material>
148                        <ambient>0 1 0 1</ambient>
149                        <diffuse>0 1 0 1</diffuse>
150                        <specular>0 1 0 1</specular>
151                    </material>
152                </visual>
153            </link>
154
155            <plugin
156                    filename="gz-sim-pose-publisher-system"
157                    name="gz::sim::systems::PosePublisher">
158                <use_pose_vector_msg>true</use_pose_vector_msg>
159                <publish_link_pose>false</publish_link_pose>
160                <publish_collision_pose>false</publish_collision_pose>
161                <publish_visual_pose>false</publish_visual_pose>
162                <publish_nested_model_pose>true</publish_nested_model_pose>
163            </plugin>
164
165        </model>
166
167        <model name="sphere">
168            <pose>0 1.5 0.5 0 0 0</pose>
169            <link name="sphere_link">
170                <inertial>
171                    <inertia>
172                        <ixx>0.1</ixx>
173                        <ixy>0</ixy>
174                        <ixz>0</ixz>
175                        <iyy>0.1</iyy>
176                        <iyz>0</iyz>
177                        <izz>0.1</izz>
178                    </inertia>
179                    <mass>1.0</mass>
180                </inertial>
181                <collision name="sphere_collision">
182                    <geometry>
183                        <sphere>
184                            <radius>0.5</radius>
185                        </sphere>
186                    </geometry>
187                </collision>
188
189                <visual name="sphere_visual">
190                    <geometry>
191                        <sphere>
192                            <radius>0.5</radius>
193                        </sphere>
194                    </geometry>
195                    <material>
196                        <ambient>0 0 1 1</ambient>
197                        <diffuse>0 0 1 1</diffuse>
198                        <specular>0 0 1 1</specular>
199                    </material>
200                </visual>
201            </link>
202
203            <plugin
204                    filename="gz-sim-pose-publisher-system"
205                    name="gz::sim::systems::PosePublisher">
206                <use_pose_vector_msg>true</use_pose_vector_msg>
207                <publish_link_pose>false</publish_link_pose>
208                <publish_collision_pose>false</publish_collision_pose>
209                <publish_visual_pose>false</publish_visual_pose>
210                <publish_nested_model_pose>true</publish_nested_model_pose>
211            </plugin>
212
213        </model>
214
215        <model name="capsule">
216            <pose>0 -3.0 0.5 0 0 0</pose>
217            <link name="capsule_link">
218                <inertial>
219                    <inertia>
220                        <ixx>0.074154</ixx>
221                        <ixy>0</ixy>
222                        <ixz>0</ixz>
223                        <iyy>0.074154</iyy>
224                        <iyz>0</iyz>
225                        <izz>0.018769</izz>
226                    </inertia>
227                    <mass>1.0</mass>
228                </inertial>
229                <collision name="capsule_collision">
230                    <geometry>
231                        <capsule>
232                            <radius>0.2</radius>
233                            <length>0.6</length>
234                        </capsule>
235                    </geometry>
236                </collision>
237                <visual name="capsule_visual">
238                    <geometry>
239                        <capsule>
240                            <radius>0.2</radius>
241                            <length>0.6</length>
242                        </capsule>
243                    </geometry>
244                    <material>
245                        <ambient>1 1 0 1</ambient>
246                        <diffuse>1 1 0 1</diffuse>
247                        <specular>1 1 0 1</specular>
248                    </material>
249                </visual>
250            </link>
251
252            <plugin
253                    filename="gz-sim-pose-publisher-system"
254                    name="gz::sim::systems::PosePublisher">
255                <use_pose_vector_msg>true</use_pose_vector_msg>
256                <publish_link_pose>false</publish_link_pose>
257                <publish_collision_pose>false</publish_collision_pose>
258                <publish_visual_pose>false</publish_visual_pose>
259                <publish_nested_model_pose>true</publish_nested_model_pose>
260            </plugin>
261
262        </model>
263
264        <model name="ellipsoid">
265            <pose>0 3.0 0.5 0 0 0</pose>
266            <link name="ellipsoid_link">
267                <inertial>
268                    <inertia>
269                        <ixx>0.068</ixx>
270                        <ixy>0</ixy>
271                        <ixz>0</ixz>
272                        <iyy>0.058</iyy>
273                        <iyz>0</iyz>
274                        <izz>0.026</izz>
275                    </inertia>
276                    <mass>1.0</mass>
277                </inertial>
278                <collision name="ellipsoid_collision">
279                    <geometry>
280                        <ellipsoid>
281                            <radii>0.2 0.3 0.5</radii>
282                        </ellipsoid>
283                    </geometry>
284                </collision>
285                <visual name="ellipsoid_visual">
286                    <geometry>
287                        <ellipsoid>
288                            <radii>0.2 0.3 0.5</radii>
289                        </ellipsoid>
290                    </geometry>
291                    <material>
292                        <ambient>1 0 1 1</ambient>
293                        <diffuse>1 0 1 1</diffuse>
294                        <specular>1 0 1 1</specular>
295                    </material>
296                </visual>
297            </link>
298
299            <plugin
300                    filename="gz-sim-pose-publisher-system"
301                    name="gz::sim::systems::PosePublisher">
302                <use_pose_vector_msg>true</use_pose_vector_msg>
303                <publish_link_pose>false</publish_link_pose>
304                <publish_collision_pose>false</publish_collision_pose>
305                <publish_visual_pose>false</publish_visual_pose>
306                <publish_nested_model_pose>true</publish_nested_model_pose>
307            </plugin>
308
309        </model>
310
311        <model name="cone">
312            <pose>0 4.5 0.5 0 0 0</pose>
313            <link name="cone_link">
314                <inertial auto="true">
315                    <density>1</density>
316                </inertial>
317                <collision name="cone_collision">
318                    <geometry>
319                        <cone>
320                            <radius>0.5</radius>
321                            <length>1.0</length>
322                        </cone>
323                    </geometry>
324                </collision>
325
326                <visual name="cone_visual">
327                    <geometry>
328                        <cone>
329                            <radius>0.5</radius>
330                            <length>1.0</length>
331                        </cone>
332                    </geometry>
333                    <material>
334                        <ambient>1 0.47 0 1</ambient>
335                        <diffuse>1 0.47 0 1</diffuse>
336                        <specular>1 0.47 0 1</specular>
337                    </material>
338                </visual>
339            </link>
340
341            <plugin
342                    filename="gz-sim-pose-publisher-system"
343                    name="gz::sim::systems::PosePublisher">
344                <use_pose_vector_msg>true</use_pose_vector_msg>
345                <publish_link_pose>false</publish_link_pose>
346                <publish_collision_pose>false</publish_collision_pose>
347                <publish_visual_pose>false</publish_visual_pose>
348                <publish_nested_model_pose>true</publish_nested_model_pose>
349            </plugin>
350
351        </model>
352
353    </world>
354
355
356</sdf>

We can open this scene in Gazebo with the following command.

gz sim $HOME/gazebo_tutorial_workspace/scenes/shapes_with_tf2_and_wrench.sdf

Using the following command will show the wrench-related topics.

gz topic -l | grep /wrench

It should result in the following output.

/world/shapes_with_tf2_and_wrench/wrench
/world/shapes_with_tf2_and_wrench/wrench/clear
/world/shapes_with_tf2_and_wrench/wrench/persistent

The message used in this topic can be found with the following command.

gz topic -i -t /world/shapes_with_tf2_and_wrench/wrench

It should result in the following output.

No publishers on topic [/world/shapes_with_tf2_and_wrench/wrench]
Subscribers [Address, Message Type]:
  tcp://172.16.191.128:37021, gz.msgs.EntityWrench

Using the following command will show the pose-related services.

gz service -l | grep set_pose

It should result in the following output.

/world/shapes_with_tf2_and_wrench/set_pose
/world/shapes_with_tf2_and_wrench/set_pose/blocking
/world/shapes_with_tf2_and_wrench/set_pose_vector
/world/shapes_with_tf2_and_wrench/set_pose_vector/blocking

The request and response of this service can be found with the following command.

gz service -i -s /world/shapes_with_tf2_and_wrench/set_pose

It should result in the following output.

Service providers [Address, Request Message Type, Response Message Type]:
  tcp://172.16.191.128:35577, gz.msgs.Pose, gz.msgs.Boolean

Objective#

Our objectives in this section will be as follows.

  1. Send ROS2 messages to the [...]/wrench Gazebo topic. It uses gz.msgs.EntityWrench, therefore the pairing message is ros_gz_interfaces/msg/EntityWrench.

  2. Use a ROS2 service to call the [...]/set_pose Gazebo service. The interface is not listed in the official list, but we will use ros_gz_interfaces/srv/SetEntityPose. See the official repository: gazebosim/ros_gz.

The pose messages will be processed by tf2.

Create the package#

We start by creating a package that depends on the interface packages mentioned above, namely ros_gz_interfaces and tf2_ros.

cd ~/ros2_tutorial_workspace/src
ros2 pkg create python_package_that_uses_gazebo \
--build-type ament_python \
--dependencies rclpy ros_gz_interfaces tf2_ros
ros2 pkg create output
going to create a new package
package name: python_package_that_uses_gazebo
destination directory: /IdeaProjects/ROS2_Tutorial/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', 'ros_gz_interfaces', 'geometry_msgs', 'std_msgs']
creating folder ./python_package_that_uses_gazebo
creating ./python_package_that_uses_gazebo/package.xml
creating source folder
creating folder ./python_package_that_uses_gazebo/python_package_that_uses_gazebo
creating ./python_package_that_uses_gazebo/setup.py
creating ./python_package_that_uses_gazebo/setup.cfg
creating folder ./python_package_that_uses_gazebo/resource
creating ./python_package_that_uses_gazebo/resource/python_package_that_uses_gazebo
creating ./python_package_that_uses_gazebo/python_package_that_uses_gazebo/__init__.py
creating folder ./python_package_that_uses_gazebo/test
creating ./python_package_that_uses_gazebo/test/test_copyright.py
creating ./python_package_that_uses_gazebo/test/test_flake8.py
creating ./python_package_that_uses_gazebo/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#

We will be working on the following files.

python_package_that_uses_gazebo/
|-- config_bridge
|   `-- control_shape_thrust.yaml
|-- launch
|   |-- control_shape_thrust_launch.py
|   |-- send_poses_to_gazebo_launch.py
|   `-- send_wrenches_to_gazebo_launch.py
|-- package.xml
|-- python_package_that_uses_gazebo
|   |-- __init__.py
|   |-- control_shape_thrust_node.py
|   |-- send_poses_to_gazebo_node.py
|   `-- send_wrenches_to_gazebo_node.py
|-- resource
|   `-- python_package_that_uses_gazebo
|-- setup.cfg
|-- setup.py
`-- test
    |-- test_copyright.py
    |-- test_flake8.py
    `-- test_pep257.py

Sending poses to Gazebo#

Summary

We will make a node that does the following for us through ROS2.

gz service -s \
/world/shapes_with_tf2_and_wrench/set_pose \
--reqtype gz.msgs.Pose \
--reptype gz.msgs.Boolean \
--req 'name: "box", position: {x: 0.0, y: 0.0, z: 50}'

We’ll be able to send poses to Gazebo with the following node. It is a relatively simple node with a service client.

send_poses_to_gazebo_node.py

 1import time
 2from ros_gz_interfaces.srv import SetEntityPose
 3
 4import rclpy
 5from rclpy.node import Node
 6
 7class SendPosesToGazeboNode(Node):
 8    """A ROS2 Node that sends poses to Gazebo"""
 9
10    def __init__(self):
11        super().__init__('send_poses_to_gazebo_node')
12
13        self.service_client = self.create_client(
14            srv_type=SetEntityPose,
15            srv_name='/world/shapes_with_tf2_and_wrench/set_pose')
16
17        while not self.service_client.wait_for_service(timeout_sec=1.0):
18            self.get_logger().info(f'service {self.service_client.srv_name} not available, waiting...')
19
20
21    def send_pose_to_gazebo(self):
22        """Sample method sending a pose to and entity in Gazebo."""
23
24        request = SetEntityPose.Request()
25
26        # Add the entity name. For this one, Gazebo accepts the name. :)
27        request.entity.name = "box"
28
29        # Set the position
30        request.pose.position.x = 0.0
31        request.pose.position.y = 0.0
32        request.pose.position.z = 50.0
33
34        # Set the orientation
35        request.pose.orientation.x = 0.0
36        request.pose.orientation.y = 0.0
37        request.pose.orientation.z = 0.0
38        request.pose.orientation.w = 1.0
39
40        return self.service_client.call_async(request)
41
42
43def main(args=None):
44    """
45    The main function.
46    :param args: Not used directly by the user, but used by ROS2 to configure certain aspects of the Node.
47    """
48    try:
49        rclpy.init(args=args)
50
51        node = SendPosesToGazeboNode()
52        future = node.send_pose_to_gazebo()
53        rclpy.spin_until_future_complete(node, future)
54
55    except KeyboardInterrupt:
56        pass
57    except Exception as e:
58        print(e)
59
60
61
62if __name__ == '__main__':
63    main()

There are perhaps only two unfamiliar aspects of this node by now. First, that we need to send the correct entity name for Gazebo to know which entity to set the pose. This name can be obtained in Gazebo`s GUI.

    def send_pose_to_gazebo(self):
        """Sample method sending a pose to and entity in Gazebo."""

        request = SetEntityPose.Request()

        # Add the entity name. For this one, Gazebo accepts the name. :)
        request.entity.name = "box"

        # Set the position
        request.pose.position.x = 0.0
        request.pose.position.y = 0.0
        request.pose.position.z = 50.0

        # Set the orientation
        request.pose.orientation.x = 0.0
        request.pose.orientation.y = 0.0
        request.pose.orientation.z = 0.0
        request.pose.orientation.w = 1.0

        return self.service_client.call_async(request)

The second possibly unfamiliar is the choice of rclpy.spin_until_future_complete to illustrate calling the service client only once.

def main(args=None):
    """
    The main function.
    :param args: Not used directly by the user, but used by ROS2 to configure certain aspects of the Node.
    """
    try:
        rclpy.init(args=args)

        node = SendPosesToGazeboNode()
        future = node.send_pose_to_gazebo()
        rclpy.spin_until_future_complete(node, future)

    except KeyboardInterrupt:
        pass
    except Exception as e:
        print(e)

Exercises

How would you approach this node if you had to, for instance.

  • Accept the entity information as a parameter for the node.

  • Accept the pose as a parameter from the node.

  • Set the pose of multiple entities.

The launch file#

This node will not work without a pairing parameter_bridge. We add the following launch file to the launch folder.

send_poses_to_gazebo_launch.py

 1from launch import LaunchDescription
 2from launch_ros.actions import Node
 3
 4
 5def generate_launch_description():
 6    
 7    node = Node(
 8            output='screen',
 9            emulate_tty=True,
10            package='python_package_that_uses_gazebo',
11            executable='send_poses_to_gazebo_node',
12            name='send_poses_to_gazebo_node'
13        )
14
15    bridge = Node(
16        package='ros_gz_bridge',
17        executable='parameter_bridge',
18        arguments=['/world/shapes_with_tf2_and_wrench/set_pose@ros_gz_interfaces/srv/SetEntityPose'],
19        output='screen'
20    )
21    
22    return LaunchDescription([
23        node,
24        bridge
25    ])

Sending wrenches to Gazebo#

Summary

We will make a node that does the following for us through ROS2.

gz topic -t \
/world/shapes_with_tf2_and_wrench/wrench \
-m gz.msgs.EntityWrench \
-p  'entity: {id: 9}, wrench: {force: {x: 1000.0, y: 0.0, z: 0.0}, torque: {x: 0.0, y: 0.0, z: 0.0}}'

We’ll be able to send poses to Gazebo with the following node. It is a relatively simple node with a publisher.

send_wrenches_to_gazebo_node.py

 1import time
 2from ros_gz_interfaces.msg import EntityWrench
 3
 4import rclpy
 5from rclpy.node import Node
 6
 7class SendWrenchesToGazeboNode(Node):
 8    """A ROS2 Node that sends wrenches to Gazebo."""
 9
10    def __init__(self):
11        super().__init__('send_wrenches_to_gazebo_node')
12
13        self.publisher = self.create_publisher(
14            msg_type=EntityWrench,
15            topic='/world/shapes_with_tf2_and_wrench/wrench',
16            qos_profile=1)
17
18        while not self.count_subscribers('/world/shapes_with_tf2_and_wrench/wrench'):
19            print(f"Waiting for subscriber to be connected...")
20            time.sleep(1)
21
22    def send_wrench_to_gazebo(self):
23        """Basic method publishing wrenches to Gazebo."""
24
25        ew = EntityWrench()
26
27        # Add the entity id. The entity.name is currently ignored by Gazebo.
28        ew.entity.id = 9
29
30        # Set the force
31        ew.wrench.force.x = 1000.0
32        ew.wrench.force.y = 0.0
33        ew.wrench.force.z = 0.0
34
35        # Set the torque
36        ew.wrench.torque.x = 0.0
37        ew.wrench.torque.y = 0.0
38        ew.wrench.torque.z = 0.0
39
40        self.get_logger().info(f"This sent entity {ew.entity.name} a wrench with force:"
41                               f" {ew.wrench.force} "
42                               f"and torque:"
43                               f" {ew.wrench.torque}.")
44
45
46        self.publisher.publish(ew)
47
48
49def main(args=None):
50    """
51    The main function.
52    :param args: Not used directly by the user, but used by ROS2 to configure certain aspects of the Node.
53    """
54    try:
55        rclpy.init(args=args)
56
57        node = SendWrenchesToGazeboNode()
58        node.send_wrench_to_gazebo()
59        rclpy.spin(node)
60
61    except KeyboardInterrupt:
62        pass
63    except Exception as e:
64        print(e)
65
66
67
68if __name__ == '__main__':
69    main()

The only unfamiliar aspect is shown below. The id of the Gazebo entity must be sent. You can obtain that information from Gazebo's GUI. Note that here, trying to send a name instead is inconsequential and Gazebo won’t recognise it.

    def send_wrench_to_gazebo(self):
        """Basic method publishing wrenches to Gazebo."""

        ew = EntityWrench()

        # Add the entity id. The entity.name is currently ignored by Gazebo.
        ew.entity.id = 9

        # Set the force
        ew.wrench.force.x = 1000.0
        ew.wrench.force.y = 0.0
        ew.wrench.force.z = 0.0

        # Set the torque
        ew.wrench.torque.x = 0.0
        ew.wrench.torque.y = 0.0
        ew.wrench.torque.z = 0.0

        self.get_logger().info(f"This sent entity {ew.entity.name} a wrench with force:"
                               f" {ew.wrench.force} "
                               f"and torque:"
                               f" {ew.wrench.torque}.")


        self.publisher.publish(ew)

Wait… what?

Yes, for wrenches, we need the id. For the pose, we use the name. Such is life.

Exercises

How would you approach this node if you had to, for instance.

  • Accept the entity information as a parameter for the node.

  • Accept the wrench as a parameter from the node.

  • Publish the wrench of multiple entities.

  • Define the wrench based on other information?
    • The next section shows one example of it.

The launch file#

This node will not work without a pairing parameter_bridge. We add the following launch file to the launch folder.

send_wrenches_to_gazebo_launch.py

 1from launch import LaunchDescription
 2from launch_ros.actions import Node
 3
 4
 5def generate_launch_description():
 6    
 7    node = Node(
 8            output='screen',
 9            emulate_tty=True,
10            package='python_package_that_uses_gazebo',
11            executable='send_wrenches_to_gazebo_node',
12            name='send_wrenches_to_gazebo_node'
13        )
14
15    bridge = Node(
16        package='ros_gz_bridge',
17        executable='parameter_bridge',
18        arguments=['/world/shapes_with_tf2_and_wrench/wrench@ros_gz_interfaces/msg/EntityWrench]gz.msgs.EntityWrench'],
19        output='screen'
20    )
21    
22    return LaunchDescription([
23        node,
24        bridge
25    ])

Controlling thrust of shapes#

Lastly, we can have a slightly more complex example. In this example, we read the poses through tf2. Then, using that pose information, we publish a wrench. This way, we can attempt to move a shape to a given place in the scene.

This example highlights one possible way of interacting with Gazebo.

control_shape_thrust_node.py

  1import time
  2from ros_gz_interfaces.msg import EntityWrench
  3
  4import rclpy
  5from rclpy.node import Node
  6
  7import tf2_ros
  8
  9class ControlShapeThrustNode(Node):
 10    """A ROS2 Node that controls the thrust of a shape in Gazebo.
 11       It will apply force based on the position of an entity."""
 12
 13    def __init__(self):
 14        super().__init__('control_shape_thurst_node')
 15
 16        # Structured easily to be changed into configurable parameters
 17        self._gazebo_world_name = "shapes_with_tf2_and_wrench"
 18        self._wrench_topic = f'/world/{self._gazebo_world_name}/wrench'
 19        self._gazebo_entity_name = "box" # Find this in Gazebo
 20        self._gazebo_entity_id = 9 # Find this in Gazebo
 21
 22        # Set up the wrench publisher
 23        self.wrench_publisher = self.create_publisher(
 24            msg_type=EntityWrench,
 25            topic=self._wrench_topic,
 26            qos_profile=1)
 27
 28        # This is one way to prevent published messages from being lost, but we don't know what is subscribing.
 29        while not self.count_subscribers(self._wrench_topic):
 30            print(f"Waiting for subscriber to be connected to {self._wrench_topic}...")
 31            time.sleep(1)
 32
 33        # Setting up the TransformListener.
 34        self.transform_listener_buffer = tf2_ros.Buffer()
 35        self.transform_listener = tf2_ros.TransformListener(self.transform_listener_buffer, self)
 36
 37        # Information about the transform we want to listen to
 38        self.parent_name = self._gazebo_world_name # It will be populated this way by Gazebo
 39        self.child_name = self._gazebo_entity_name # It will be populated this way by Gazebo
 40
 41        self.timer_period: float = 0.001
 42        self.timer = self.create_timer(self.timer_period, self.timer_callback)
 43
 44    def compute_control_action(self, current, target, proportional_gain:float = 5.0) -> float:
 45        """A simple proportional controller"""
 46        error = current - target
 47        return -proportional_gain * error
 48
 49    def send_force_to_gazebo(self, force: tuple[float, float, float] = (0, 0, 0)) -> None:
 50        """Basic method to send force to an entity."""
 51
 52        ew = EntityWrench()
 53
 54        # Add the entity id. The entity.name is currently ignored by Gazebo.
 55        ew.entity.id = self._gazebo_entity_id
 56
 57        # Set the force
 58        ew.wrench.force.x = force[0]
 59        ew.wrench.force.y = force[1]
 60        ew.wrench.force.z = force[2]
 61
 62        # Set the torque
 63        ew.wrench.torque.x = 0.0
 64        ew.wrench.torque.y = 0.0
 65        ew.wrench.torque.z = 0.0
 66
 67        self.wrench_publisher.publish(ew)
 68
 69    def timer_callback(self):
 70        """The timer callback will look up the transforms and send the next control action."""
 71
 72        try:
 73            tfs =   self.transform_listener_buffer.lookup_transform(
 74                    self.parent_name,
 75                    self.child_name,
 76                    rclpy.time.Time())
 77
 78            # A simple proportional controller for the x-axis force
 79            u = self.compute_control_action(tfs.transform.translation.x, -3.0)
 80
 81            self.send_force_to_gazebo(
 82                (u,
 83                 0.0,
 84                 0.0)
 85            )
 86
 87        except tf2_ros.TransformException as e:
 88
 89            self.get_logger().warn(
 90                f'Could not get transform from `{self.parent_name}` to `{self.child_name}`: {e}')
 91
 92
 93def main(args=None):
 94    """
 95    The main function.
 96    :param args: Not used directly by the user, but used by ROS2 to configure certain aspects of the Node.
 97    """
 98    try:
 99        rclpy.init(args=args)
100
101        node = ControlShapeThrustNode()
102        rclpy.spin(node)
103
104    except KeyboardInterrupt:
105        pass
106    except Exception as e:
107        print(e)
108
109
110
111if __name__ == '__main__':
112    main()

We start by having multiple hard-coded values to configure the node. This is illustrative and because of how this is organised it would be easier to turn them into configurable parameters.

As you have seen in previous examples, _gazebo_world_name is used to define the topic name for the wrench and also the parent reference frame for tf2. The _gazebo_entity_name is used to get the child frame for tf2. Lastly, _gazebo_entity_id is used to send the wrench to the correct entity.

        # Structured easily to be changed into configurable parameters
        self._gazebo_world_name = "shapes_with_tf2_and_wrench"
        self._wrench_topic = f'/world/{self._gazebo_world_name}/wrench'
        self._gazebo_entity_name = "box" # Find this in Gazebo
        self._gazebo_entity_id = 9 # Find this in Gazebo

There is also a simple proportional controller in one dimension.

    def compute_control_action(self, current, target, proportional_gain:float = 5.0) -> float:
        """A simple proportional controller"""
        error = current - target
        return -proportional_gain * error

Lastly, in the timer_callback, we use the current coordinate value to compute the control action and send it to Gazebo.

    def timer_callback(self):
        """The timer callback will look up the transforms and send the next control action."""

        try:
            tfs =   self.transform_listener_buffer.lookup_transform(
                    self.parent_name,
                    self.child_name,
                    rclpy.time.Time())

            # A simple proportional controller for the x-axis force
            u = self.compute_control_action(tfs.transform.translation.x, -3.0)

            self.send_force_to_gazebo(
                (u,
                 0.0,
                 0.0)
            )

        except tf2_ros.TransformException as e:

            self.get_logger().warn(
                f'Could not get transform from `{self.parent_name}` to `{self.child_name}`: {e}')

Exercises

How would you approach this node if you had to, for instance.

  • Accept relevant information as a parameter for the node.

  • Control the shape via thrusts in the 2D plane.

  • Add derivative and integral control actions.

  • Control the shape in 3D.
    • How would the gravity be counterbalanced?

The bridge file#

The bridge file, added to the folder config_bridge, will manage bridging the topics with tf2 and the wrench.

control_shape_thrust.yaml

 1- ros_topic_name: "/clock"
 2  gz_topic_name: "/clock"
 3  ros_type_name: "rosgraph_msgs/msg/Clock"
 4  gz_type_name: "gz.msgs.Clock"
 5  direction: GZ_TO_ROS
 6
 7- ros_topic_name: "/tf"
 8  gz_topic_name: "/model/box/pose"
 9  ros_type_name: "tf2_msgs/msg/TFMessage"
10  gz_type_name: "gz.msgs.Pose_V"
11  direction: GZ_TO_ROS
12
13- ros_topic_name: "/world/shapes_with_tf2_and_wrench/wrench"
14  gz_topic_name: "/world/shapes_with_tf2_and_wrench/wrench"
15  ros_type_name: "ros_gz_interfaces/msg/EntityWrench"
16  gz_type_name: "gz.msgs.EntityWrench"
17  direction: ROS_TO_GZ

The launch file#

This node will not work without a pairing parameter_bridge. We add the following launch file to the launch folder.

control_shape_thrust_launch.py

 1import os
 2from ament_index_python.packages import get_package_share_directory
 3from launch import LaunchDescription
 4from launch_ros.actions import Node
 5
 6
 7def generate_launch_description():
 8    
 9    this_package_share_directory = get_package_share_directory('python_package_that_uses_gazebo')
10
11    node = Node(
12            output='screen',
13            emulate_tty=True,
14            package='python_package_that_uses_gazebo',
15            executable='control_shape_thrust_node',
16            name='control_shape_thrust_node'
17        )
18
19    bridge = Node(
20        package='ros_gz_bridge',
21        executable='parameter_bridge',
22        parameters=[{
23            'config_file': os.path.join(this_package_share_directory, 'config_bridge', 'control_shape_thrust.yaml')
24        }],
25        output='screen'
26    )
27    
28    return LaunchDescription([
29        node,
30        bridge
31    ])

Adjusting the setup.py#

This file will include the directives for all the three nodes, added in entry_points. We also have on L15 the directive for the launch files. Lastly, on L16, we have a directive to install the bridge configuration file which is used by control_shape_thrust_launch.py.

setup.py

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

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#

We first open and run the simulation.

gz sim $HOME/gazebo_tutorial_workspace/scenes/shapes_with_tf2_and_wrench.sdf

It is possible to run each of these examples in this particular order and see their effects.

We can run the first example with the following command. Then, stop it with CTRL+C before moving to the following one.

ros2 launch python_package_that_uses_gazebo send_poses_to_gazebo_launch.py
Output for send_poses_to_gazebo_launch.py
[INFO] [launch]: All log files can be found below /home/murilo/.ros/log/2025-11-12-10-29-03-415524-murilo-VMware20-1-6420
[INFO] [launch]: Default logging verbosity is set to INFO
[INFO] [send_poses_to_gazebo_node-1]: process started with pid [6423]
[INFO] [parameter_bridge-2]: process started with pid [6424]
[parameter_bridge-2] [INFO] [1762943343.652578097] [ros_gz_bridge]: Creating ROS->GZ service bridge [/world/shapes_with_tf2_and_wrench/set_pose (ros_gz_interfaces/srv/SetEntityPose -> /)]
[INFO] [send_poses_to_gazebo_node-1]: process has finished cleanly [pid 6423]
^C[WARNING] [launch]: user interrupted with ctrl-c (SIGINT)
[parameter_bridge-2] [INFO] [1762943347.930139121] [rclcpp]: signal_handler(signum=2)
[INFO] [parameter_bridge-2]: process has finished cleanly [pid 6424]

We can run the second example with the following command. Then, stop it with CTRL+C before moving to the following one.

ros2 launch python_package_that_uses_gazebo send_wrenches_to_gazebo_launch.py
Output for send_wrenches_to_gazebo_launch.py
[INFO] [launch]: All log files can be found below /home/murilo/.ros/log/2025-11-12-10-29-35-514265-murilo-VMware20-1-6504
[INFO] [launch]: Default logging verbosity is set to INFO
[INFO] [send_wrenches_to_gazebo_node-1]: process started with pid [6507]
[INFO] [parameter_bridge-2]: process started with pid [6508]
[parameter_bridge-2] [INFO] [1762943375.599973888] [ros_gz_bridge]: Creating ROS->GZ Bridge: [/world/shapes_with_tf2_and_wrench/wrench (ros_gz_interfaces/msg/EntityWrench) -> /world/shapes_with_tf2_and_wrench/wrench (gz.msgs.EntityWrench)] (Lazy 0)
[send_wrenches_to_gazebo_node-1] Waiting for subscriber to be connected...
[send_wrenches_to_gazebo_node-1] [INFO] [1762943376.818068638] [send_wrenches_to_gazebo_node]: This sent entity  a wrench with force: geometry_msgs.msg.Vector3(x=1000.0, y=0.0, z=0.0) and torque: geometry_msgs.msg.Vector3(x=0.0, y=0.0, z=0.0).
[parameter_bridge-2] [INFO] [1762943376.824228506] [ros_gz_bridge]: Passing message from ROS ros_gz_interfaces/msg/EntityWrench to Gazebo gz.msgs.EntityWrench (showing msg only once per type)
^C[WARNING] [launch]: user interrupted with ctrl-c (SIGINT)
[parameter_bridge-2] [INFO] [1762943378.394190781] [rclcpp]: signal_handler(signum=2)
[INFO] [parameter_bridge-2]: process has finished cleanly [pid 6508]
[INFO] [send_wrenches_to_gazebo_node-1]: process has finished cleanly [pid 6507]

We can run the third example with the following command. Then, stop it with CTRL+C.

ros2 launch python_package_that_uses_gazebo control_shape_thrust_launch.py
Output for control_shape_thrust_launch.py
[INFO] [launch]: All log files can be found below /home/murilo/.ros/log/2025-11-12-10-30-17-199931-murilo-VMware20-1-6604
[INFO] [launch]: Default logging verbosity is set to INFO
/home/murilo/git_another/ROS2_Tutorial/ros2_tutorial_workspace/install/python_package_that_uses_gazebo/share/python_package_that_uses_gazebo/config_bridge/control_shape_thrust.yaml
[INFO] [control_shape_thrust_node-1]: process started with pid [6607]
[INFO] [parameter_bridge-2]: process started with pid [6608]
[control_shape_thrust_node-1] Waiting for subscriber to be connected to /world/shapes_with_tf2_and_wrench/wrench...
[parameter_bridge-2] [INFO] [1762943418.299653851] [ros_gz_bridge]: Creating GZ->ROS Bridge: [/clock (gz.msgs.Clock) -> /clock (rosgraph_msgs/msg/Clock)] (Lazy 0)
[parameter_bridge-2] [INFO] [1762943418.304154168] [ros_gz_bridge]: Creating GZ->ROS Bridge: [/model/box/pose (gz.msgs.Pose_V) -> /tf (tf2_msgs/msg/TFMessage)] (Lazy 0)
[parameter_bridge-2] [INFO] [1762943418.316157126] [ros_gz_bridge]: Creating ROS->GZ Bridge: [/world/shapes_with_tf2_and_wrench/wrench (ros_gz_interfaces/msg/EntityWrench) -> /world/shapes_with_tf2_and_wrench/wrench (gz.msgs.EntityWrench)] (Lazy 0)
[parameter_bridge-2] [INFO] [1762943418.596583200] [ros_gz_bridge]: Passing message from ROS ros_gz_interfaces/msg/EntityWrench to Gazebo gz.msgs.EntityWrench (showing msg only once per type)
^C[WARNING] [launch]: user interrupted with ctrl-c (SIGINT)
[parameter_bridge-2] [INFO] [1762943424.457110149] [rclcpp]: signal_handler(signum=2)
[INFO] [parameter_bridge-2]: process has finished cleanly [pid 6608]
[INFO] [control_shape_thrust_node-1]: process has finished cleanly [pid 6607]

This is illustrated in the video below.