17.3. 案例分析:使用机器人操作系统

在这一章节中,我们将带领大家安装ROS2并配置好使用环境,然后再通过一些简单的代码示例来让大家更深入的了解如何使用ROS2和上一章节所介绍的概念。

在本章节以及本章后续的案例章节中,我们将使用ROS2 Foxy Fitzroy(笔者撰写时的最新的ROS2 LTS版本),Ubuntu Focal(20.04)和Ubuntu Focal系统所带的Python 3.8(笔者的Ubuntu Focal所带的是3.8.10)。 其中ROS2 Foxy Fitzroy和Ubuntu Focal是官方的搭配,而如果你采用debian安装的方式(官方推荐方式)来安装ROS2的话,则Python必须使用Ubuntu所带的Python3版本。 这是因为debian安装方式会将很多ROS2的Python依赖库以apt install(而非pip install)的方式安装到Ubuntu自带的Python3路径中去。 这也就是说,当你选定ROS2版本后,你所需的Ubuntu版本和Python版本也就随之确定了。

如果想要使用Python虚拟环境(virtual env)的话,也必须指定使用Ubuntu系统所带的Python解释器(interpreter),并在创建时加上site-packages选项。添加这个选项是因为我们需要那些安装在系统Python3路径中的ROS2的依赖库。

举例来说,对于pipenv用户,可以通过下面这条命令来创建一个使用系统Python3并添加了site-packages的虚拟环境。

pipenv --python $(/usr/bin/python3 -V | cut -d" " -f2) --site-packages

因为要使用系统Python3的原因,用conda创建的虚拟环境可能会出现各种不兼容的问题。

对于其它版本的ROS2,安装过程和使用方式基本相同。

在本章节以及本章后续的案例章节中,我们在合适的场合将用ROS2,Ubuntu和Python来分别指代ROS2 Foxy Fitzroy,Ubuntu Focal和Ubuntu Focal所带的Python 3.8。

本章节中的案例有参考ROS2的官方教程。这个官方教程讲解的非常详细,非常适合初学者入门ROS2。

17.3.1. 安装ROS2 Foxy Fitzroy

在Ubuntu上安装ROS2相对简单,绝大多数情况跟随官方教程安装即可。

17.3.1.1. 系统区域(locale)需要支持UTF-8

在开始安装之前,我们需要先确保我们Ubuntu系统的区域(locale)已经设置成了支持UTF-8的值。 我们可以通过locale命令来查看目前的区域(locale)设置。 如果LANG的值是以.UTF-8结尾的话,则代表系统已经是支持UTF-8的区域(locale)设置了。 否则,可以使用下面的命令来将系统的区域(locale)设置为支持UTF-8的美式英语。 想设置成其它语言只需更改相应的语言代码即可。

sudo apt update && sudo apt install locales
sudo locale-gen en_US en_US.UTF-8
sudo update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8
export LANG=en_US.UTF-8

17.3.1.2. 设置软件源

我们还需要将ROS2的软件源加入到系统中。我们可以通过下面这些命令完成这点。

sudo apt update && sudo apt install curl gnupg2 lsb-release
sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key  -o /usr/share/keyrings/ros-archive-keyring.gpg

echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(source /etc/os-release && echo $UBUNTU_CODENAME) main" | sudo tee /etc/apt/sources.list.d/ros2.list > /dev/null

17.3.1.3. 安装ROS2

现在我们可以开始安装ROS2了。我们可以先更新软件源缓存,然后再安装ROS2 Desktop版。这个版本包含了ROS2框架和大部分ROS2开发常用的软件库,如RViz等,因此是首选的版本。

sudo apt update
sudo apt install ros-foxy-desktop

另外,让我们再来安装两个额外的软件,colconrosdep。前者是ROS2的编译工具,后者可以帮助我们迅速安装一个ROS2工程所需的依赖库。

sudo apt-get install python3-colcon-common-extensions python3-rosdep

到此,我们已经安装好了ROS2。但是,如果想要使用它,我们还需要一个额外的环境设置步骤。

17.3.1.4. 环境设置

对于任意安装好的ROS2(和ROS)版本,我们需要source对应的setup脚本来为对应的版本设置好所需环境,然后才能开始使用其版本。

例如,对于刚安装好的ROS2 Foxy Fitzroy,我们可以在终端中执行下面的命令来设置好ROS2所需的环境。

source /opt/ros/foxy/setup.bash

如果你用的是bash以外的shell,你可以尝试将setup的文件扩展名改为对应shell的名字。例如zsh的用户可以尝试使用source /opt/ros/foxy/setup.zsh命令。

如果你不想每次使用ROS2之前都要输入上述命令,可以尝试将这条命令加入到你的.bashrc文件中去(或者是.zshrc或其它对应的shell文件)。这样,你以后的每个新命令行终端都会自动设置到ROS2所需的环境。

这种环境设置方式的好处在于你可以放心的安装多个不同版本的ROS2(和ROS),然后只需在需要时source对应版本的setup.bash文件,从而使用这个版本的ROS2并不受其它版本的干扰。

如果你是一个Python的重度用户,上面这种将setup.bash加入到.bashrc的方式可能会对你造成一些困扰。因为你的所有virtual env从此都会自动引入ROS2的环境设置,并且ROS2所包含的python libraries也会加入到你的virtual env的路径里面去。 我相信,你可能对于virtual env会检测到ROS2的库这种情况不会感到特别开心,即使这些库并不会被用到或破坏你virtual env中程序的运行。

解决这个问题的方法也很简单。当你准备主要用Python来开发一个ROS2项目时,你可以为这个项目新建一个virtual env,然后将source /opt/ros/foxy/setup.bash这条命令加入到这个virtual env的activate脚本中去。

注意!你可能需要将这条source命令添加到脚本结尾前一些的位置或脚本最开头,要不然当你进入(activate)virtual env时你有可能会遇到下面这个错误(例如,对于pipenv的用户就需要添加到脚本结尾处的hash -r 2>/dev/null这条命令之前而不是最末尾)。

Shell for UNKNOWN_VIRTUAL_ENVIRONMENT already activated.
No action taken to avoid nested environments.

17.3.1.5. 测试安装成功

当我们执行了上述的source命令之后,我们可以测试ROS2的安装以及环境设置时成功的。

我们只需在执行了source命令的命令行中执行printenv | grep -i ^ROS。输出的结果应该包含以下三个环境变量。

ROS_VERSION=2
ROS_PYTHON_VERSION=3
ROS_DISTRO=foxy

此外,我们可以新开两个执行了source命令的终端窗口,然后分别执行以下两条命令。

终端1:

ros2 run demo_nodes_cpp talker

终端2:

ros2 run demo_nodes_py listener

如果成功安装并执行了source命令的话,我们将会看到talker显示它正在发布消息,同时listener显示它听到了这些消息。

恭喜!您已经成功安装好了ROS2并配置到了环境。下面我们将会通过几个简单的案例来展示上章节中介绍过的ROS2的核心概念。

17.3.2. ROS2节点和Hello World

在这一小节中,我们将会创建一个ROS2项目,并使用Python来编写一个Hello World案例,以便展示ROS2 Node的基本结构。

17.3.2.1. 新建一个ROS2项目

首先,在一个合适的位置新建一个文件夹。这个文件夹将是我们ROS2项目的根目录,同时也是上一章节中介绍过的“工作区”(Workspace)。这个工作区是我们自己创建的,所以它是一个Overlay Workspace。相对的,我们之前执行的source命令会帮我们准备好这个Overlay所基于的核心工作区(Underlay Workspace)。

假设我们创建了名为openmlsys-ros2的工作区。

mkdir openmlsys-ros2
cd openmlsys-ros2

然后让我们为这个工作区创建一个Python的虚拟环境(virtual env)并依照上面环境设置小节中所介绍的那样将source命令添加到虚拟环境对应的activate脚本中去。

我们默认之后所有案例章节的命令都是在这个新建的虚拟环境中执行的。

不同的虚拟环境管理工具会有不同的指令,因此这一步笔者没有提供可执行命令的示例,而是留给读者自行处理。

接下来,我们要在这个工作区文件夹内新建一个名为src的子文件夹。在这个子文件夹内,我们将会创建不同的ROS2的程序库(package)。这些程序库相互独立,但又会互相调用其他库的功能来达成整个ROS2项目想要达成的各种目的。

在创建好src文件夹后,我们可以尝试调用colcon build命令。colcon是ROS2项目常用的一个编译工具(build tool)。这个命令会尝试编译整个ROS2项目(即目前工作区内的所有的程序库)。在成功运行完命令后,我们可以发现工作区内多出了三个新文件夹:buildinstalllog。其中build内是编译过程的中间产物,install内是编译的最终产物(即编译好的库),而log内是编译过程的日志。

到此,我们已经新建好了一个ROS2项目的框架,可以开始编写具体的代码了。

17.3.2.2. 新建一个ROS2框架下的Python库

下面,让我们在src文件夹内新建一个ROS2的程序库。我们将在这个程序库内编写我们的Hello World案例。

cd src
ros2 pkg create --build-type ament_python --dependencies rclpy std_msgs --node-name hello_world_node my_hello_world

ros2命令的pkg create子项可以帮助我们快速的创建一个ROS2程序库的框架。build-type参数指明了这是一个纯Python库,dependencies参数指明了这个库将会使用rclpystd_msgs这两个依赖库,node-name参数指明了我们创建的程序库中会有一个名为hello_world_node的ROS2节点,而最后的my_hello_world则是新建程序库的名字。

进入新建好的程序库文件夹my_hello_world,我们可以看到刚运行的命令已经帮我们建好一个Python库文件夹my_hello_world。其与程序库同名,且内含__init__.py文件和hello_world_node.py文件。后者的存在是由于我们使用了node_name参数的原因。我们将在这个Python库文件夹内编写我们的Python代码。

除此之外,还有resourcetest这两个文件夹。前者帮助ROS2来定位Python程序库,因此我们不需要管它。后者用来包含所有的测试代码,并且我们可以看到里面已经有了三个测试文件。

除了这三个文件夹外,还有三个文件,package.xmlsetup.cfgsetup.py

package.xml是ROS2程序库的标准配置文件。打开后我们可以发现很多内容已经预生成好了,但是我们还需填写或更新versiondescriptionmaintainerlicense这几项的内容。在此笔者推荐大家每次新建一个ROS2库的时候都第一时间将这些信息补全。除了这些项,我们还能看到rclpystd_msgs已经被列为依赖库了,这是因为我们使用了dependencies参数的原因。如果我们要添加或修改依赖库,可以直接在package.xml内的depend列表处修改。除了最常用的depend(同时针对build,export和execution),我们还有build_dependbuild_export_dependexec_dependtest_dependbuildtool_dependdec_depend。关于package.xml的具体介绍可以参考此英文Wiki Page

setup.cfgsetup.py都是Python库的相关文件,但是ROS2也会通过这两个文件来了解怎么安装这个Python库至install文件夹以及有哪些需要注册的entry points,即可以直接用ROS2命令行命令来直接调用的程序。我们可以看到在setup.py中的entry_points项的console_scripts子项中已经将hello_world_node这个名字设置为my_hello_world/hello_world_node.py这个Python文件中main()函数的别名。我们后续就可以使用ROS2命令行命令和这个名字来直接调用这个函数。具体方式如下:

# ros2 run <package_name> <entry_point>
ros2 run my_hello_world hello_world_node

后续如果需要添加新的entry point的话可以直接在此位置添加。

除了entry point需要关注之外,我们也需要及时将setup.py中的versionmaintainermaintainer_emaildescriptionlicense项都更新好。

17.3.2.3. 第一个ROS2节点

让我们打开my_hello_world/hello_world_node.py这个Python文件,清空里面全部内容,以便于编写我们需要的代码。

首先,让我们引入必要的库:

import rclpy
from rclpy.node import Node

from std_msgs.msg import String

rclpy(ROS Client Library for Python)让我们能够通过Python来使用ROS2框架内的各种功能。而Node类则是所有ROS2节点的基类(Base Class),我们的节点类也需要继承这个基类。std_msgs则包含了ROS2预定义的一些用于框架内通信的标准信息格式,我们需要使用String这种消息格式来传递字符串信息。

接下来让我们定义我们自己的ROS2节点:

class HelloWorldNode(Node):

    def __init__(self):
        super().__init__('my_hello_world_node')
        self.msg_publisher = self.create_publisher(String, 'hello_world_topic', 10)
        timer_period = 1.
        self.timer = self.create_timer(timer_period, self.timer_callback)
        self.count = 0

    def timer_callback(self):
        msg = String()
        msg.data = f'Hello World: {self.count}'
        self.msg_publisher.publish(msg)
        self.get_logger().info(f'Publishing: "{msg.data}"')
        self.count += 1

如上所述,我们的节点类HelloWorldNode继承于Node基类。

__init__()方法中,我们先调用基类的初始化方法,并通过这个调用将我们的节点命名为my_hello_world_node。接着我们创建一个信息发布者,它可以将字符串类型的信息发布到hello_world_topic这个主题上,并且会维持一个大小为10的缓冲区。再接着我们创建一个计时器,它会每秒钟调用一次timer_callback()方法。最后,我们初始化一个计数器,来统计总共有多少条信息被发布了。

timer_callback()方法中,我们简单的创建一条带计数器的Hello World信息,并通过信息发布者发送出去。然后我们在日志中记录这次操作并将计数器加一。

定义好我们的HelloWorldNode类后,我们可以开始定义main()函数。这个函数就是我们之前在setup.py中看到的那个entry point。

def main(args=None):
    rclpy.init(args=args)
    hello_world_node = HelloWorldNode()
    rclpy.spin(hello_world_node)
    hello_world_node.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

这个main()也比较简单。我们先通过rclpy.init()方法来启动ROS2框架。然后我们创建一个HelloWorldNode的实例。接着我们通过rclpy.spin()方法将这个实例加入到运行的ROS2框架中去,让其参与ROS2的事件循环并正确运行。rclpy.spin()是一个阻碍方法,它会一直运行一直到被阻止(例如ROS2框架停止运行)。这时候我们就会摧毁我们的节点,并且确保关闭ROS2框架。如果我们忘记了摧毁不再使用的节点,不用慌,garbage collector也会帮忙摧毁这个节点。

到此,我们创建了第一个ROS2节点!

17.3.2.4. 第一次编译和运行

让我们尝试编译新编写的这个库。这里,我们并不是真的要编译一个Python项目,而是将我们写的Python库安装到一个ROS2能找到的地方。

# cd <workspace>
cd openmlsys-ros2
colcon build --symlink-install

通过在运行这个编译命令,我们会编译工作区内src文件夹下所有的Python和C++库,并将编译好的C++库和Python库安装到install文件夹下。 通过指定--symlink-install这个选项,我们要求colcon对于Python库用生成symlink的方式来代替复制安装。这样一来,我们在src中做的后续改动都会直接反应到install中去,而不用一直反复执行编译命令。

在编译成功之后,编译好的库还不能直接使用。例如你现在执行ros2 run my_hello_world hello_world_node的话很有可能会得到Package 'my_hello_world' not found这样一个结果。

为了使用编译好的库,我们需要让ROS2知道install文件夹。具体来说,我们需要sourceinstall文件夹下的local_setup.bash文件。即:

source install/local_setup.bash

有些机敏的读者可能会想到我们可以像之前添加那个setup.bash一样将这个install/local_setup.bash也加入到虚拟环境的activate脚本中去,这样我们就不用每次都单独source这个文件了。很可惜,这样会带来一些问题。

具体来说,一方面我们需要将这两个文件都source了(不管是通过activate脚本还是手动输入)才能顺利运行编译好的ROS2程序,但另一方面我们必须只source第一个setup.bash而不source第二个local_setup.bash才能顺利编译带有C++依赖项的纯Python的ROS2库。 在稍后面一点的案例中我们会看到,对于一个使用了自定义消息接口库(自己编写的C++库)的纯Python的ROS2程序库来说,必须只source第一个setup.bash而不source第二个local_setup.bash才能顺利编译。

在成功sourceinstall/local_setup.bash之后,我们就可以尝试调用写好的节点了。

从现在开始,除非特殊说明,新开一个终端窗口都是指新开一个确保``setup.bash``和``install/local_setup.bash``都已经被``source``了的终端窗口,而在工作区执行``colcon build``命令则都是在一个只``source``了``setup.bash``而忽略了``install/local_setup.bash``的终端窗口中执行此编译命令

ros2 run my_hello_world hello_world_node

我们应该会看到类似下面这样的信息:

[INFO] [1653270247.805815900] [my_hello_world_node]: Publishing: "Hello World: 0"
[INFO] [1653270248.798165800] [my_hello_world_node]: Publishing: "Hello World: 1"

我们还可以再新开一个终端窗口,然后执行ros2 topic echo /hello_world_topic。我们应该能看到类似下面的信息:

data: 'Hello World: 23'
---
data: 'Hello World: 24'
---

这代表着我们的信息确实被发布到了目标主题上。因为ros2 topic echo <topic_name>这条命令输出的就是给定名字的主题所接收到的信息。

恭喜!您已成功运行了您的第一个ROS2节点!

17.3.2.5. 一个消息订阅者节点

只是发布消息并不能组成一个完整的流程,我们还需要一个消息订阅者来消费我们发布的信息。

让我们在hello_world_node.py所在的文件夹内新建一个名为message_subscriber.py的文件,并添加以下内容:

import rclpy
from rclpy.node import Node

from std_msgs.msg import String


class MessageSubscriber(Node):

    def __init__(self):
        super().__init__('my_hello_world_subscriber')
        self.msg_subscriber = self.create_subscription(
            String, 'hello_world_topic', self.subscriber_callback, 10
        )

    def subscriber_callback(self, msg):
        self.get_logger().info(f'Received "{msg.data}"')


def main(args=None):
    rclpy.init(args=args)
    message_subscriber = MessageSubscriber()
    rclpy.spin(message_subscriber)
    message_subscriber.destroy_node()
    rclpy.shutdown()


if __name__ == "__main__":
    main()

这个新添加的文件以及其中的消息订阅者节点类和上面的HelloWorldNode类十分相似,甚至更为简单些。我们只需要在初始化时通过基类初始化方法赋予节点my_hello_world_subscriber这个名字,然后创建一个消息订阅者来订阅hello_world_topic主题下的消息,并指定subscriber_callback()方法来处理接收到的消息。而在subscriber_callback()中,我们将接收到的消息记录进日志。main()方法则和HelloWorld节点类的基本一样。

在能正式使用这个新节点之前,我们需要将其添加成为一个entry point。为此,我们只需在setup.py的对应位置添加下面这行:

'message_subscriber = my_hello_world.message_subscriber:main'

但是,添加完成之后在终端窗口运行ros2 run my_hello_world message_subscriber还是会得到No executable found这样的错误反馈。这是因为我们新增了一个entry point,必须重新编译整个ROS2项目才能让ROS2知道这个新增点。

让我们再次在工作区目录执行colcon build --symlink-install。在成功编译后,让我们新建两个终端窗口,都分别确保source好了两个setup文件。然后分别用ros2命令调用它们:

# in terminal 1
ros2 run my_hello_world hello_world_node
# in terminal 2
ros2 run my_hello_world message_subscriber

我们应该可以看到终端窗口1中会不断显示发布了第N号Hello World消息,而终端窗口2中则不断显示收到了第N号Hello World消息。

恭喜!你完成了一对ROS2节点,一个负责发送信息,一个负责订阅接受信息。

17.3.3. ROS2参数

顺利完成上面的消息发布者和消息订阅者是个很好的开始,但是实际项目的节点不会这么简单。 至少,实际项目的节点会是参数化的。下面,就让我们一起看看怎样让一个节点读取一个参数。

让我们在hello_world_node.py所在的文件夹内新建一个名为parametrised_hello_world_node.py的文件,并添加以下内容:

import rclpy
from rclpy.node import Node

from std_msgs.msg import String


class ParametrisedHelloWorldNode(Node):

    def __init__(self):
        super().__init__('parametrised_hello_world_node')
        self.msg_publisher = self.create_publisher(String, 'hello_world_topic', 10)
        timer_period = 1.
        self.timer = self.create_timer(timer_period, self.timer_callback)
        self.count = 0
        self.declare_parameter('name', 'world')

    def timer_callback(self):
        name = self.get_parameter('name').get_parameter_value().string_value
        msg = String()
        msg.data = f'Hello {name}: {self.count}'
        self.msg_publisher.publish(msg)
        self.get_logger().info(f'Publishing: "{msg.data}"')
        self.count += 1


def main(args=None):
    rclpy.init(args=args)
    hello_world_node = ParametrisedHelloWorldNode()
    rclpy.spin(hello_world_node)
    hello_world_node.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

我们可以看到,这个新的参数化HelloWorld节点类和之前的HelloWorld节点类基本相同。 唯二的区别在于:1)这个新类在初始化方法中额外通过self.declare_parameter()方法来向ROS2框架声明新的节点实例会有一个名为name的参数,并且这个参数的初始值为world;2)这个新类在timer_callback()回调函数中尝试获取这个name参数的实际值,并以这个实际值来组成要发送的信息的内容。

让我们先将这个新文件的main()方法注册为一个新的entry point。 同样的,在setup.py中的相应位置加入下面这行即可。 然后别忘了在工作区根目录下执行colcon build --symlink-install来重新编译项目。

'parametrised_hello_world_node = my_hello_world.parametrised_hello_world_node:main'

在编译完成之后,如果我们在终端中执行ros2 run my_hello_world parametrised_hello_world_node,我们将看到这个参数化HelloWorld节点将正常运行,并持续发布“Hello World: N”这样的信息。此时节点使用的是world这个初始值。

让我们在一个新的终端中执行ros2 param list,我们将看到下面的信息:

/parametrised_hello_world_node:
  name
  use_sim_time

这个信息表示parametrised_hello_world_node这个节点的确申明并使用一个name参数。 另外一个名为use_sim_time的参数是ROS2默认给与的一个参数,用来表示这个节点是否使用ROS2框架内部的模拟时间,而不是电脑的系统时间。

我们可以继续在这个终端中输入下面这个命令来将值ROS2赋予给name这个参数。

ros2 param set /parametrised_hello_world_node name "ROS2"

如果赋值成功的话,这个命令会返回Set parameter successful,并且我们可以在持续运行参数化HelloWorld节点的那个终端窗口内看到其发布的信息变为了“Hello ROS2: N”。

恭喜!你现在掌握了如何让ROS2节点(和其它类型的ROS2程序)使用参数的方法。

17.3.4. 服务端-客户端服务模式

在上一章节中我们知道了ROS2框架除了发布者-订阅者这种通信模式,还有服务端-客户端这种模式。 在这一小节中,我们将通过一个简单的串联两个字符串的服务来演示如何使用这种模式。

17.3.4.1. 自定义的服务接口

在正式开始编写服务端和客户端的代码之前,我们需要先定义好它们之间进行沟通的信息接口。

ROS2框架内有三种类型的信息接口:

  • 发布者-订阅者模式下的节点所用的消息类型接口(message/msg):这种接口只负责单向的消息传递,也只用定义单向传递的信息的格式。

  • 服务端-客户端模式下的服务节点所用的服务类型接口(service/srv):这种接口需要负责双向的消息传递,即需要定义客户端发给服务端的请求的格式和服务端发给客户端的响应的格式。

  • 动作模式下的动作节点所用的动作类型接口(action):这种接口需要负责双向的消息传递以及中间的进展反馈,即需要定义动作发起节点发给动作节点的请求的格式,动作节点发给发起节点的结果的格式,以及动作节点发给发起节点的中间进展反馈的格式。

对于前面定义的那些HelloWorld节点,我们使用的是已经预定义好的std_msgs库内的std_msgs.msg.String类型的消息类型接口。 实际上,因为消息类型接口只负责定义单向的信息格式,我们很容易找到现成的符合我们需求的类型。 但是对于服务(service)和动作(action)来说,因为涉及到定义双向沟通的格式,很多时候我们需要自己定义一个接口类型。接下来,就让我们自行定义我们的字符串串联服务将要使用的服务类型接口。

首先,让我们在工作区的src文件夹内新建一个库来专门维护自定义的消息,服务和动作类型接口。

cd openmlsys-ros2/src
ros2 pkg create --build-type ament_cmake my_interfaces

这个新建的库是一个C++库,而不是Python库。这是因为ROS2的自定义接口类型只能以C++库的方式存在。新建好库之后,记得更新package.xml中的相关项。

下面,让我们在新建的src/my_interfaces文件夹内新建三个子文件夹:msgsrvaction。这是因为一般会将自定义的接口放到相对应的子文件夹中去,以方便维护。

cd my_interfaces
mkdir msg srv action

接着,让我们在srv子目录下创建我们想要定义的服务类型接口。

cd srv
touch ConcatTwoStr.srv

然后,让我们将以下内容添加到ConcatTwoStr.srv中去:

string str1
string str2
---
string ret

其中,---之上的是客户端发给服务端的请求的格式,而之下的是服务端发给客户端的响应的格式。

定义好了接口后,我们还需要更改CMakeLists.txt以便让编译器知道有自定义接口需要编译并能找到它们。让我们打开my_interfaces/CMakeLists.txt并在if(BUILD_TESTING)这行之前添加下面的内容。

find_package(rosidl_default_generators REQUIRED)

rosidl_generate_interfaces(${PROJECT_NAME}
  "srv/ConcatTwoStr.srv"
)

上面这两段代码的主要作用是告诉编译器需要rosidl_default_generators这个库并生成我们指明的自定义接口。

在更新好CMakeLists.txt之后,我们还需要把rosidl_default_generators添加到package.xml中作为自定义接口库的依赖项。打开package.xml,在<test_depend>ament_lint_auto</test_depend>这行前添加下面内容。

<build_depend>rosidl_default_generators</build_depend>
<exec_depend>rosidl_default_runtime</exec_depend>
<member_of_group>rosidl_interface_packages</member_of_group>

更新好package.xml后,我们就可以编译这个自定义接口库了。

cd openmlsys-ros2
colcon build --packages-select my_interfaces

上述命令中我们通过--packages-select选项指定了只编译my_interfaces这一个库从而节省时间,因为my_hello_world这个库目前并没有任何更改。另外,我们没有使用--symlink-install选项是因为这个自定义接口库是一个C++库,每次更改后必须重新编译。

在运行这次的编译命令时,读者有可能会遇到ModuleNotFoundError: No module named 'XXX'这类的错误(XXX可以是emcatkin_pkglarknumpy或其它Python库)。 遇到这类错误多半是因为所使用的Python虚拟环境并不是指向Ubuntu系统Python3或site-packages并没有被包含在虚拟环境中。 读者可能需要删除当前的虚拟环境并按照本章节开头所讲解的那样重新创建一个符合要求的虚拟环境。

我们可以通过在新的终端窗口运行ros2 interface show my_interfaces/srv/ConcatTwoStr来验证是否已经编译成功了。成功的话终端会显示自定义服务接口ConcatTwoStr的具体定义。

现在,我们定义好了需要使用的服务接口,下面可以开始编写我们的服务端和客户端了。

17.3.4.2. ROS2服务端

让我们在hello_world_node.py所在的文件夹内新建一个名为concat_two_str_service.py的文件,并添加以下内容:

from my_interfaces.srv import ConcatTwoStr

import rclpy
from rclpy.node import Node


class ConcatTwoStrService(Node):

    def __init__(self):
        super().__init__('concat_two_str_service')
        self.srv = self.create_service(ConcatTwoStr, 'concat_two_str', self.concat_two_str_callback)

    def concat_two_str_callback(self, request, response):
        response.ret = request.str1 + request.str2
        self.get_logger().info(f'Incoming request\nstr1: {request.str1}\nstr2: {request.str2}')

        return response


def main(args=None):
    rclpy.init(args=args)
    concat_two_str_service = ConcatTwoStrService()
    rclpy.spin(concat_two_str_service)
    concat_two_str_service.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

我们可以发现,编写一个服务(Service)和编写一个一般的节点(Node)很相似,甚至它们都是继承自同一个基类rclpy.node.Node。在这个文件中,我们先从编译好的my_interfaces库中引入自定义的服务接口ConcatTwoStr。然后在服务端节点的初始化方法中通过self.create_service()创建一个服务器对象,并指明服务接口类型是ConcatTwoStr,服务名字是concat_two_str,处理服务请求的回调函数是self.concat_two_str_callback。而在回调函数self.concat_two_str_callback()中,我们通过request对象取得请求的str1str2,计算出结果并赋值到response对象的ret上,并进行日志记录。我们可以看到,requestresponse对象的结构符合我们在ConcatTwoStr.srv中的定义。

另外别忘记了将此文件的main()方法作为一个entry point添加到setup.py中去。

'concat_two_str_service = my_hello_world.concat_two_str_service:main'

17.3.4.3. ROS2客户端

让我们在hello_world_node.py所在的文件夹内新建一个名为concat_two_str_client_async.py的文件,并添加以下内容:

import sys

from my_interfaces.srv import ConcatTwoStr
import rclpy
from rclpy.node import Node


class ConcatTwoStrClientAsync(Node):

    def __init__(self):
        super().__init__('concat_two_str_client_async')
        self.cli = self.create_client(ConcatTwoStr, 'concat_two_str')
        while not self.cli.wait_for_service(timeout_sec=1.0):
            self.get_logger().info('service not available, waiting again...')
        self.req = ConcatTwoStr.Request()

    def send_request(self):
        self.req.str1 = sys.argv[1]
        self.req.str2 = sys.argv[2]
        self.future = self.cli.call_async(self.req)


def main(args=None):
    rclpy.init(args=args)

    concat_two_str_client_async = ConcatTwoStrClientAsync()
    concat_two_str_client_async.send_request()

    while rclpy.ok():
        rclpy.spin_once(concat_two_str_client_async)
        if concat_two_str_client_async.future.done():
            try:
                response = concat_two_str_client_async.future.result()
            except Exception as e:
                concat_two_str_client_async.get_logger().info(
                    'Service call failed %r' % (e,))
            else:
                concat_two_str_client_async.get_logger().info(
                    'Result of concat_two_str: (%s, %s) -> %s' %
                    (concat_two_str_client_async.req.str1, concat_two_str_client_async.req.str2, response.ret))
            break

    concat_two_str_client_async.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

相比于服务端,这个客户端较为复杂一点。在客户端节点的初始化方法中,我们先创建一个客户端对象,并指明服务接口类型是ConcatTwoStr,服务名字为concat_two_str。然后通过一个while循环,这个客户端将一直等待知道对应服务上线才会进行下一步。这个循环等待的技巧是很多客户端都会使用的。当服务端上线以后,初始化方法将创建一个服务请求对象的模板并暂存于客户端节点的req属性上。除了初始化方法,客户端节点还定义了另一个方法send_request()来读取程序启动时命令行的前两个参数,然后存入服务请求对象并异步发送给服务端。

而在main()方法中,我们先创建一个客户端并发送服务请求,然后通过一个while循环来等待服务返回结果并记录进日志。其中,rclpy.ok()是用来检测ROS2是否还在正常运行,以保证当ROS2在服务结束前就停止运行了的话,客户端这边不会陷入死循环。而rclpy.spin_once()rclpy.spin()略有不同,后者会不断执行事件循环直到ROS2停止,而前者则只会执行一次事件循环。这也是为什么前者更适合用在这里,因为我们已经有了一个while循环了。另外我们可以看到,concat_two_str_client.future对象提供了很多方法来帮助我们确定目前服务请求的状态。

同样的,别忘记了将此文件的main()方法作为一个entry point添加到setup.py中去。

'concat_two_str_client_async = my_hello_world.concat_two_str_client_async:main'

我们现在编写好了我们的服务端和客户端,让我们在工作区根目录下重新编译一边my_hello_world库。

cd openmlsys-ros2
colcon build --packages-select my_hello_world --symlink-install

然后让我们在两个新的终端窗口中分别运行以下命令。

# in terminal 1
ros2 run my_hello_world concat_two_str_client_async Hello World
# in terminal 2
ros2 run my_hello_world concat_two_str_service

如果一切正常的话,我们应该看到类似以下的信息。

# in terminal 1
[INFO] [1653525569.843701600] [concat_two_str_client_async]: Result of concat_two_str: (Hello, World) -> HelloWorld
# in terminal 2
[INFO] [1653516701.306543500] [concat_two_str_service]: Incoming request
str1: Hello
str2: World

恭喜!您现在已经了解如何在ROS2框架中新建自定义的接口类型和创建服务端节点和客户端节点了!

17.3.5. 动作模式

在上一章节中我们了解了ROS2框架内的服务端-客户端模式。这样一来,我们只剩下动作(action)这一种模式了。 在这一小节中,我们将通过一个简单的逐个累加一个数列的每项元素来求和的动作来演示如何使用这种模式。

17.3.5.1. 自定义的动作接口

在正式开始编写动作相关的节点代码之前,我们需要先定义好动作的信息接口。

我们可以继续使用之前建好的my_interfaces库。 让我们在my_interfaces/action中新建一个MySum.action文件,并添加以下内容。

# Request
int32[] list
---
# Result
int32 sum
---
# Feedback
int32 sum_so_far

可以看到,整个信息接口十分简单。动作的请求信息只有一项类型为整数数列的项list,动作的最终结果信息只有一项类型为整数的项sum`,而中间反馈信息则只有一项类型同为整数的项`sum_so_far`,用以计算到目前位置累加的和。

接下来,让我们在CMakeLists.txt中添加这个新的信息接口。具体来说只用将"action/MySum.action"添加到rosidl_generate_interfaces()方法内的"srv/ConcatTwoStr.srv"之后即可。

最后别忘了编译所做的更改:在工作区根目录中运行colcon build --packages-select my_interface

17.3.5.2. ROS2动作服务器

让我们在hello_world_node.py所在的文件夹内新建一个名为my_sum_action_server.py的文件,并添加以下内容:

import rclpy
from rclpy.action import ActionServer
from rclpy.node import Node

from my_interfaces.action import MySum


class MySumActionServer(Node):

    def __init__(self):
        super().__init__('my_sum_action_server')
        self._action_server = ActionServer(
            self, MySum, 'my_sum', self.execute_callback
        )

    def execute_callback(self, goal_handle):
        self.get_logger().info('Executing goal...')
        feedback_msg = MySum.Feedback()
        feedback_msg.sum_so_far = 0
        for elm in goal_handle.request.list:
            feedback_msg.sum_so_far += elm
            self.get_logger().info(f'Feedback: {feedback_msg.sum_so_far}')
            goal_handle.publish_feedback(feedback_msg)
        goal_handle.succeed()
        result = MySum.Result()
        result.sum = feedback_msg.sum_so_far
        return result


def main(args=None):
    rclpy.init(args=args)

    my_sum_action_server = MySumActionServer()

    rclpy.spin(my_sum_action_server)


if __name__ == '__main__':
    main()

对于这个动作服务器节点类,类似的,我们还是在其初始化方法中新建一个动作服务器对象,并指定了之前定义的MySum作为信息接口类型,my_sum是动作名字,self.execute_callback方法则作为动作执行的回调函数。

紧接着,我们在self.execute_callback()方法中定义了当接收到了一个新目标是应做什么处理。在这里,我们可以把一个目标当作之前定义的MySum信息接口里的request部分来处理,因为这里的目标就是包含了动作请求的目的的相关信息的结构体,即request部分所定义的部分。

当我们接收到一个目标后,我们先从MySum创建一个反馈消息对象feedback_msg,并将其sum_so_far项用作一个累加器。然后我们遍历目标请求中的list项里面的数据,并这些数据逐项进行累加。每当我们累加一项后,我们都会通过goal_handle.publish_feedback()方法发送一次反馈消息。最后,当全部计算完成后,我们通过goal_handle.succeed()来标记此次动作已经成功完成,并且通过MySum新建一个结果对象,填充结果值并返回。

main()函数中,我们只需要新建一个动作服务器节点类的新实例,并调用rclpy.spin()将其加入事件循环即可。

最后别忘了将main()也添加成为一个entry point。我们只需在setup.py中适当位置添加下面行即可。

'my_sum_action_server = my_hello_world.my_sum_action_server:main'

17.3.5.3. ROS2动作客户端

让我们在hello_world_node.py所在的文件夹内新建一个名为my_sum_action_client.py的文件,并添加以下内容:

import sys
import rclpy
from rclpy.action import ActionClient
from rclpy.node import Node

from my_interfaces.action import MySum


class MySumActionClient(Node):

    def __init__(self):
        super().__init__('my_sum_action_client')
        self._action_client = ActionClient(self, MySum, 'my_sum')

    def send_goal(self, list):
        goal_msg = MySum.Goal()
        goal_msg.list = list

        self._action_client.wait_for_server()

        self._send_goal_future = self._action_client.send_goal_async(
            goal_msg, feedback_callback=self.feedback_callback
        )
        self._send_goal_future.add_done_callback(self.goal_response_callback)

    def goal_response_callback(self, future):
        goal_handle = future.result()
        if not goal_handle.accepted:
            self.get_logger().info('Goal rejected...')
            return

        self.get_logger().info('Goal accepted.')

        self._get_result_future = goal_handle.get_result_async()
        self._get_result_future.add_done_callback(self.get_result_callback)

    def get_result_callback(self, future):
        result = future.result().result
        self.get_logger().info(f'Result: {result.sum}')
        rclpy.shutdown()

    def feedback_callback(self, feedback_msg):
        feedback = feedback_msg.feedback
        self.get_logger().info(f'Received feedback: {feedback.sum_so_far}')


def main(args=None):
    rclpy.init(args=args)

    action_client = MySumActionClient()

    action_client.send_goal([int(elm) for elm in sys.argv[1:]])

    rclpy.spin(action_client)


if __name__ == '__main__':
    main()

我们可以看到,这个动作客户端节点类比上面的服务器节点类要稍许复杂些,这是因为我们要适当的处理发送请求,接受反馈和处理结果这三件事。

首先,还是类似的,我们在这个动作客户端节点类的初始化方法中新建一个动作客户端对象,并指定MySum作为消息接口类型和my_sum作为动作名称。

然后,我们申明self.send_goal()方法来负责生成并发送一个目标/请求。具体来说,我们先从MySum新建一个目标对象并将接收到的list参数赋值到目标对象的list属性上去。紧接着,让我们等待动作服务器准备就绪。当动作服务器准备就绪后,让我们异步发送目标并指定self.feedback_callback作为反馈信息回调函数。最后,我们设定self.goal_response_callback作为发送目标信息这个异步操作的回调函数。

self.goal_response_callback()这个异步发送目标信息的回调函数中,我们先检查目标请求是否被接受了,并日志记录相关结果。如果目标请求被接受了的话,我们就通过goal_handle.get_result_async()来得到处理结果这个异步操作的future对象,并通过这个future对象将self.get_result_callback设定为最终结果的回调函数。

self.get_result_callback()这个最终结果的回调函数中,我们就简单的获取累加结果并记录进日志。最后我们调用rclpy.shutdown()来结束当前节点。

相对的,在self.feedback_callback()这个反馈消息的回调函数中。我们仅仅简单的获取反馈信息的内容并记录进日志。值得注意的是,反馈消息的回调函数可能被执行多次,所以最好不要在其中写入太多的处理逻辑,而是尽量让其轻量化。

最后,在main()方法中,我们创建一个动作客户端节点类的实例,将命令行的参数转化为需要被求和的目标数列,最后调用动作客户端节点类实例的send_goal()方法并传入目标求和数列来发起求和请求。

同样的,别忘了将main()也添加成为一个entry point。我们只需在setup.py中适当位置添加下面行即可。

'my_sum_action_client = my_hello_world.my_sum_action_client:main'

我们现在编写好了我们的动作服务器和动作客户端,让我们在工作区根目录下重新编译一遍my_hello_world库。

cd openmlsys-ros2
colcon build --packages-select my_hello_world --symlink-install

然后让我们在两个新的终端窗口中分别运行以下命令。

# in terminal 1
ros2 run my_hello_world my_sum_action_client 1 2 3
# in terminal 2
ros2 run my_hello_world my_sum_action_server

如果一切正常的话,我们应该看到类似以下的信息。

# in terminal 1
[INFO] [1653561740.000499500] [my_sum_action_client]: Goal accepted.
[INFO] [1653561740.001171900] [my_sum_action_client]: Received feedback: 1
[INFO] [1653561740.001644000] [my_sum_action_client]: Received feedback: 3
[INFO] [1653561740.002327500] [my_sum_action_client]: Received feedback: 6
[INFO] [1653561740.002761600] [my_sum_action_client]: Result: 6
# in terminal 2
[INFO] [1653561739.988907200] [my_sum_action_server]: Executing goal...
[INFO] [1653561739.989213900] [my_sum_action_server]: Feedback: 1
[INFO] [1653561739.989549000] [my_sum_action_server]: Feedback: 3
[INFO] [1653561739.989855400] [my_sum_action_server]: Feedback: 6

恭喜!您现在已经了解如何在ROS2框架中新建自定义的接口类型和创建动作服务端节点和动作客户端节点了!