从零开始的-ROS2-指南-全-

从零开始的 ROS2 指南(全)

原文:zh.annas-archive.org/md5/bf8811360e8e4fe6319d15ed2bf23ef1

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

在 2016 年,当我共同创立一家机器人初创公司时,我在寻找构建新六轴机器人手臂软件的工具和技术,偶然发现了 ROS。不知何故,我有一种直觉,认为 ROS 似乎正是我一直在寻找的东西。然而,我并不能真正理解它是什么,或者它在做什么。

我花了很长时间才理解 ROS,在学习的过程中,我意识到这个过程有多么痛苦。当时(现在也是如此)缺乏清晰的教学材料和在线资源,尤其是对于初学者。随着我多年来继续使用 ROS,我也意识到这不仅仅是我一个人遇到的问题——许多其他开发者仍然感到迷茫。这促使我创建在线课程来教授 ROS 和其他相关主题,目标是让 ROS 对每个人来说都更容易接触。

几年后,这本书是这个过程的延续。随着在 ROS(或 ROS 2——稍后会解释这个区别)的使用和教学方面积累了更多经验,我写了这本我希望在刚开始时就能拥有的书。在撰写这本书的过程中,我尽可能地站在初学者的角度,并避免学习过程中常见的两个障碍。

首先,在技术圈中,你有时会看到一些专家表现出的有毒行为,他们对你不屑一顾,会说些“你怎么连这么基本的东西都不会——这太简单了?”之类的话,或者用术语快速解释事情,然后在你不理解时让你感到愚蠢。这种行为没有帮助,也不会激励你学习。

第二,我不知道为什么,但很多人喜欢把事情搞得很复杂,这不仅仅与 ROS 或技术有关。大多数时候,一旦概念被清晰地理解,它们可以非常简单地解释。如果不需要,就没有必要让它们听起来很复杂,也没有必要花一个小时解释某件事,如果五分钟就能完成。这会产生噪音和困惑。

在这本书中,我想做的是相反的事情:不因技能不足而评判,优先提供清晰简洁的解释。通过这种方式,我希望你能高效地学习,完成这本书后比现在更少困惑,更有动力继续你的 ROS 2 和机器人之旅。

这本书面向的对象

这本书是为工程师、研究人员、学生、教师、开发者和希望以高效方式从头开始学习 ROS 2、不浪费时间的人准备的。

即使你不需要在某个领域成为专家,但这本书并不是为软件工程领域的完全新手准备的。你需要具备 Linux 和 Python 的良好基础——C++是可选的。对这些技术的良好掌握和一些经验将使你的学习更加容易。

不需要 ROS(或 ROS 1)的经验。

这本书涵盖的内容

第一章ROS 2 简介——ROS 2 是什么?,解释了 ROS 2 究竟是什么,并消除了你可能会有的大部分疑问和困惑。

第二章安装和设置 ROS 2,引导你完成 Ubuntu、ROS 2 和附加工具的安装和设置,这样你就有了一切使用 ROS 2 所需的东西。

第三章揭示 ROS 2 核心概念,通过实验和动手发现介绍了主要的 ROS 2 概念,目标是培养对事物工作方式的直觉。

第四章编写和构建 ROS 2 节点,展示了如何编写 ROS 2 程序,安装它们,并运行它们。Python 和 C++都被使用,并给出了额外的挑战来让你练习(以下章节也是如此)。

第五章主题——节点之间通过主题发送和接收消息,解释了如何通过主题在两个节点之间进行通信。我们首先使用现实生活中的类比来解释这个概念,然后深入代码。

第六章服务——节点之间的客户端/服务器交互,遵循了上一章的相同大纲——这次是针对 ROS 2 中第二重要的通信类型。

第七章动作——当服务不足时,介绍了 ROS 2 的第三种也是最后一种通信类型。这一章稍微复杂一些,在第一次阅读时可以跳过。

第八章参数——使节点更具动态性,展示了如何向你的节点添加参数,以便在运行时提供不同的设置。

第九章启动文件——一次性启动所有节点,为你提供了一种从单个文件启动完整 ROS 2 应用程序的方法。

第十章使用 RViz 发现 TFs,介绍了一个最重要的概念之一,这样你就可以跟踪机器人随时间变化的不同坐标。这将是几乎任何你创建的 ROS 2 应用程序的骨架。

第十一章为机器人创建 URDF,让你开始一个新的项目,在这个项目中,你将使用 ROS 2 创建一个自定义机器人。使用 URDF,你可以创建机器人的描述。

第十二章发布 TFs 和打包 URDF,解释了如何正确打包你的应用程序并生成所需的 TFs,这要归功于你创建的 URDF。

第十三章在 Gazebo 中模拟机器人,教你如何将机器人适配到 Gazebo(3D 模拟工具),如何生成机器人,以及如何控制它,以便得到尽可能接近真实机器人的模拟。

第十四章更进一步——下一步做什么,为你提供了在完成本书后,根据个人目标可以选择的不同路径的更多视角。

要充分利用本书

您需要以下基本知识:

  • Linux,特别是如何使用命令行(带有自动完成)和用文本编辑器编写代码,以及您应该了解一些关于文件系统和环境如何工作的知识(例如.bashrc文件)。

  • Python 编程:大部分代码将使用 Python 3 编写,采用面向对象编程。您的 Python 技能越好,就越容易。

  • C++编程:您可以决定只通过遵循 Python 示例开始,因此不需要 C++。如果您还想遵循 C++示例,当然需要 C++。

关于软件和操作系统,您需要在您的计算机上安装 Ubuntu(最好是双启动,也可以在虚拟机上运行)。本书针对 Ubuntu 24.04 和 ROS 2 Jazzy,但您也应该能够从后续版本中获得最大收益。本书将提供有关如何安装 Ubuntu(在虚拟机上)和 ROS 2 的逐步说明。

如果您使用的是本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将帮助您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub(github.com/PacktPublishing/ROS-2-from-Scratch)下载本书的示例代码文件。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“例如,如果您想在 ROS Jazzy 上安装abc_def软件包,那么您需要运行sudo apt install ros-jazzy-abc-def。”

代码块设置如下:

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

entry_points={
    'console_scripts': [
        "test_node = my_py_pkg.my_first_node:main"
    ],
},

任何命令行输入或输出都应如下编写:

$ sudo apt update
$ sudo apt upgrade

粗体:表示新术语、重要单词或屏幕上出现的单词。例如,菜单或对话框中的单词以粗体显示。以下是一个示例:“要启动虚拟机,请在VirtualBox 管理器中双击它,或者选择它并点击开始按钮。”

小贴士或重要注意事项

看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及本书标题。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packtpub.com 与我们联系,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了从零开始学习 ROS 2,我们非常乐意听到您的想法!请点击此处直接进入亚马逊评论页面为这本书分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载这本书的免费 PDF 副本

感谢您购买这本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在,随着每本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到您的收件箱。

按照以下简单步骤获取福利:

  1. 扫描二维码或访问以下链接

留下评论的二维码

packt.link/free-ebook/9781835881408

  1. 提交您的购买证明

  2. 就这些!我们将直接将您的免费 PDF 和其他福利发送到您的电子邮件。

第一部分:ROS 2 入门

在本第一部分,您将获得 ROS 2 的全局概述,了解它是什么,何时使用它,以及为什么。您将在您的计算机上安装 ROS 2,以及所有必要的工具,并通过动手实验来发现核心概念。

本部分包含以下章节:

  • 第一章ROS 2 简介 – ROS 2 是什么?

  • 第二章安装和设置 ROS 2

  • 第三章揭示 ROS 2 核心概念

第一章:ROS 2 简介——什么是 ROS 2?

机器人操作系统ROS)可能会让人困惑,正如其名称所示。很难知道它确切是什么,它包含什么,以及它能做什么。此外,为什么你需要 ROS,以及何时应该使用它?

在开始之前,感到困惑是正常的——大多数人都会这样。尽管 ROS 是学习和开发机器人应用的最佳工具之一,但它也伴随着陡峭的学习曲线,第一个障碍就是理解它是什么。

在这个简短的第一章中,我将解释我们将在这本书中使用的术语。然后你会看到 ROS 为什么存在,以及它能为你解决什么问题。之后,我们将深入探讨 ROS 的四个支柱,以了解它是什么。你还会看到一些例子,说明何时以及何时不应使用它。

到本章结束时,你将更好地理解 ROS 背后的全局图景,并对最常见的困惑有清晰的认识。你还将了解在开始使用 ROS 之前你需要哪些先决条件,以及如何遵循这本书以充分利用它。这将帮助你迈出正确的第一步。

在本章中,我们将涵盖以下主题:

  • 术语

  • 什么是 ROS,何时应该使用它,以及为什么?

  • ROS 1 与 ROS 2 的比较

  • 开始使用 ROS 2 的先决条件

  • 如何遵循这本书

术语

你可能已经看到过 ROS、ROS 1、ROS 2 以及其他各种变体(带或不带空格),这些可能会让人困惑。

让我们澄清一下:

  • ROS 1 是(曾是)ROS 的第一个版本

  • ROS 2 是 ROS 的第二个和更新的版本,并将成为本书的重点

在这本书中,我将使用以下约定:

  • ROS:当谈论 ROS 的一般概念、哲学等时

  • ROS 1:当具体谈论 ROS 的第一个版本时。然而,这将会非常罕见,因为这里的重点是 ROS 2

  • ROS 2:当谈论 ROS 的第二版时

注意

我可能会在某些时候交替使用ROSROS 2,因为我们不会在这里关注 ROS 1。

在未来(当 ROS 1 完全消失时),ROS 2 的名字可能再次变为 ROS,这并非不可能。如果你听说过 Angular,它最初是 AngularJS,之后他们发布了 Angular2,然后几年后,它简单地变成了 Angular。我猜 ROS 也会发生类似的事情,尽管这目前只是我的一个理论。

什么是 ROS,何时我们应该使用它,以及为什么?

在我们开始理解 ROS 是什么之前,让我们先了解为什么我们需要它。

为什么需要 ROS?

让我们从机器人中经常出现的一个大问题开始。

想象一下,你刚刚在工作中接到了一个新的项目,你需要开发一个机器人应用,或者你正在进行一个新的研究论文。一个非常重要的事情是要考虑到,在现实生活中,任何项目或论文都将有一个特定的持续时间,从几个月到几年不等。

现在,接下来会发生什么?

你开始设计你项目所需的机器人系统,很快就会意识到开发机器人将花费大量时间,因为所有你找到的现有解决方案都不符合你的需求。几周后,你最终确定了规格,并开始构建你的机器人。几个月后,你仍在开发轮控和导航的基本软件。你低估了仅让机器人运行所需的时间。一年或两年后,你意识到到目前为止你所做的一切只是构建了一个机器人系统,而你还没有开始你的应用程序或研究的核心功能。现在是时候加快速度了。

你尽可能地完成机器人,做一些捷径,发布你的论文或展示那个原型。在最佳情况下,你也可以通过开源许可分享你的代码,这样其他人就可以使用它,但可能不会直接使用,因为这只是满足你自身需求的代码,而不是一个包含模块化组件、文档等完整框架或库。

然后,你转向新的项目、新的工作和新的研究。别人会取代你的位置,阅读你的代码,并意识到它并不能帮助他们构建应用程序。因此,他们不得不从头开始。

这里发生的事情是你重新发明了轮子。下一个人会重复同样的循环。这比你想象的要普遍得多。人们不断地重复发明轮子。这就是 ROS 被创造出来的首要原因:在你需要创建机器人时阻止你重新发明轮子。就像你有开源框架、工具和环境来开发网站或移动应用程序一样,为什么不为机器人做同样的事情呢?这就是 ROS 背后的哲学:为机器人应用程序提供一个标准,你可以在任何机器人上使用它。

在你学习 ROS 之后,你可以将更多时间投入到你想要添加的关键功能上,而不是基础的技能学习。你将能够迅速编程新的机器人,加入现有的项目,并且能够轻松地与团队协作。

ROS 是什么?

ROS 难以定义,因为它不仅仅是一件事。坦白说,我认为你只有开始理解如何用它编写代码,才能真正理解 ROS 是什么。

我们可以从 ROS 不是什么开始了解。

ROS 不是一个操作系统。它是四个主要部分的组合:

  • 框架

  • 工具集

  • 即插即用的插件

  • 在线社区

让我们更深入地探讨这些部分。

ROS 是一个具有管道的框架

ROS 提供了一套构建应用程序的规则。正如我们将在本书中看到的,你需要创建包,然后在那些包(节点)内部编写程序。创建和编写它们以及创建工具来构建和使用它们都有特定的方式。

任何框架都附带一套特定的规则。令人印象深刻的是,在你创建了一些项目之后,任何新的项目都将更容易、更快地设置。此外,由于每个人都遵循相同的规则,你可以更轻松地在团队中工作或理解和使用他人编写的代码。

使用此框架的直接结果是,你获得了通常被称为plumbing的访问权限,这意味着节点之间的底层通信由你管理。想象一下,你正在建造一栋房子,管道或电气系统已经为你准备好了。这将节省你大量的开发时间,而且你也不需要学习如何自己来做(因此,重新发明轮子)。

总结来说,使用 ROS,你可以轻松地将你的应用程序分成不同的子程序(称为nodes)。节点之间的通信由你处理。你可以轻松测试一个组件,如果这个组件失败,它将不会影响其他正在运行的组件。ROS 是一个模块化框架。

一套工具

ROS 附带一套工具,这些工具可以帮助你更快地开发。其中,你可以找到用于构建应用程序的命令行工具,用于监控通信流程的检查工具,日志功能,图表等等。

你还获得了 3D 可视化工具来查看你的机器人正在做什么,甚至还有一个名为Gazebo的完整模拟器,它使用真实物理,这样你就可以在尝试你的机器人之前进行现实模拟。

可用的工具相当多,我们将在本书中了解到其中许多。作为一个例子,有一个(称为bags)的功能允许你保存通信流,以便稍后回放。假设你构建了一个移动机器人,需要在下雨时进行测试,然后考虑雨水因素继续开发软件。你可能不会每天都下雨,或者你甚至无法随时接触到机器人。有了这个工具,你可以运行一次实验,保存数据,然后稍后回放以开发针对特定条件的应用程序。

功能性 – 插件和堆栈即插即用

这可能是你将节省数百小时的地方。想象两个常见的场景:

  • 你开发了一个移动机器人,需要机器人能够在动态环境中自主导航。

  • 你开发了一个六轴机械臂,并希望创建运动规划以在所有轴上执行平滑运动。

这看起来相当复杂,需要理解和实现几个算法,以及编写良好优化和高效的代码。这可能是你不得不重新发明轮子并浪费大量宝贵时间的地方。

对于这两种情况,你可以找到现有的插件来为你完成工作。你所需要做的就是安装插件,并配置你的机器人使其兼容。当然,这比说起来要难,但工作量可以按天/周计算,而不是按月/年计算。而且,一旦你学会了如何使用这些插件,你的下一个项目将花费的时间会少得多。

你可以使用许多插件。有些相当简单,而有些则涉及一系列插件,也被称为框架或堆栈。作为 ROS 开发者,你的工作是粘合所有这些组件,也许为尚未开发的特性创建新的组件。

在线社区

这是 ROS 的第四个支柱,它相当重要:社区。ROS 是一个开源项目,拥有宽松的许可证。我无法就许可证提供任何法律建议,但你可以在商业产品中使用 ROS 而无需重新分发你的代码。

你可以在网上找到所有 ROS 代码,以及即插即用的插件代码。所有这些都可以在 GitHub 上轻松访问。

ROS 项目还得到了一个在线社区的支持,你通常可以在以下区域找到它:

  • 机器人技术问答社区(robotics.stackexchange.com/):你可以使用这个平台来提问技术问题。如果你熟悉 Stack Overflow,就像大多数开发者一样,那么,这就是机器人领域的 Stack Overflow。

  • ROS 论坛(discourse.ros.org/):在这里,你可以了解最新的发展、工作、社区项目、新想法等。我建议你经常检查这个网站,以保持对 ROS 走向的了解。

何时使用 ROS

现在你对 ROS 有了更多的了解,那么在你的项目中只要有“机器人”这个词,你就应该使用 ROS 吗?在本节中,我将给你一些提示,说明何时使用 ROS 是有意义的,并通过一些例子来帮助你更好地理解。

首先,如果你因为工作或大学需要学习 ROS 而阅读这本书,那么答案很简单:是的,你将在你的项目中使用 ROS。

但如果你必须自己做出决定,你应该怎么做?

让我们简化机器人技术,并说一个机器人系统包含三类事物:执行器、传感器和控制器。

执行器是产生运动的东西(例如,一个旋转轮子的电机)。传感器将从环境中读取数据(例如,摄像头、激光扫描或温度传感器)。控制器介于两者之间:它从一个或多个传感器(输入)获取可用的数据,并通过算法为机器人的执行器(输出)创建命令。从某种意义上说,控制器是机器人的大脑或大脑之一。

对于非常简单的应用,当你只有几个传感器和执行器时,你可能不需要 ROS。

这里有一些例子,说明 ROS 不是必需的:

  • 当用户按下按钮时,你只需要使用 Raspberry Pi 板从相机中拍照,并将这张照片发送到网络服务器。没有必要使用 ROS——你只需在脚本中结合几个 Python 库,就完成了。在这里使用 ROS 将是一个过度设计的例子(除非你这样做是为了学习目的)。

  • 当检测到运动时,你需要使用红外传感器打开/关闭门,这是一个非常简单的应用,可以很容易地使用基本的微控制器板编程——你可以用 Arduino 这样的板快速原型。

  • 你已经用两个轮子和一个红外传感器构建了一个简单的机器人,并想使机器人跟随一条线。这是一个典型的工程项目,通常在工程学院的学生中给出,一个简单的 Arduino 板上的算法就可以完成。

现在,让我们考虑一些需要 ROS 的例子:

  • 你有一个新的两轮激光扫描移动机器人,你想从激光扫描中读取数据,绘制环境地图,使机器人自主移动,并相应地控制两个轮子。除此之外,你还想用真实的物理属性在 3D 中模拟机器人。这时 ROS 就会变得非常有用。它不仅可以帮助你使所有组件协同工作,你还可以使用现有的路径规划算法(通过 ROS 插件)和模拟。

  • 你需要创建一个包含六轴机械臂,甚至多个机械臂协同工作,以及传送带和移动机器人的系统。

  • 你的机器人应用(不一定是单个机器人)包含大量你想要单独开发并模块化添加的传感器和执行器。

  • 你想为某个组件创建一个硬件驱动程序,并使其他机器人开发者能够轻松使用这个组件。通过使组件与 ROS 兼容,任何了解 ROS 的人都可以轻松将其集成到他们的应用中。

如前所述示例所示,ROS 并非在每次需要编程硬件或创建机器人系统时都是必需的。当然,你可以为任何应用使用它,但这就好比你要为单个静态网页使用完整的网络框架(例如 Django)一样。

从后面的示例中,你可以看到,如果你的系统变得更加复杂,如果你想与其他开发者轻松协作,或者如果你意识到系统的一个大块部分可以用一个即插即用的插件来解决,ROS 可能就是解决方案。

当然,学习它需要时间,你的第一个项目完成时间会更长,但随着经验的积累,你会变得更快。

例如,一个经验丰富的 ROS 开发者可能不到一周就能为机械臂编写自定义代码(包括机器人模型、运动规划和硬件控制),同样,对于具有导航能力的移动机器人也是如此(前提是硬件已经有一个 ROS 驱动程序)。不到一周,你就能得到一个可工作的软件原型。

ROS 1 与 ROS 2 的比较

为了明确,这本书完全是关于 ROS 2 的,而不是 ROS 1。你将从零基础开始学习 ROS 2。这个部分可能是唯一一次我会这么详细地谈论 ROS 1。

ROS 的简要故事,以及我们如何到达 ROS 2

ROS 1(最初被称为 ROS)首次开发于 2007 年。它在接下来的几年里迅速获得了人气,并呈指数级增长。

2014 年,宣布了 ROS 2 项目。简单来说,ROS 1 对于工业应用来说有点过于局限(缺乏实时支持、安全性等),并且仅用于研究/教育。为了解决这个问题,开发者决定使 ROS 更加“适合工业”,同时也使它变得更好,这是从 ROS 开始以来学到的所有经验教训。

现在,为什么创建 ROS 2 而不是仅仅对 ROS 进行一些新更改?因为更改太大,它们将完全破坏与旧版本的兼容性。因此,决定从头开始创建一个全新的 ROS,并将其命名为 ROS 2。2014 年,ROS 2 正式宣布,项目开发开始。

2017 年 12 月,发布了第一个 ROS 2 发行版,这意味着 ROS 1 和 ROS 2 开始共存。在这个时候,ROS 2 缺少许多核心功能性和插件,使其不适合严肃的项目。大多数 ROS 开发者仍在使用 ROS 1。

年复一年,ROS 2 得到了更多的发展,插件也越来越多。它的受欢迎程度开始增长。

我认为从 2022 年开始使用 ROS 2(与 ROS 1 相比)是值得的。这可能是个人观点,有些人可能不同意,但从 2022 年和ROS 2 Humble(更多关于发行版的内容见第二章)的发布开始,我们有了长期稳定发布的版本,所有主要插件和堆栈都能正常工作,这正是编写机器人程序所需要的。

同时,宣布 ROS 1 将在 2025 年 5 月结束。在此日期之后,ROS 1 仍然存在,但将不再提供支持。

2023 年是 ROS 社区从 ROS 1 转向 ROS 2 最显著的年份。现在可以肯定地说,当开发新的 ROS 应用程序时,ROS 2 是必由之路。

所以,如果你之前听说过 ROS 1 和 ROS 2,现在你知道 ROS 2 是你需要学习的,我们可以这样说,ROS 1 是一个过时的项目。但这是真的吗?

ROS 1 已经过时了吗?

理论上是这样,但在实践中,情况总有些不同。你可能知道,有几家公司仍在使用过时和遗留的技术。原因是将软件更新到新版本通常相当昂贵,也可能存在风险。这就是为什么你仍然会看到银行系统的工作机会要求具备 Cobol 语言技能,这是一种 1960 年代的编程语言,现在已经没有人使用了。

在机器人领域,情况有些相似。一些公司已经发布了使用特定版本的 ROS 1 的机器人,并且当机器人仍在市场上时,公司不会升级,仍然使用和维护之前的版本,这也被称为遗留版本。因此,2025 年的最终过渡可能还需要几年时间。

我为什么要写这篇文章?简单来说,就是想让你知道,如果你在一家已经使用 ROS 的机器人公司找到工作,即使 ROS 1 已经正式结束,你可能会遇到一些 ROS 1 项目。然而,请放心,你拥有的所有 ROS 2 知识都可以轻松迁移到 ROS 1,因为核心概念是相同的。

总结来说,对于所有新的学习、项目、研究、教学和创业,ROS 2 是你需要的。我现在将关闭 ROS 1 的这一章节,并专注于 ROS 2。如前所述,我可能会交替使用ROSROS 2,因为我们这里的目标不是 ROS 1。

开始使用 ROS 2 的先决条件

要开始使用 ROS 和这本书,你需要了解一些事情。

知识先决条件

最好你对以下内容有所了解:

  • Linux 命令行:由于我们将使用Ubuntu,熟悉 Linux 是强制性的。你不需要成为专家——你只需要了解基础知识。ROS 2 中的许多工具都涉及命令行,所以知道如何打开终端并编写基本命令将极大地帮助你。

  • Python 编程:ROS 最常用的两种语言是Python和 C++。Python 更容易上手,并且允许你更快地进行原型设计。因此,我们将使用这种语言进行所有详细解释。你需要了解 Python 基础知识,面向对象编程OOP)是一个很好的加分项,因为 ROS 2 在各个地方都大量使用 OOP。

  • 可选C++编程。尽管这本书的重点是 Python,但我仍然想包括我们做的所有事情中的 C++代码。如果你只想学习 Python,你可以忽略 C++代码,但当然,如果你想遵循 C++指令,你需要 C++基础知识(最好是面向对象编程)。

我想强调,如果你有良好的编程和 Linux 基础知识,学习 ROS 2 将会容易得多。学习 ROS 本身就已经相当具有挑战性(尽管有了这本书,目标是降低学习曲线),所以如果你是从零开始学习 ROS、Linux 和 Python,这可能会让你感到不知所措。

如果你正在阅读这些内容而你不知道如何编写 Python 函数或在终端中导航到目录,那么我真心建议你在这里暂停,花些时间学习 Python 和 Linux 基础知识,然后再回到这本书。没有必要花数百小时来做这件事,但投入一些时间确保基础知识正确将帮助你更快地完成这本 ROS 2 书。

硬件和软件

你需要一台计算机来阅读本书。关于规格,你不需要任何花哨的东西来开始使用 ROS 2。如果你可以打开带有几个标签的网页浏览器并且有顺畅的体验,那么我可以说你这台计算机足够开始使用了。

然后,稍后,根据你想要用 ROS 做什么,你可能需要一个更好的机器(例如,如果你想要使用大量传感器和图像处理来模拟多个机器人)。然而,可能最好等到你需要额外的性能时再升级。现在,最重要的事情是开始学习 ROS。

对于软件要求,我将在本书的整个过程中提供必要的安装说明。我们将使用的所有软件都是免费使用和开源的。

我们还将使用 Ubuntu 24.04,在其中我们将运行 ROS 2。安装 Ubuntu 是一个要求,但我会给你一个在第二章的回顾。

如何阅读本书

本书分为三个部分,包括 14 章。

每一章都可以单独阅读,尽管对于某一章,你需要所有之前章节的知识。

如果你是因为想要从头开始而得到这本书,那么很简单:按照书写的顺序阅读本书。我特意设计它,以便你一次学习一个概念,而不必考虑你应该采取什么方向。

然后,随着你的进步,随时可以回到任何章节来消除疑惑。我鼓励你这样做。第一次学习一个概念时,你并不一定能够掌握所有的细微之处。随着你继续阅读本书并使用这个概念以及其他新概念,你经常会遇到“顿悟时刻”,那时一切都会变得清晰。

如果你已经了解一些 ROS 2 基础知识(或者你已经阅读过这本书),那么你可以自由地跳到任何章节。如果一个章节是从我们在前几章中开发的代码库开始的,那么你将能够下载代码并从那里开始。

有一个 GitHub 仓库你可以用来阅读本书:github.com/PacktPublishing/ROS-2-from-Scratch。我们将编写的所有代码都托管在那里,所以请确保在阅读本书时密切使用这个 GitHub 仓库。我会在本书稍后解释如何使用这个仓库。

摘要

在本章的介绍部分,我们澄清了一些关于 ROS(机器人操作系统)最常见的困惑点:它的名称、它是什么以及不是什么、何时使用它以及为什么。你还了解了不同版本的 ROS(ROS 1 和 ROS 2),以及你学习了开始使用 ROS 2 所需具备的先决条件。

现在,你应该对整体情况有了更好的理解,即使一切看起来仍然有些混乱,也不要过于担心——当你使用 ROS 2 的概念和代码时,一切都会变得清晰。

现在,为了能够使用 ROS 2,我们需要安装它。这将是下一章的重点,并帮助你将你的环境 100%准备好用于 ROS 2。

第二章:安装和设置 ROS 2

在使用 ROS 2 之前,我们需要安装它并设置它。做这件事并不像只是下载和安装一个基本程序那样简单。有几个 ROS 2 版本(称为发行版),我们需要选择哪一个是最合适的。我们还需要选择一个 Ubuntu 版本,因为 ROS 和 Ubuntu 发行版是紧密相连的。

一旦你知道你需要哪个 ROS/Ubuntu 组合,你必须安装相应的 Ubuntu 操作系统OS)。尽管熟悉 Linux 是这本书的先决条件,但我仍会回顾如何在虚拟机VM)上安装 Ubuntu,以防万一,这样你就不会迷失方向,可以继续阅读这本书。

然后,我们将在 Ubuntu 上安装 ROS 2,在我们的环境中设置它,并安装额外的工具,这将使你拥有更好的开发体验。

到本章结束时,你将准备好电脑上的所有东西,以便你可以使用 ROS 2 并编写自定义程序。

即使所有安装步骤听起来有点令人畏惧,不要担心——这并不那么困难,而且每次新的安装都会变得更容易。为了给你一个概念,在有稳定的互联网连接的情况下,安装一个全新的 Ubuntu 版本大约需要 1 个小时,安装 ROS 则需要 20 分钟(其中大部分时间都花在等待安装完成上)。

在本章中,我们将涵盖以下主题:

  • 选择哪个 ROS 2 发行版

  • 安装操作系统(Ubuntu)

  • 安装 ROS 2

  • 设置 ROS 2 的开发环境

  • ROS 2 开发额外的工具

选择哪个 ROS 2 发行版

在安装 ROS 2 之前,了解你需要使用哪个发行版是很重要的。为了做出这个决定,你首先需要更多地了解 ROS 2 发行版是什么,以及每个发行版的具体特点。

什么是 ROS 2 发行版?

ROS 2 是一个持续发展的项目,不断接收新的功能或对现有功能的改进。

发行版简单地说是在某个给定点对开发进行冻结以创建一个稳定的发布版本。有了这个,你可以确信一个给定发行版的核心包不会有任何破坏性的变化。没有发行版,就不可能有一个稳定的系统,你将需要不断更新你的代码。

每年,一个新的 ROS 2 发行版都会在 5 月 23 日发布。这一天对应于世界乌龟日。正如你将能观察到的,所有 ROS 发行版都有一个乌龟作为标志;有一个名为TurtleBot的移动机器人平台,甚至还有一个名为Turtlesim的 2D 教育工具。这是基于 1967 年一个名为 Logo 的教育编程语言的参考,它包括在屏幕上移动某种乌龟机器人的功能。所以,如果你对为什么到处都是乌龟感到困惑,现在你知道了——这就是这个乌龟括号的结尾。

你可以在 ROS 2 文档发布页面上看到所有 ROS 2 发行版:docs.ros.org/en/rolling/Releases.html

你将在每年的 5 月看到一个新的发行版。至于顺序,没有数字;相反,名称是按字母顺序排列的。第一个官方发布版被命名为 Ardent Apalone,然后是 Bouncy Bolson,以此类推。2024 年 5 月,发布了 ROS Jazzy Jalisco。在此之后,你可以期待在 2025 年有 ROS K,在 2026 年有 ROS L,依此类推。新发布版的名称通常提前一年宣布。

注意

ROS 发行版包含两个名称,但通常只提到第一个。因此,我们不会谈论 ROS Jazzy Jalisco,而是会谈论 ROS Jazzy。我们也可以写成 ROS 2 Jazzy 来指明这个发行版是为 ROS 2 而不是 ROS 1,但这是不必要的,因为 Jazzy 只是一个仅用于 ROS 2 的名称,因此 ROS Jazzy。

在所有显示的发行版之上,还有一个与之并行存在的发行版:ROS Rolling。这个发行版有些特别,是所有新开发都进行的发行版。用 Git 和版本控制系统来类比,它就像有一个 开发 分支,并使用这个分支每年发布一次稳定版本。因此,ROS Rolling 不是一个稳定发行版,我不建议用它来学习或发布产品。这是一个你可以在新功能正式发布到下一个稳定发行版之前测试这些新功能的发行版——或者如果你想为 ROS 代码做出贡献。然而,如果你正在阅读这本书,你还没有达到那个阶段。

现在你已经知道了 ROS 2 的发行版有哪些,以及如何找到它们,让我们开始看看它们之间的区别。这将使我们能够选择正确的版本。

LTS 和非 LTS 发行版

如果你仔细观察,你会发现有些发行版支持 5 年,而有些发行版支持 1.5 年(这仅适用于 2022 年之后)。你可以通过比较发布日期和 生命终结EOL)日期来看到这一点。目前支持的发行版在屏幕上也有绿色背景,所以你可以很容易地找到它们。

当一个发行版达到其 EOL 日期时,这仅仅意味着它将不再接收官方支持和软件包更新。这并不意味着你不能使用它(实际上,许多公司仍在使用 5 年或更早的遗留版本),但你不会得到任何更新。

第一个官方 ROS 2 发行版是 ROS Ardent,于 2017 年 12 月发布。在那之后,前几个发行版还不完全,开发团队更愿意发布较短的发行版,以便更快地推进开发。

ROS Humble 是第一个支持 5 年的 长期支持LTS)发行版(2022-2027)。

ROS Jazzy也是一个 LTS 版本,从 2024 年到 2029 年有官方支持。从这一点可以预期,每两年(偶数年:2024 年、2026 年、2028 年等等),5 月份将发布一个新的 LTS 发行版,并支持 5 年。

一些 LTS 发行版可以共存。例如,在 2026 年,随着ROS L发行版的发布,你将能够使用 ROS Humble 和 ROS Jazzy。

然后,你有非 LTS 发行版。这些发行版在奇数年发布(2023 年、2025 年、2027 年等等),并且只支持 1.5 年。这些发行版发布是为了让你能够访问某种程度的稳定版本中的新开发,而无需等待两年。然而,由于非 LTS 发行版的寿命较短,并且它们可能不太稳定(以及支持度较低),如果你的目标是学习、教学或使用 ROS 进行商业应用,最好不要使用它们。

通过这一点,你可以看到我们可以排除一半的发行版,现在只需专注于当前受支持的 LTS 发行版。让我们完成这一部分,并选择我们将为这本书使用的发行版。

如何选择 ROS 发行版

我推荐使用最新的可用 LTS 发行版。然而,我并不一定会在它发布后立即使用 LTS 发行版,因为它可能仍然包含一些错误和问题。此外,你可能需要的某些插件和其他社区包可能还没有被移植。一般来说,如果你想与一个稳定的系统一起工作,有时最好不要过于接近新技术,而应该等待一段时间。

例如,ROS Humble 于 2022 年 5 月发布。就在它可用之后,我对其进行了测试,但为了在生产环境中使用它,我必须等到 9 月甚至 11 月,以确保一切正常工作。

因此,对于这本书,我们将使用 ROS Jazzy,该版本于 2024 年 5 月发布。

注意

你可以用一个发行版学习,然后用另一个发行版开始项目。如果你有一个需要使用不同 ROS 2 发行版的项目或工作,你仍然可以用 ROS Jazzy 开始学习。发行版之间的差距非常小,尤其是对于核心功能。这本书的 99%可以应用于 2022 年之后发布的任何 ROS 2 LTS 发行版。

安装操作系统(Ubuntu)

ROS 2 在三个操作系统上运行:Ubuntu、Windows 和 macOS。尽管 Ubuntu 和 Windows 获得一级支持,但 macOS 只有三级支持,意味着“尽力而为”,而不是“完全测试”。你可以在描述 ROS 2 发布时间表和目标平台的 REP 2000 上了解更多关于一级、二级和三级支持的含义:www.ros.org/reps/rep-2000.html

这意味着使用 macOS 进行 ROS 2 不一定是最适合学习的选择(如果你是苹果用户)。我们只剩下 Windows 或 Ubuntu。

从教学经验来看,我发现即使 ROS 在 Windows 上可以很好地工作,但正确安装和使用它并不容易。可能会出现很多错误,尤其是在 2D 和 3D 工具方面。当您学习 ROS 时,您希望有一个顺畅的体验,并且希望花时间学习功能,而不是修复配置。

因此,最佳的整体选择是使用 Ubuntu。如果您没有 Ubuntu 并且正在使用 Windows/macOS,您可以在计算机上安装 Ubuntu 作为双启动,或者使用虚拟机(还有其他一些选项,但这里不会涉及那些选项)。

既然我们已经选择了 ROS Jazzy,并且我们想在 Ubuntu 上运行它,那么问题就是:我们应该在哪个 Ubuntu 发行版上安装它?

ROS 2 与 Ubuntu 之间的关系

如果您访问 Jazzy 发布页面(docs.ros.org/en/rolling/Releases/Release-Jazzy-Jalisco.html),您会看到 ROS Jazzy 支持 Ubuntu 24.04(而不是任何其他之前的或未来的 Ubuntu 发行版)。

ROS 与 Ubuntu 发行版之间存在密切关系。这种关系相当简单:对于每个新的 Ubuntu LTS 发行版(每两年在偶数年份),都有一个新的 ROS 2 LTS 发行版:

  • Ubuntu 22.04: ROS Humble

  • Ubuntu 24.04: ROS Jazzy

  • Ubuntu 26.04: ROS L

使用正确的组合非常重要。因此,在安装 ROS Jazzy 之前,您必须做的第一件事是确保您已经在计算机上安装了 Ubuntu 24.04。如果您恰好有旧版本,我强烈建议您升级或简单地从头开始安装 Ubuntu 24.04。

注意事项

如果您必须使用另一个 Ubuntu 发行版,例如,您正在使用学校/工作场所的计算机,并且无法更改操作系统,那么请使用相应的 ROS 发行版。然而,我建议不要使用比 ROS Humble 更旧的版本,并避免使用非 LTS 发行版。您还可以在虚拟机(稍后将有描述)上安装 Ubuntu 24.04。

您可能已经在生活中某个时刻安装过 Linux 操作系统,但根据经验,我知道一些阅读此内容的人可能会在安装过程中感到困惑。因此,我将提供额外的安装说明——双启动的概述以及虚拟机的详细说明。如果您已经安装了 Ubuntu,可以自由跳过这部分内容,直接进入 ROS 2 安装部分。

使用双启动原生安装 Ubuntu 24.04

最佳选择是在您的计算机上原生安装 Ubuntu。这将使您能够跟随本书学习,并在没有任何问题的前提下继续前进。在此,我不会提供如何做到这一点的完整教程;您可以在互联网上轻松找到大量免费教程。

这里是您必须遵循的几个高级重要步骤:

  1. 在您的磁盘上腾出一些空间,以便您能够创建一个新的分区。我建议至少 70 GB,如果可能的话,更多。

  2. 从官方 Ubuntu 网站下载 Ubuntu .iso 文件(Ubuntu 24.04 LTS)。

  3. 使用 Balena Etcher 等工具将此镜像烧录到 SD 卡或 USB 闪存驱动器上。

  4. 重新启动您的计算机,并选择从外部设备启动。

  5. 按照安装说明进行操作。重要:当被问及您想要如何安装时,选择与 Windows 一起安装,例如——不要删除您的所有磁盘。

  6. 完成安装。现在,当您启动计算机时,您应该会看到一个菜单,您可以选择是否要启动 Ubuntu 或 Windows。

这些是您需要遵循的主要步骤;您可以在互联网上找到您需要的所有信息。

在虚拟机上安装 Ubuntu 24.04

如果您不能作为双启动安装 Ubuntu(由于技术限制、计算机上的管理员权限不足或其他原因),或者如果您想快速开始而不需要太多努力,只是为了学习 ROS,那么您可能希望使用虚拟机。

虚拟机(VM)的设置非常简单,对于教学和学习非常有用。例如,当我在一个初学者的研讨会中教授 ROS 离线时,我经常提供一个已经安装好所有内容的虚拟机。这样,参与者就可以更快地开始使用 ROS。后来,当他们有更多知识时,他们可以自己花时间设置一个合适的操作系统。

注意

本书第3部分(关于 3D 模拟和 Gazebo)可能不适合在虚拟机上运行。您仍然可以使用虚拟机完成本书的大部分内容,并在最后设置双启动。

现在,我将向您展示如何在虚拟机上安装 Ubuntu 24.04,这样即使您没有创建和运行虚拟机的先验知识,也可以完成它。

第 1 步 – 下载 Ubuntu .iso 文件

下载 Ubuntu 24.04 .iso文件 releases.ubuntu.com/noble/。请注意,就像 ROS 一样,Ubuntu 发行版也有一个名称。对于 Ubuntu 24.04,名称是Ubuntu Noble Numbat。我们通常只使用第一个名称,所以在这种情况下Ubuntu Noble

点击64 位 PC (AMD64) 桌面镜像。文件大小应为 5 到 6GB,因此在下载之前请确保您有一个良好的互联网连接。

第 2 步 – 安装 VirtualBox

您可以在下载 Ubuntu .iso文件的同时开始第 2 步

两个流行的虚拟机管理器有一个免费版本:VMware Workstation 和 VirtualBox。两者都可以使用,但在这里,我将专注于 VirtualBox,因为它使用起来稍微简单一些。

访问官方 VirtualBox 网站的下载页面:www.virtualbox.org/wiki/Downloads。在VirtualBox 平台包下,选择您正在运行的当前操作系统。例如,如果您想在 Windows 上安装 VirtualBox,请选择Windows 主机

下载安装程序,然后像安装其他软件一样安装 VirtualBox。

第 3 步 – 创建新的虚拟机

安装好 VirtualBox 并下载了 Ubuntu .iso文件后,打开 VirtualBox 软件(VirtualBox 管理器),然后点击新建。这将打开一个弹出窗口,您可以在其中开始配置新的虚拟机:

图 2.1 – 开始虚拟机设置过程

图 2.1 – 开始虚拟机设置过程

这里,我们有以下值:

  • Ubuntu 24.04 - book

  • 在你的用户目录中的VirtualBox VMs文件夹,那里将安装所有虚拟机。你可以保留这个文件夹或者如果你想的话可以更改它。

  • 你刚刚下载的.iso文件。

  • Linux

  • Ubuntu (64-bit)

  • 跳过无人值守安装:确保勾选此框。如果不勾选,可能会在以后造成问题。

点击下一步。在这里,你需要选择为机器分配多少 CPU 和 RAM:

图 2.2 – 为虚拟机分配硬件资源

图 2.2 – 为虚拟机分配硬件资源

这将取决于你的电脑配置。

这里是我对 RAM 分配(VirtualBox 上的基本内存)的建议:

  • 如果你电脑上有 16 GB 或更多,分配 6 GB(就像我在图 2.2中做的那样)或者更多一些;这应该足够了。

  • 如果你拥有 8 GB 的 RAM,分配 4 GB。

  • 对于少于 8 GB 的情况,调整 RAM 值(你现在可以设置一个值,稍后在设置中修改它),以便你可以启动虚拟机,打开VS Code和带有几个标签的 Firefox,并且你的机器不会慢太多。如果事情变得太慢,考虑使用更强大的机器来学习 ROS。

对于 CPU 分配,分配你 CPU 的一半。所以,如果你的电脑有 8 个 CPU,将值设置为 4。在我的设置中,我有 4 个 CPU 和 8 个逻辑处理器,所以我选择了 4 个 CPU。尽量不要低于 2,因为只有一个 CPU 将会非常慢。如果有疑问,现在尝试一个设置;你可以稍后更改它。

总体来说,保持在绿色区域更好。如果你必须推到橙色区域,那么确保当你运行虚拟机时,你的电脑上不要运行其他任何东西(或者可能只是打开一个标签的网页浏览器或用于这本书的 PDF 阅读器)。

点击下一步。现在要做的最后一件事是为虚拟机配置将要创建的虚拟硬盘:

图 2.3 – 为虚拟机创建虚拟磁盘

图 2.3 – 为虚拟机创建虚拟磁盘

这里是你必须在此屏幕上选择的设置:

  1. 你可以保留默认选项(现在创建虚拟硬盘)。默认大小是 25 GB。为了学习 ROS 2,我建议至少使用 30 到 40 GB。无论如何,虚拟机的大小将开始较低,随着你安装更多东西而扩展,所以你可以设置一个更高的最大值而不阻塞资源。

  2. 保持预分配完整大小框未勾选。

点击下一步。现在,你将看到之前步骤中选择的全部选项的摘要。然后,点击完成。你将在 VirtualBox 管理器左侧看到新创建的虚拟机。

在你开始虚拟机之前,我们还需要配置一些其他事情。打开虚拟机的设置(无论是选择它并点击设置按钮,或者右键点击虚拟机并选择设置)。

修改以下三个设置:

  • 系统 | 加速:取消选择启用嵌套分页复选框

  • 显示:取消选择启用 3D 加速复选框(这个可能已经取消选中)

  • 显示:如果可能,将视频内存增加到 128 MB

使用这些设置,你可能会避免虚拟机中图形界面的意外行为和问题。

注意

每台计算机都不同,具有不同的硬件配置。对我以及很多人有效的东西可能对你不起作用。如果你在运行虚拟机时遇到奇怪的行为,也许可以尝试通过修改前面三个设置来再次尝试。一次只测试一个更改。

步骤 4 – 启动虚拟机和完成安装

现在虚拟机已正确配置,我们需要启动它来安装 Ubuntu,使用我们已下载并添加到设置的 Ubuntu .iso 文件。

要启动虚拟机,在VirtualBox 管理器中双击它,或者选择它并点击启动按钮。

你将得到一个引导菜单。第一个选择是尝试或安装 Ubuntu,它应该已经选中。按Enter

等待几秒钟;Ubuntu 将启动安装屏幕。通过不同的窗口进行配置:

  1. 选择你的语言。我建议你保持英语,这样你就有和我相同的配置。

  2. 跳过辅助功能菜单,除非你需要设置更大的字体大小,例如。

  3. 选择你的键盘布局。

  4. 连接到互联网。为此,请选择使用有线连接

  5. 在这一点上,你可能会看到一个屏幕要求你更新安装程序。在这种情况下,点击现在更新。完成后,点击关闭安装程序。在虚拟机桌面上查找安装 Ubuntu 24.04 LTS并双击它。这将从步骤 1重新开始安装;重复步骤 1步骤 4

  6. 当被问及你希望如何安装 Ubuntu 时,请选择交互式安装

  7. 对于要安装的 Ubuntu 应用程序,请选择默认选择。这将减少虚拟机使用的空间,你仍然会得到一个网络浏览器,以及所有核心基础——安装 ROS 2 我们不需要更多。

  8. 当被问及是否要安装推荐的第三方软件时,请选择安装图形和 Wi-Fi 硬件第三方软件

  9. 对于磁盘设置,选择擦除磁盘并安装 Ubuntu。这里没有风险,因为我们正在擦除我们刚刚创建的空虚拟磁盘(如果你将 Ubuntu 作为双启动安装,你必须选择另一个选项)。

  10. 选择用户名、计算机名和密码。这里保持简单。例如,我使用 ed 作为用户名,ed-vm 作为计算机的名称。同时,确保密码输入正确,尤其是如果你之前更改了键盘布局。

  11. 选择你的时区。

  12. 在摘要菜单中,点击安装并等待几分钟。

  13. 安装完成后,一个弹出窗口将要求你重启。点击现在重启

  14. 你将看到一个消息提示请移除安装介质,然后按回车键。这里不需要做任何事情——只需按Enter键即可。

  15. 启动后,你将看到一个欢迎屏幕弹出。你可以跳过所有步骤,点击完成来退出弹出窗口。

这样,Ubuntu 就已经安装完成了。

为了彻底完成,打开一个终端窗口并升级现有包(并不是因为你刚刚安装了 Ubuntu,所以所有包都是最新的):

$ sudo apt update
$ sudo apt upgrade

安装到此结束。然而,还有一件特定于 VirtualBox 的事情我们必须做,以确保虚拟机窗口能够正确工作。

第 5 步 – 客户端添加 CD 镜像

到目前为止,如果你尝试调整运行虚拟机的窗口大小,你会看到桌面分辨率没有改变。此外,如果你尝试在主机和虚拟机之间复制/粘贴一些文本或代码,可能不会工作。

为了解决这个问题,我们需要安装所谓的客户端添加 CD 镜像

首先,打开一个终端并运行以下命令(注意,复制/粘贴目前还不工作,所以你必须手动输入)。这将安装一些依赖项,所有这些依赖项都是下一步所需的:

$ sudo apt install build-essential gcc make perl dkms

然后,在虚拟机窗口的顶部菜单中,点击设备 | 插入客户端添加 CD 镜像

你将在 Ubuntu 菜单(在左侧)中看到一个新 CD 镜像。点击它——这将打开一个文件管理器。在文件管理器中,右键点击空白区域并选择在终端中打开。以下图示将提供更多说明:

图 2.4 – 在终端中打开客户端添加 CD 镜像文件夹

图 2.4 – 在终端中打开客户端添加 CD 镜像文件夹

然后,你将进入一个终端,在那里你可以找到一个名为VBoxLinuxAdditions.run的文件。你需要以管理员权限运行此文件:

$ sudo ./VBoxLinuxAdditions.run

之后,你可能会看到一个弹出窗口要求你重启虚拟机。点击立即重启。如果你没有看到这个弹出窗口,只需在终端中运行sudo reboot即可。

当虚拟机再次启动时,当你改变窗口大小时,屏幕应该会自动调整大小。如果第一次没有成功,我发现再次运行命令(sudo ./VBoxLinuxAdditions.run)并重启可能会解决问题。

然后,你可以右键点击磁盘并选择弹出,这样就设置完成了。要启用主机和虚拟机之间的复制/粘贴功能,请转到顶部菜单并点击设备 | 共享剪贴板 | 双向

现在,你的虚拟机已经完全安装和配置。如果你选择了这条路径,这将允许你至少完成这本书的第一部分第二部分。对于第三部分,如前所述,你可能会遇到一些问题,尤其是在使用 Gazebo 时。当你到达这个阶段时,我建议你使用双启动方式本地安装 Ubuntu。

在 Ubuntu 上安装 ROS 2

现在你已经在你的计算机上安装了 Ubuntu 24.04(无论是作为双启动还是在一个虚拟机中),让我们安装 ROS 2。如前所述,我们将在这里安装 ROS Jazzy。如果你使用的是不同的 Ubuntu 发行版,请确保使用适当的 ROS 2 发行版。

安装 ROS 2 的最佳方式是遵循官方文档网站上的说明,对于二进制包安装:docs.ros.org/en/jazzy/Installation/Ubuntu-Install-Debians.html。需要运行很多命令,但别担心——你只需要逐个复制粘贴即可。

注意

由于安装说明经常更新,本书中看到的以下命令可能略与官方文档中的不同。如果这样,请从官方说明中复制/粘贴。

现在,让我们开始安装 ROS 2。

设置区域设置

确保你有支持 UTF-8 的区域设置:

$ 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

现在,再次检查;这次你会看到 UTF-8:

$ locale

设置源

逐个运行这五个命令(注意最好直接从官方文档中复制/粘贴):

$ sudo apt install software-properties-common
$ sudo add-apt-repository universe
$ sudo apt update && sudo apt install curl -y
$ 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 $(. /etc/os-release && echo $UBUNTU_CODENAME) main" | sudo\tee /etc/apt/sources.list.d/ros2.list > /dev/null

这些命令的主要目的是将 ROS 包服务器添加到你的 apt 源中。运行以下命令:

$ sudo apt update

你现在应该看到额外的行,类似于以下内容:

Get:5 http://packages.ros.org/ros2/ubuntu noble InRelease [4,667 B]
Get:6 http://packages.ros.org/ros2/ubuntu noble/main amd64 Packages [922 kB]

这些新的源(packages.ros.org)将允许你在使用 apt 安装时获取 ROS 2 包。在这里,你还可以看到 noble,这意味着 ROS 2 包是为 Ubuntu Noble(24.04)准备的。由于你知道 Ubuntu 和 ROS 发行版是如何关联的,这也意味着 ROS Jazzy。

现在我们有了正确的源,我们最终可以安装 ROS 2。

安装 ROS 2 包

作为最佳实践,在安装任何 ROS 2 包之前,请升级所有现有包:

$ sudo apt update
$ sudo apt upgrade

现在,你可以安装 ROS 2 包。如文档所示,你可以选择桌面安装ROS-Base 安装

ROS 2 不仅仅是一块软件或包。它是一系列包的集合:

  • ROS-Base:这个包包含使 ROS 2 正确工作的最基本包。

  • ROS Desktop:这个包包含 ROS-Base 中的所有包,以及许多额外的包,因此你可以访问更多工具、模拟、演示等。

由于你正在一台带有桌面的计算机上安装 ROS 2,并且你想要尽可能多地访问功能,请选择 ROS Desktop 安装。如果你要在受限环境中安装 ROS,例如在树莓派板上安装没有桌面的 Ubuntu Server,那么 ROS-Base 就是有意义的。

通过运行以下命令安装 ROS Desktop:

$ sudo apt install ros-<distro>-desktop

替换为发行版名称(使用小写)。所以,如果你想安装 ROS Jazzy,你将使用以下命令:

$ sudo apt install ros-jazzy-desktop

如你所见,当你运行这个命令时,将安装几百个新的软件包。这可能会花费一些时间,这取决于你的互联网连接速度以及你的计算机(或虚拟机)的性能。

如果你看到错误信息指出无法定位软件包 ros-jazzy-desktop,例如,这可能意味着你正在尝试在错误的 Ubuntu 版本上安装 ROS 2 发行版。这是一个常见错误,所以请确保使用正确的 Ubuntu/ROS 2 配对(如本章之前所述)。

注意

从这里,很容易看出如何安装任何其他 ROS 2 软件包。你只需写下ros,然后是发行版名称,然后是软件包名称(使用破折号,而不是下划线):ros-<distro>-package-name。例如,如果你想安装 ROS Jazzy 上的abc_def软件包,那么你需要运行sudo apt install ros-jazzy-abc-def

安装完成后,你还可以安装 ROS 开发工具。在本书的剩余部分,我们需要这些工具来编译我们的代码和创建 ROS 程序。对于此命令,不需要指定 ROS 发行版;对所有它们都一样:

$ sudo apt install ros-dev-tools

一旦完成这些,ROS 2 将被安装,你将拥有所有需要的 ROS 2 工具。

我还建议你经常更新你已安装的 ROS 2 软件包。为此,只需运行sudo apt updatesudo apt upgrade,就像更新任何其他软件包一样。

设置 ROS 2 的环境

在这一点上,打开一个终端并运行以下命令:

$ ros2
ros2: command not found

你将得到一条错误消息,指出ros2命令找不到。正如我们稍后将要看到的,ros2是一个我们可以从终端运行和测试程序的命令行工具。如果这个命令不起作用,这意味着 ROS 2 没有正确设置。

即使 ROS 2 已安装,在每次新会话(或终端)中,你想要使用 ROS 2 时,你还需要做一件事:你需要在环境中源码它。

在环境中源码 ROS 2

要做到这一点,从 ROS 2 安装的位置源码此 bash 脚本:

$ source /opt/ros/<distro>/setup.bash

替换为你当前使用的发行版名称。对于 ROS Jazzy,运行以下命令:

$ source /opt/ros/jazzy/setup.bash

在运行此命令后,尝试再次执行ros2命令。这次,你应该得到一条不同的消息(用法消息)。这意味着 ROS 2 已正确安装在您的环境中。

注意

我们将在本书的后面部分看到如何使用ros2命令行。现在,你可以用它来检查 ROS 2 是否已正确设置。

将源码行添加到.bashrc 文件

每次打开新会话或终端时,都必须源码此 bash 脚本。为了使事情变得简单,并且你不忘记它,我们只需将此命令行添加到.bashrc文件中。

如果你不知道.bashrc是什么,简单来说,它是一个 bash 脚本,每次你打开一个新的会话(可以是 SSH 会话、新的终端窗口等)时都会运行。这个.bashrc文件对每个用户都是特定的,所以你会在你的家目录中找到它(因为它以点开头,所以是一个隐藏文件)。

你可以使用以下命令将源行添加到.bashrc文件中:

$ echo 'source /opt/ros/<distro>/setup.bash' >> ~/.bashrc

替换为你的 ROS 发行版名称。你也可以直接使用任何文本编辑器(gedit、nano 或 Vim)打开.bashrc文件,并在末尾添加源行。确保只添加一次。

一旦你完成了这个步骤,每次你打开一个新的终端时,你可以确信这个终端已经被正确配置,因此你可以在其中使用 ROS 2。

现在,为了进行最后的检查,打开一个终端并运行以下命令(现在你不需要理解任何内容;这只是为了验证安装):

$ ros2 run turtlesim turtlesim_node

这将打印一些日志,你应该会看到一个中间有海龟的新窗口。要停止程序,请在运行命令的终端中按Ctrl + C

这就是安装和配置 ROS 2 的全部内容。我将给你一些建议,关于在开发 ROS 时哪些开发工具可能是有用的。

ROS 开发的额外工具

除了安装和设置 ROS 2 的必经步骤之外,任何其他开发工具都由你决定。如果你有自己喜欢使用终端、文本编辑器或集成开发环境(IDE)的方式,这完全没问题。

在本节中,我将向你展示一些许多 ROS 开发者(包括我)使用的一些工具,我认为这些工具可以帮助你在开发 ROS 时获得更好的体验。

Visual Studio Code

Visual Studio CodeVS Code)是一个非常受欢迎的 IDE,被许多开发者使用。对我们来说,它对 ROS 开发的良好支持使其变得很棒。

VS Code 是免费且开源的;你甚至可以在 GitHub 上找到它的代码。要安装 VS Code,打开终端并运行以下命令:

$ sudo snap install code --classic

安装只需要一行,并使用 Ubuntu 的 Snap 功能。安装后,你可以在 Ubuntu 应用程序中搜索它,或者在终端中简单地运行code

现在,启动 VS Code 并转到扩展面板——你可以在左侧菜单中找到它。

在那里,你可以通过输入ros来搜索 ROS 扩展。有很多选择;请选择由微软开发的那个。这个扩展与 ROS 1 和 ROS 2 都兼容,所以这里没有问题:

图 2.5 – 在 VS Code 中安装的 ROS 扩展

图 2.5 – 在 VS Code 中安装的 ROS 扩展

安装此扩展。这将还会安装一些其他扩展,特别是 Python 和 C++扩展,这在编写代码时非常有用。

此外,我还通常会安装由 twxs 提供的 CMake 扩展(只需键入 cmake,你就能找到它)。有了这个,我们在编写 CMakeLists.txt 文件时可以获得漂亮的语法高亮,这是我们使用 ROS 2 时经常会做的事情。

终端和其他工具

当你使用 ROS 2 进行开发时,你通常会需要打开几个终端:一个用于编译和安装,几个用于运行你的应用程序的不同程序,还有几个用于检查和调试。

跟踪你使用的所有终端可能会变得相当困难,因此,最佳实践是拥有一个可以轻松在一个窗口中处理多个终端的工具。

做这件事的工具有很多。这里我要介绍的是名为终结者的工具。它不仅名字有趣,而且使用起来也非常实用。

要安装 终结者,请运行以下命令:

$ sudo apt install terminator

然后,你可以在应用程序菜单中找到它,运行它,右键单击左侧栏菜单,并选择固定到仪表盘,这样它就会在那里,并且启动变得容易。

你可以在网上找到 终结者 的所有命令,但以下是一些开始时最重要的命令:

  • Ctrl + Shift + O: 水平拆分选定的终端。

  • Ctrl + Shift + E: 垂直拆分选定的终端。

  • Ctrl + Shift + X: 使当前终端填充整个窗口。再次使用以恢复。

  • Ctrl + Shift + W: 关闭一个终端。

图 2.6 – 四个终端的终结者

图 2.6 – 四个终端的终结者

每当你拆分一个终端时,这个终端就会变成两个不同的终端,每个都是一个会话。因此,你可以轻松地将你的终端拆分为四个或六个;这足以运行你大多数的 ROS 2 应用程序。由于我们之前已经在 .bashrc 文件中添加了源 ROS 2 的行,所以你可以在每个新的终端中直接使用 ROS 2。

摘要

在选择 ROS 2 发行版时,我建议你选择最新的 LTS 发行版,因为它已经有一段时间了,并且包含了你需要的所有功能。

要安装和设置 ROS 2,你首先需要安装 Ubuntu。每个 ROS 2 发行版都与特定的 Ubuntu 发行版相关联。

对于 ROS 2 Jazzy,你必须安装 Ubuntu 24.04。最佳选择是原生双启动,但为了快速开始,你也可以选择在虚拟机中安装它。然后,你可以安装 ROS 2 软件包。

之后,通过从 ROS 安装文件夹中源一个 bash 脚本来源 ROS 2 的环境非常重要。你可以将源此脚本的行添加到你的 .bashrc 文件中,这样你就不需要在每次打开新的终端时都这样做。

最后,为了获得更好的 ROS 开发体验,我建议使用带有 ROS 扩展的 VS Code,以及一个允许你将终端拆分为多个终端的工具,例如 终结者

使用这个设置,你就可以完全准备好开始使用 ROS 2 了。在下一章中,你将运行你的第一个 ROS 2 程序并发现核心概念。

第三章:揭示 ROS 2 核心概念

现在,你将开始你的第一个 ROS 2 程序。正如你将看到的,一个 ROS 2 程序被称为 节点

节点内部有什么,它做什么,节点之间是如何通信的?你如何配置节点并同时启动多个节点?

这就是我们将在本章中关注的内容。我们不会编写任何代码,而是通过实际实验来探索概念,使用与 ROS 2 一起安装的现有演示。

到本章结束时,你将对 ROS 2 的主要核心概念有一个全局的理解。你还将熟悉你将在所有项目中使用的最重要的 ROS 2 工具。

重要注意事项

在本章中,我不会解释所有内容。我们将开始一个探索阶段,在这个阶段中,我们将使用不同的核心概念并猜测它们是如何工作的。现在不是所有东西都必须有意义,如果你对某些概念仍然有些模糊,请不要过于担心。只需尝试通过运行所有命令来通过本章。

这里的目标不是获得完整的理解或记住所有命令,而是获得对事物工作方式的直观感受。这将极大地帮助你理解第二部分,当我们更详细地介绍每个概念并与之一起开发时。

在本章中,我们将涵盖以下主题:

  • 运行你的第一个节点

  • 主题

  • 服务

  • 动作

  • 参数

  • 启动文件

运行你的第一个节点

要理解节点是什么,我们将简单地运行一个节点并使用一些最有用的 ROS 2 工具进行一些观察。

对于本章,我建议打开几个终端。你可以启动几个终端窗口并将它们排列在你的屏幕上,或者使用 Terminator(见第二章中的ROS 开发额外工具)并至少打开三个标签页。为了在运行命令时消除任何混淆,我还会告诉你应该在哪个终端运行该命令(终端 1、终端 2 等)。

使用 ros2 run 从终端启动节点

让我们探索你的第一个 ROS 2 工具,可能是最重要的一个:ros2 命令行工具。你将在未来的项目中一直使用这个工具。

ros2 包含了许多功能。在本章中,我们将探索其中的一些,在后续章节中还将介绍更多。没有必要记住所有命令:现在只需使用它们来建立理解,以后你将能够轻松地从终端中检索它们。

要启动一个节点,你必须遵循以下模板:ros2 run <包名> <可执行文件>

正如我们稍后将要看到的,节点组织在包内部。这就是为什么你首先需要指定节点所在的包名以及该节点的可执行文件名。由于我们安装了 ROS 桌面,许多演示包已经包含在内,例如,demo_nodes_cpp

在终端 1 中,从 demo_nodes_cpp 包启动 talker 节点:

$ ros2 run demo_nodes_cpp talker
[INFO] [1710223859.331462978] [talker]: Publishing: 'Hello World: 1'
[INFO] [1710223860.332262491] [talker]: Publishing: 'Hello World: 2'
[INFO] [1710223861.333233619] [talker]: Publishing: 'Hello World: 3'
^C[INFO] [1710223862.456938986] [rclcpp]: signal_handler(signum=2)

在你运行此命令后,节点开始。要停止它,只需在节点运行的终端中按 Ctrl + C

那么,这里发生了什么?从我们能观察到的来看,这个节点只是一个程序,它会在终端每秒打印一条日志。

现在,保持节点存活,或者如果你停止了它,再次启动它。在另一个终端(终端 2)中,让我们启动一个不同的节点,即来自同一包的监听节点:

$ ros2 run demo_nodes_cpp listener
[INFO] [1710224252.496221751] [listener]: I heard: [Hello World: 9]
[INFO] [1710224253.497121609] [listener]: I heard: [Hello World: 10]
[INFO] [1710224254.495878769] [listener]: I heard: [Hello World: 11]

这个节点也是一个简单的程序,将在终端打印一些日志。然而,正如你所看到的,当两个节点运行时(说话者和监听者),在说话者上打印的任何内容似乎也会被监听者接收,然后打印出来。

在这个例子中,我们有两个节点正在运行,我们可以清楚地看到它们正在相互通信。如果你停止了说话节点,你会看到监听节点也会停止打印日志。当你重新启动说话节点时,监听节点开始打印说话节点“发送”的内容。

注意

这里有一些在使用 ros2 命令行工具时的提示:

尽可能多地使用自动补全。这将使你更快地输入命令,但更重要的是,你可以确保你输入了正确的命令、包名、节点名等等。

如果你对一个命令或子命令有疑问,你可以在命令中添加 -h 来从终端获取帮助。例如,使用 ros2 -h 获取全局帮助,或者使用 ros2 run -h 获取针对运行子命令的帮助。如果你知道在哪里找到信息,就没有必要记住所有的命令。

使用 rqt_graph 检查节点

在这里,我们将发现另一个非常有用的工具,它是命令行的良好补充:rqt_graph。这个工具将用漂亮的视觉效果显示所有正在运行的节点。

保持两个节点(终端 1 和 2)存活,并在终端 3 中启动 rqt_graph。命令与工具名称相同:

$ rqt_graph

这将打开一个新的图形窗口,你应该能看到两个节点。如果你什么都没有看到,确保两个节点都在运行,并通过点击左上角带有刷新图标的按钮来刷新视图。你还可以从左上角的下拉菜单中选择 Nodes/Topics (all)。然后,你应该会得到类似以下的内容:

图 3.1 – 带有两个节点的 rqt_graph

图 3.1 – 带有两个节点的 rqt_graph

在这里,我们可以看到两个节点都在运行(目前没有新的内容),但我们还看到从说话节点到一个方框的箭头,以及从那个方框到监听节点的另一个箭头。这是 ROS 2 通信,允许说话节点向监听节点发送一些数据——在这里,是一些文本。我们将在本章的下一节中讨论这种通信。

我们现在可以得出的结论是,我们在两个不同的终端中使用了 ros2 run 命令启动了两个不同的 ROS 程序(节点)。看起来这两个程序正在相互通信,我们可以用 rqt_graph 来确认这一点。

在我们进一步了解 ROS 通信的类型之前,让我们运行另一组节点。

运行 2D 机器人模拟

我们运行的前两个节点是非常简单的程序,它们在终端上打印日志并在彼此之间发送一些文本。

现在,停止所有现有的节点(在每个终端中按 Ctrl + C),然后让我们用一些其他节点重新开始。在终端 1 中运行以下命令:

$ ros2 run turtlesim turtlesim_node
Warning: Ignoring XDG_SESSION_TYPE=wayland on Gnome. Use QT_QPA_PLATFORM=wayland to run on Wayland anyway.
[INFO] [1710229365.273668657] [turtlesim]: Starting turtlesim with node name /turtlesim
[INFO] [1710229365.288027379] [turtlesim]: Spawning turtle [turtle1] at x=[5.544445], y=[5.544445], theta=[0.000000]

你会看到一些日志,但更重要的是,你将得到一个带有蓝色背景和中间有海龟的新窗口。这个海龟代表一个(非常简化的)在 2D 空间中移动的模拟机器人。

在终端 2 中,启动这个第二个节点:

$ ros2 run turtlesim turtle_teleop_key
Reading from keyboard
---------------------------
Use arrow keys to move the turtle.
Use G|B|V|C|D|E|R|T keys to rotate to absolute orientations. 'F' to cancel a rotation.
'Q' to quit.

看到这一点后,确保终端 2 已选中,并使用箭头键(上、下、左、右)。当你这样做的时候,你应该看到海龟机器人移动。

图 3.2 – 移动模拟海龟机器人(TurtleSim)

图 3.2 – 移动模拟海龟机器人(TurtleSim)

我们可以猜测,我们启动的第二个节点(使用 turtle_teleop_key)正在读取你按在键盘上的键,并将某种信息/命令发送到 turtlesim 节点,然后该节点使海龟机器人移动。为了确认这一点,请在终端 3 上再次启动 rqt_graph

$ rqt_graph

如果需要,刷新视图几次。选择 节点/主题(全部),你会看到类似这样的内容:

图 3.3 – 带有 turtlesim 和 teleop_turtle 节点的 rqt_graph

图 3.3 – 带有 turtlesim 和 teleop_turtle 节点的 rqt_graph

我们可以找到一个名为 turtlesim 的节点和一个名为 teleop_turtle 的节点,我们可以清楚地看到这两个节点正在彼此通信。

注意

如你所见,我们用来启动节点的可执行文件名(turtle_teleop_key)不一定与节点名(teleop_turtle)相同。我们将在本书的后面回到这个问题。

回顾 – 节点

从这两个实验中我们能得到什么?正如你所见,ROS 2 节点可以是任何包含以下内容的计算机程序:

  • 指令用于在终端打印日志

  • 图形窗口(2D,也可以是 3D)

  • 硬件驱动程序等

除了是一个计算机程序之外,节点还受益于 ROS 2 的功能:日志、与其他节点的通信以及本书中将发现的其它特性。

现在你已经看到了如何启动节点和使用 ros2 命令行工具,让我们关注它们是如何相互通信的。

主题

节点使用 ROS 2 通信功能相互通信。有三种类型的通信:主题、服务和动作。我们将发现所有三种,从 主题 开始。

在这里,我们将做一些基本的发现,以了解 ROS 2 主题是什么,你将了解更多关于它们的信息,包括如何为主题编写代码,在第五章中。

运行主题发布者和订阅者

停止所有正在运行的节点(Ctrl + C),然后让我们回到我们的第一个例子。

在终端 1 中,输入以下命令:

$ ros2 run demo_nodes_cpp talker

在终端 2 中,输入以下命令:

$ ros2 run demo_nodes_cpp listener

在终端 3 中,输入以下命令:

$ rqt_graph

如果需要,刷新视图几次,选择Nodes/Topics (all),你应该会得到与图 3相同的视觉效果。

在中间,你会看到一个/chatter框。这个框代表一个 ROS 2 主题。你还可以看到说话节点正在向/chatter主题发送信息,这些信息随后将被监听节点接收。

我们说说话节点是一个发布者,监听节点是一个订阅者

一个重要的细节是,说话节点实际上并没有直接向监听节点发送数据。说话节点在/chatter主题上发布,监听节点订阅/chatter主题。正因为如此,数据从说话节点流向监听节点。

一个名称和一个接口(数据类型)

rqt_graph中,我们已能看到一个节点可以通过主题向另一个节点发送数据。一个重要点是,主题是由名称定义的。发布者和订阅者使用相同的名称来确保通信成功。

不仅仅是名称。让我们回到终端,使用ros2命令行工具来发现更多信息。

你之前使用ros2 run来启动节点。我们还有ros2 topic来与主题交互。你可以使用ros2 topic -h获取所有可用主题命令的帮助。ros2 topic list命令将列出所有可用主题,这意味着所有运行节点之间的主题通信。

在终端 3(如果你停止了rqt_graph)或终端 4 中,运行以下命令:

$ ros2 topic list
/chatter
/parameter_events
/rosout

对于你创建的任何节点,你总会看到/rosout/parameter_events。这些目前并不重要,你可以忽略它们。重要的是/chatter主题。我们已经知道它用于说话节点和监听节点之间,但现在的问题是:正在发送什么类型的数据?

要获取这些信息,我们可以使用ros2 topic info <topic_name>

$ ros2 topic info /chatter
Type: std_msgs/msg/String
Publisher count: 1
Subscription count: 1

在这里,我们可以看到有多少节点正在发布和订阅这个主题。我们有一个发布者(说话节点)和一个订阅者(监听节点)。我们还可以看到正在发送的消息类型:std_msgs/msg/String。在 ROS 2 中,这种消息被称为接口

要查看接口内部的内容,运行ros2 interface show <interface_name>

$ ros2 interface show std_msgs/msg/String
# Some comments
string data

可能有一堆注释(以#开头),你可以忽略。重要的是这一点:字符串数据。这告诉我们该主题上正在发送什么。在这里,它是一个名为data的字符串(字符链)。

因此,当说话节点想要向/chatter主题发送消息时,它需要发送一个类型为字符串的data字段。为了获取这些信息,监听节点需要订阅/chatter,并期望接收相同的数据类型。

这就是主题是如何定义的:一个名称和一个界面(数据类型)。发布者和订阅者都应该使用相同的名称和界面来进行通信。

这是有道理的:作为一个类比,想象你和我通过在线聊天进行交流。如果我们不在同一个聊天室(同一个主题名称),我们就找不到对方。此外,如果我用你不懂的语言和你说话,这对你来说就没有意义。为了交流,我们都需要同意使用什么语言(相同的界面)。

对主题进行更多实验

让我们通过一个挑战来练习一下。这次,我不会直接展示要运行的命令,而是给你一个挑战,这样你可以自己练习。

注意

我会在本书中有时给你一些挑战/活动,难度各不相同——这个第一个挑战相当小。然后我会给你解决方案(或其中一部分)。当然,我鼓励你在阅读说明后停止阅读,只使用前面的页面来解决挑战。然后,阅读解决方案,并与你所做的内容进行比较。

挑战

运行我们之前用 2D turtlesim机器人(两个节点)做的第二个例子。

我现在挑战你做的是找到turtle_teleop节点用来向turtlesim节点发送速度命令的主题名称和界面。使用本章前面的命令尝试获取这些信息。

解决方案

启动两个节点和rqt_graph

在终端 1 中,输入以下内容:

$ ros2 run turtlesim turtlesim_node

在终端 2 中,输入以下内容:

$ ros2 run turtlesim turtle_teleop_key

在终端 3 中,输入以下内容:

$ rqt_graph

确保你在rqt_graph上刷新视图并选择节点/主题(全部)。你将得到与之前相同的内容,如图 3。3*。

屏幕上还有一些其他内容,但我们只需要一条信息。正如你所见,有一个/turtle1/cmd_vel框,这里代表一个主题。teleop_turtle节点是一个发布者,而turtlesim节点是该主题的订阅者。

这很有逻辑性:teleop_turtle节点将读取你按下的键,然后在该主题上发布。在其末端,turtlesim节点将订阅该主题以获取机器人的最新速度命令。

我们可以从终端中获得大致相同的信息:

$ ros2 topic list
/parameter_events
/rosout
/turtle1/cmd_vel
/turtle1/color_sensor
/turtle1/pose

从所有运行的主题列表中,我们可以找到/****turtle1/cmd_vel主题。

现在,为了获取该主题的界面(数据类型),请运行以下命令:

$ ros2 topic info /turtle1/cmd_vel
Type: geometry_msgs/msg/Twist
Publisher count: 1
Subscription count: 1

要了解界面的详细信息,请运行以下命令:

$ ros2 interface show geometry_msgs/msg/Twist
# This expresses velocity in free space broken into its linear and angular parts.
Vector3  linear
      float64 x
      float64 y
      float64 z
Vector3  angular
      float64 x
      float64 y
      float64 z

这个界面比我们之前用的要复杂一些。现在没有必要去理解所有这些内容,因为我们会在本书的后面部分深入探讨界面。这里的目的是仅仅找到主题名称和界面。

从这些信息中,假设我们想让机器人向前移动。我们可以猜测,我们需要在linear字段内设置x字段的值(因为在 ROS 中,x指向前方)。

概述 – 主题

通过这两个实验,你可以看到节点通过主题相互通信。一个节点可以发布或订阅一个主题。发布时,节点发送一些数据。订阅时,它接收数据。

主题由一个名称和数据类型定义。现在你需要记住的就是这些。让我们切换到第二种通信类型:服务。

服务

主题非常有用,可以从一个节点向另一个节点发送数据/命令流。然而,这并不是唯一的通信方式。你还可以在 ROS 2 中找到客户端/服务器通信。在这种情况下,服务将被使用。

正如我们对主题所做的那样,我们将运行两个节点通过服务相互通信,这次我们将尝试使用 ROS 2 工具分析正在发生的事情以及通信是如何工作的。

第六章 中,你将得到关于服务、何时使用它们与主题相比以及如何在代码中包含它们的更详细解释。现在,让我们继续我们的探索阶段。

运行服务服务器和客户端

停止所有正在运行的节点。这次,我们将从 demo_nodes_cpp 中启动另一个节点,它包含一个简单的服务服务器,用于添加两个整数。我们还将启动一个 客户端 节点,该节点将向 服务器 节点发送请求。

在终端 1 中,输入以下命令:

$ ros2 run demo_nodes_cpp add_two_ints_server

在终端 2 中,输入以下命令:

$ ros2 run demo_nodes_cpp add_two_ints_client

一旦运行客户端节点,你可以在终端 1(服务器)中看到这个日志:

[INFO] [1710301834.789596504] [add_two_ints_server]: Incoming request
a: 2 b: 3

你也可以在终端 2(客户端)中看到这个日志:

[INFO] [1710301834.790073100] [add_two_ints_client]: Result of add_two_ints: 5

从我们在这里观察到的来看,似乎服务器节点正在挂起并等待。客户端节点将向服务器发送一个请求,包含两个整数,在这个例子中是:23。服务器节点接收请求,相加数字,并返回结果:5。然后,客户端获取响应并打印结果。

这基本上是 ROS 2 中服务工作的方式。你运行一个包含服务器的节点,然后任何其他节点(客户端)都可以向该服务器发送请求。服务器处理请求并返回响应给客户端节点。

一个名称和一个接口(数据类型)

对于主题,服务由两个东西定义:一个名称和一个接口(数据类型)。唯一的区别是接口将包含两部分:一个 请求 和一个 响应

不幸的是,rqt_graph 不支持服务内省——尽管有一些计划在未来 ROS 2 发行版中实现这一功能。

要找到服务的名称,我们可以再次使用 ros2 命令行工具,这次使用 service 命令,后面跟 list。正如你所见,如果你理解了如何列出所有主题,那么对于服务来说也是一样的。

到目前为止,你仍然在终端 1 上运行着服务节点,而终端 2 上没有运行任何东西(因为客户端在收到响应后停止了)。在终端 2 或 3 上,运行以下命令:

$ ros2 service list
/add_two_ints
/add_two_ints_server/describe_parameters
/add_two_ints_server/get_parameter_types
/add_two_ints_server/get_parameters
/add_two_ints_server/list_parameters
/add_two_ints_server/set_parameters
/add_two_ints_server/set_parameters_atomically

这有很多服务。其中大部分都可以丢弃。对于每个节点,你都会自动获得六个额外的服务,它们都包含名称parameter。如果我们忽略它们,我们可以看到/add_two_ints服务,这是在add_two_ints_server节点上运行的服务器。

太好了,我们找到了名称。现在,为了获取数据类型,我们可以使用ros2 service type <service_name>,然后ros2 interface show <interface_name>

$ ros2 service type /add_two_ints
example_interfaces/srv/AddTwoInts
$ ros2 interface show example_interfaces/srv/AddTwoInts
int64 a
int64 b
---
int64 sum

你可以看到接口中有一条包含三个短横线(---)的行。这是请求和响应之间的分隔。有了这个,你知道作为客户端向服务器发送请求时,你需要发送一个名为a的整数和一个名为b的整数。然后,你将收到一个包含一个名为sum的整数数字的响应。

从终端发送请求

我们不仅可以运行add_two_ints_client节点,还可以直接从终端发送请求。我把它加在这里是因为这是一种非常有用的测试服务的方法,而不需要现有的客户端节点。

语法是ros2 service call <service_name> <interface_name> "<request_in_json>"。正如你所见,我们需要提供服务名称和接口。

这里是如何做到这一点的示例(确保服务器节点仍在运行):

$ ros2 service call /add_two_ints example_interfaces/srv/AddTwoInts "{a: 4, b: 7}"
waiting for service to become available...
requester: making request: example_interfaces.srv.AddTwoInts_Request(a=4, b=7)
response:
example_interfaces.srv.AddTwoInts_Response(sum=11)

使用这个命令,我们发送了一个包含47的请求。服务器节点将打印以下日志:

[INFO] [1710302858.634838573] [add_two_ints_server]: Incoming request
a: 4 b: 7

最后,在客户端,我们得到了包含sum=11的响应。

更多关于服务的实验

这是你练习使用服务的另一个挑战。

挑战

启动turtlesim节点,列出现有服务,并找出如何在终端中生成新的海龟机器人,在 2D 屏幕上。

再次建议你花点时间自己尝试做这个。你可以随意回顾本章的所有先前命令。不需要记住所有这些命令,因为你可以很容易地在书中找到它们,使用Tab键进行自动完成,或者通过在任意命令后添加-h

解决方案

停止所有正在运行的节点。

在终端 1 中,输入以下内容:

$ ros2 run turtlesim turtlesim_node

在终端 2 中,输入以下内容:

$ ros2 service list
/clear
/kill
/reset
/spawn
/turtle1/set_pen
/turtle1/teleport_absolute
/turtle1/teleport_relative
# There are more services containing "parameter" that we can ignore

这些是我们可以为turtlesim节点使用的所有服务。正如你所见,我们已经有相当多了。在这个挑战中,你必须生成一个海龟。太好了,我们可以找到一个/****spawn服务。

我们已经有了名称;现在,让我们找到接口(请求,响应):

$ ros2 service type /spawn
turtlesim/srv/Spawn
$ ros2 interface show turtlesim/srv/Spawn
float32 x
float32 y
float32 theta
string name # Optional.  A unique name will be created and returned if this is empty
---
string name

现在,我们有了所有需要的信息。要向服务器发送请求,我们必须使用/spawn服务和turtlesim/srv/Spawn接口。我们可以发送包含(xytheta)坐标的请求,以及一个可选的名称。实际上,请注意,请求中的所有字段都是可选的。如果你没有为字段提供值,数字的默认值将是0,字符串的默认值将是""

现在,让我们从终端发送我们的请求:

$ ros2 service call /spawn turtlesim/srv/Spawn "{x: 3.0, y: 4.0}"
waiting for service to become available...
requester: making request: turtlesim.srv.Spawn_Request(x=3.0, y=4.0, theta=0.0, name='')
response:
turtlesim.srv.Spawn_Response(name='turtle2')

如果你查看 2D 窗口,你会看到一个新海龟。

图 3.4 – 生成新海龟后的 TurtleSim 窗口

图 3.4 – 生成新海龟后的 TurtleSim 窗口

这个海龟已经在请求中提供的(xytheta)坐标处生成。您可以尝试再次运行 ros2 service call 命令几次,使用不同的坐标,这样您就可以在屏幕上生成更多的海龟。

回顾 – 服务

您已经成功在两个节点之间运行了客户端/服务器通信。再次强调,服务是由名称和接口(请求,响应)定义的。

关于何时使用主题与服务的更多细节,请继续阅读,因为这是我们将在本书的后面部分看到的,那时您将更了解每个概念。现在,您已经看到了节点之间的两种通信方式。每种通信都有一个名称和接口,我们已经在终端中可以与之互动。

现在还有一个需要发现的 ROS 2 通信:动作。

动作

ROS 2 的 动作基本上与服务(客户端/服务器通信)相同,但设计用于更长时间的任务,并且当您可能希望在执行过程中也获得一些反馈,能够取消执行等。

在机器人领域,我们使机器人移动。使机器人移动不是瞬间发生的事情。它可能只需要一秒钟的几分之一,但有时一个任务可能需要几秒钟/几分钟或更长时间。ROS 2 服务是为快速执行设计的,例如:计算,或者立即动作,比如在屏幕上生成海龟。当客户端/服务器通信可能需要更多时间,并且我们希望对其有更多控制时,就会使用动作。

我们将在 第七章 中更详细地探讨动作。动作是我认为的一个中级概念,而不是初级概念,所以现在我不会深入探讨。让我们只是通过一个非常简单的例子继续探索阶段,以便了解它是如何工作的。

运行动作服务器

停止所有正在运行的节点,并在终端 1 中再次启动 turtlesim 节点:

$ ros2 run turtlesim turtlesim_node

正如您已经通过主题和服务实践过的,以下 ros2 命令将开始对您来说变得熟悉。在终端 2 中列出所有现有的动作:

$ ros2 action list
/turtle1/rotate_absolute

从我们所观察到的来看,似乎 turtlesim 节点包含一个名为 /turtle1/rotate_absolute 的动作服务器。目前还没有为这个动作创建客户端节点,因此我们将尝试从终端与之交互。当然,我们需要两样东西:名称和接口。

名称和接口(数据类型)

关于主题和服务,一个动作将由一个名称和一个接口定义。这次,接口包含三个部分:目标结果反馈

目标和结果类似于服务的请求和响应。反馈是服务器可以发送的附加数据,可以在目标执行期间提供一些反馈。

要获取动作接口,你可以运行 ros2 action info <action_name> -t 命令。别忘了添加 -t(表示类型),否则,你会看到一些细节,但不会有接口:

$ ros2 action info /turtle1/rotate_absolute -t
Action: /turtle1/rotate_absolute
Action clients: 0
Action servers: 1
    /turtlesim [turtlesim/action/RotateAbsolute]

我们可以看到动作在一个服务器(turtlesim 节点)内部运行,我们还找到了接口:turtlesim/action/RotateAbsolute

让我们看看这个接口内部是什么:

$ ros2 interface show turtlesim/action/RotateAbsolute
# The desired heading in radians
float32 theta
---
# The angular displacement in radians to the starting position
float32 delta
---
# The remaining rotation in radians
float32 remaining

你可以看到由三个短横线(---)分隔的两个部分。第一部分是目标,第二部分是结果,第三部分是反馈。这个动作相当简单;我们每个接口部分只有一个浮点数。

作为客户端,我们发送旋转所需的期望角度。服务器节点将接收目标并处理它,同时可选地发送一些反馈。当目标完成时,服务器将结果发送给客户端。

从终端发送目标

作为动作客户端,我们首先对接口的目标部分感兴趣。在这里,我们需要发送一个浮点数,它对应于我们想要旋转海龟的角度(以弧度为单位)。

从终端发送目标的语法是 ros2 action send_goal <action_name> <action_interface> "<goal_in_json>"。再次强调,你需要提供名称和接口。

确保终端 2 中的 turtlesim 节点是活跃的,然后从终端 2 发送一个目标:

$ ros2 action send_goal /turtle1/rotate_absolute turtlesim/action/RotateAbsolute "{theta: 1.0}"
Waiting for an action server to become available...
Sending goal:
     theta: 1.0
Goal accepted with ID: 3ba92096282a4053b552a161292afc8e
Result:
    delta: -0.9919999837875366
Goal finished with status: SUCCEEDED

在运行命令后,你应该看到海龟机器人在 2D 窗口中旋转。一旦达到期望的角度,动作将完成,你将收到结果。

概括 – 动作

你已经在 ROS 2 中运行了第一个动作通信。一个 动作 由两件事定义:一个名称和一个接口(目标、结果、反馈)。当需要客户端/服务器类型的通信,并且动作的持续时间可能需要一些时间——而不是立即执行时,使用动作。

通过这个,你已经看到了 ROS 2 中的三种通信类型:主题、服务和动作。每个都会在 第二部分 中有自己的章节,这样你可以详细地了解它们是如何工作的,如何在代码中使用它们,以及如何使用 ROS 2 工具完全内省它们。

参数

我们现在将回到节点本身,并讨论另一个重要的 ROS 2 概念:参数

这次,不是关于通信,而是关于如何在启动节点时给它不同的设置。

让我们快速了解参数是如何工作的,你将在第八章中获得更详细的解释,包括更多示例和用例。

获取节点的参数

停止所有正在运行的节点,并在终端 1 中启动 turtlesim 节点:

$ ros2 run turtlesim turtlesim_node

然后,要列出所有参数,相当简单,你可能能猜到命令。如果我们有 ros2 topic list 用于主题,ros2 service list 用于服务,ros2 action list 用于动作,那么,对于参数,我们就有 ros2 param list。唯一的特殊性是我们使用单词 param 而不是 parameter。在终端 2 中运行此命令:

$ ros2 param list
/turtlesim:
  background_b
  background_g
  background_r
  holonomic
  qos_overrides./parameter_events.publisher.depth
  qos_overrides./parameter_events.publisher.durability
  qos_overrides./parameter_events.publisher.history
  qos_overrides./parameter_events.publisher.reliability
  start_type_description_service
  use_sim_time

注意

有时,ros2 param list命令可能无法正常工作,你将看不到任何参数或不是所有参数。这种情况也可能发生在一些其他的ros2命令中。在这种情况下,只需再次运行命令,如果需要的话,多次运行,这应该会工作。这可能是ros2命令行工具本身的某种类型的错误,但无需担心:应用程序正在正常运行。

我们首先看到turtlesim节点(实际上写作/turtlesim,带有一个前置斜杠),然后是这个节点下的一个带有缩进的名称列表。这些名称是参数,它们属于该节点。这是 ROS 2 中参数的第一个特点:它们存在于节点内部。如果你停止这个turtlesim节点,那么参数也会被销毁。

有一些参数你可以忽略:use_sim_timestart_type_description_service以及包含qos_overrides的所有参数。那些将存在于你启动的任何节点中。如果我们去掉它们,我们剩下一些参数,包括background_bbackground_gbackgound_r

从这个观察结果来看,我们似乎可以在启动turtlesim节点时改变 2D 窗口的背景颜色。

现在,那些参数里面是什么?是什么类型的值?是一个整数,一个浮点数,还是一个字符串?让我们用ros2 param get <node_name> <param_name>来找出答案。在终端 2 中运行以下命令:

$ ros2 param get /turtlesim background_b
Integer value is: 255
$ ros2 param get /turtlesim background_g
Integer value is: 86
$ ros2 param get /turtlesim background_r
Integer value is: 69

从这个观察结果中,我们可以推测背景的红、绿、蓝RGB)值是(6986255)。看起来参数值是一个从0255的整数。

为节点设置参数值

现在我们已经找到了每个参数的名称以及我们应该使用什么类型的值,让我们在启动节点时自己修改这些值。

为了做到这一点,我们需要重新启动节点,使用与之前相同的语法:ros2 run <package_name> <executable_name>。然后我们将添加--ros-args(只添加一次),并为每个要修改的参数添加-p <param_name>:=value

在终端 1 上停止turtlesim节点,然后重新启动它,并为一些参数设置不同的值:

$ ros2 run turtlesim turtlesim_node --ros-args -p background_b:=0 -p background_r:=0

在这里,我们决定蓝色和红色颜色都将是0。我们没有为background_g指定任何值,这意味着将使用默认值(如前所述:86)。

运行此命令后,你应该会看到 2D 屏幕出现,但这次背景是深绿色。

概括——参数

参数是可以在运行时提供的设置(这意味着当我们运行节点时)。它们允许我们轻松配置我们启动的不同节点,因此,它们使 ROS 2 应用程序更加动态。

参数存在于节点内部。你可以找到节点的所有参数并获取每个参数的值。在启动节点时,你可以为要修改的参数提供一个自定义值。

启动文件

让我们用启动文件来结束这个 ROS 2 概念的列表。

启动文件将允许你从一个文件中启动多个节点和参数,这意味着你可以通过一条命令行启动整个应用程序。

第九章 中,你将学习如何编写自己的启动文件,但现在,让我们先启动几个来看看它们的作用。

启动启动文件

要在终端中启动单个节点,你已经看到了 ros2 run 命令。对于启动文件,我们将使用 ros2 launch <package_name> <launch_file>

停止所有正在运行的节点,并从 demo_nodes_cpp 包中启动 talker_listener 启动文件。在终端 1 中运行以下命令:

$ ros2 launch demo_nodes_cpp talker_listener_launch.py
[INFO] [launch]: All log files can be found below /home/ed/.ros/log/2024-03-14-16-09-27-384050-ed-vm-2867
[INFO] [launch]: Default logging verbosity is set to INFO
[INFO] [talker-1]: process started with pid [2868]
[INFO] [listener-2]: process started with pid [2871]
[talker-1] [INFO] [1710403768.481156318] [talker]: Publishing: 'Hello World: 1'
[listener-2] [INFO] [1710403768.482142732] [listener]: I heard: [Hello World: 1]

如你所见,似乎说话者和监听节点都已经启动。你可以在终端 2 中轻松验证这一点:

$ ros2 node list
/listener
/talker

使用 rqt_graph,你还可以检查节点之间是否相互通信。我们有日志证明这一点:在同一屏幕上,我们获取了说话者和监听节点双方的日志,看起来监听节点正在接收消息(使用我们之前看到的 /chatter 主题)。

最后,这和我们在两个终端上启动两个节点是一样的。启动文件将简单地在一个终端中启动这两个节点。

如果我们更仔细地阅读日志,我们可以看到每个节点将在不同的进程中启动。要停止启动文件,请按 Ctrl + C。这将停止所有进程(节点),并且你的应用程序将结束。

现在我们尝试从 turtlesim 包中启动另一个启动文件。在终端 1 中停止启动文件,并从 turtlesim 包中启动 multisim 启动文件:

$ ros2 launch turtlesim multisim.launch.py
[INFO] [launch]: All log files can be found below /home/ed/.ros/log/2024-03-14-16-14-41-043618-ed-vm-2962
[INFO] [launch]: Default logging verbosity is set to INFO
[INFO] [turtlesim_node-1]: process started with pid [2963]
[INFO] [turtlesim_node-2]: process started with pid [2965]

这样,你将看到两个二维窗口,每个窗口中都有一个海龟机器人。正如日志所示,我们正在启动两个 turtlesim 节点(两个具有不同名称的相同节点)。

我们也可以从终端中检查:

$ ros2 node list
/turtlesim1/turtlesim
/turtlesim2/turtlesim

节点已经被重命名。我们得到的不再是 /turtlesim,而是 /turtlesim1/turtlesim/turtlesim2/turtlesim。这些名称是在启动文件内部选择的。

复习 – 启动文件

启动文件对于从一个文件中启动多个节点(以及这些节点的参数)非常有用。只需一条命令行(ros2 launch),你就可以启动整个 ROS 2 应用程序。

目前关于启动文件的讨论就这么多,因为这个概念相当简单(真正的挑战在于编写启动文件,而不是启动它)。我们现在已经完成了对 ROS 2 主要概念的探索。

概述

通过本章,你已经发现了 ROS 2 最重要的一些概念:节点、主题、服务、动作、参数和启动文件。

ROS 2 程序被称为节点。简单来说,它们是常规的软件程序,也可以从 ROS 2 功能中受益:日志、通信、参数等等。

有三种通信类型:主题、服务和动作。主题用于从一个或多个节点向另一个或多个其他节点发送数据/命令流。服务用于需要客户端/服务器通信的情况。动作基本上与服务相同,但对于可能需要一些时间的目标执行。

除了通信功能外,节点还可以使用参数来指定运行时的设置。参数允许节点在启动时轻松配置。

最后,我们可以通过一个启动文件从一条命令行启动所有节点和参数。

对于核心概念(目前是这样),你已经发现了ros2命令行工具和rqt_graph。这些工具是无价的,你将经常使用它们。我们使用这些工具进行的实验与你在未来的 ROS 2 项目中将要做的非常相似。

这一章节有点特别,因为它并没有从 A 到 Z 完整地解释一个概念。正如引言中所述,它更像是一个概念巡游,通过实际操作发现主要概念。你得到的不是完整的理解,而是对事物工作方式的直觉,一些工具的使用经验,以及对整体图景的认识。

随时可以回到这一章节,在你阅读本书的过程中再次运行实验。一切都会变得更加有意义。

现在,你已经准备好继续进行第二部分,在那里,你将从头开始创建一个完整的 ROS 2 应用程序,使用 Python 和 C++代码。你迄今为止看到的每个概念都将有自己的专属章节。在这里培养的直觉将非常有用。

第二部分:使用 ROS 2 进行开发 – Python 和 C++

这一部分主要关注使用 ROS 2 编写代码并构建可扩展的机器人应用。您已经在 第三章 中发现了主要概念,并对它们的工作原理有了直观的了解。现在,您将逐一深入到每个概念,通过实际生活中的类比、深入代码(使用 Python 和 C++)以及额外的挑战来帮助您练习。

本部分包含以下章节:

  • 第四章, 编写和构建 ROS 2 节点

  • 第五章, 主题 – 节点之间发送和接收消息

  • 第六章, 服务 – 节点之间的客户端/服务器交互

  • 第七章, 动作 – 当服务不足时

  • 第八章, 参数 – 使节点更加动态

  • 第九章, 启动文件 – 一次性启动所有节点

第四章:编写和构建 ROS 2 节点

要使用 ROS 2 编写自己的自定义代码,你必须创建 ROS 2 程序,换句话说,就是节点。你已经在 第三章 中发现了节点的概念。在本章中,我们将更深入地探讨,你将使用 Python 和 C++ 编写你的第一个节点。

在你创建节点之前,有一些设置要做:你需要创建一个 ROS 2 工作空间,你将在其中构建你的应用程序。在这个工作空间中,你将添加软件包以更好地组织你的节点。然后,在这些软件包中,你可以开始编写你的节点。在你编写一个节点之后,你将构建它并运行它。

我们将一起完成这个完整的过程,一路上都有动手代码和命令行。这是你在开发 ROS 2 应用程序时创建任何新节点时需要重复的过程。

到本章结束时,你将能够使用 Python 和 C++ 创建自己的软件包和 ROS 2 节点。你还将能够从终端运行和检查你的节点。这是学习任何其他 ROS 2 功能性所需的基础。没有节点,就没有主题、服务、动作、参数或启动文件。

所有解释都将从 Python 开始,然后是 C++,我们将快速介绍。如果你只想用 Python 学习,你可以跳过 C++ 部分。然而,如果你想用 C++ 学习,阅读之前的 Python 解释对于理解是强制性的。

本章的所有代码示例都可以在本书 GitHub 仓库的 ch4 文件夹中找到(github.com/PacktPublishing/ROS-2-from-Scratch)。

在本章中,我们将涵盖以下主题:

  • 创建和设置 ROS 2 工作空间

  • 创建一个软件包

  • 创建一个 Python 节点

  • 创建一个 C++ 节点

  • Python 和 C++ 节点的节点模板

  • 检查你的节点

技术要求

要跟随本章,你需要以下内容:

  • 安装了 Ubuntu 24.04(双启动或虚拟机)

  • ROS Jazzy

  • 文本编辑器或 IDE(例如,带有 ROS 扩展的 VS Code)

这些要求将适用于 第二部分 中的所有章节。

创建和设置 ROS 2 工作空间

在我们编写任何代码之前,我们需要做一些组织工作。节点将存在于软件包中,而你所有的软件包都将存在于一个 ROS 2 工作空间 中。

什么是 ROS 2 工作空间?工作空间 仅仅是一个文件夹组织,你将在其中创建和构建你的软件包。你的整个 ROS 2 应用程序都将生活在这个工作空间中。

要创建一个工作空间,你必须遵循某些规则。让我们一步一步地创建你的第一个工作空间,并正确地设置它。

创建工作空间

要创建一个工作空间,你只需在你的家目录中创建一个新的目录。

关于工作空间的名称,让我们现在保持简单,使用一个易于识别的名称:ros2_ws

注意

工作空间的名称并不重要,它不会影响你的应用程序中的任何内容。因为我们刚开始,我们只有一个工作空间。当你取得进展并开始处理多个应用程序时,最佳实践是为每个工作空间命名应用程序或机器人的名称。例如,如果你为名为ABC V3的机器人创建一个工作空间,那么你可以将其命名为abc_v3_ws

打开一个终端,导航到你的家目录,并创建工作空间:

$ cd
$ mkdir ros2_ws

然后,进入工作空间并创建一个名为src的新目录。这就是你将编写所有 ROS 2 应用程序代码的地方:

$ cd ros2_ws/
$ mkdir src

这就是全部内容。要设置新的工作空间,你只需创建一个新的目录(在你的家目录中的任何位置),并在其中创建一个src目录。

构建工作空间

即使工作空间为空(我们还没有创建任何包),我们仍然可以构建它。为此,请按照以下步骤操作:

  1. 导航到工作空间根目录。确保你处于正确的位置。

  2. 运行colcon build命令。colcon是 ROS 2 的构建系统,在你安装ros-dev-tools包时已安装(见第二章)。

让我们构建工作空间:

$ cd ~/ros2_ws/
$ colcon build
Summary: 0 packages finished [0.73s]

如你所见,没有构建任何包,但让我们列出~/ros2_ws下的所有目录:

$ ls
build  install  log  src

如你所见,我们有了三个新的目录:buildinstalllogbuild目录将包含整体构建所需的中间文件。在log中,你会找到每个构建的日志。对你来说最重要的目录是install,这是你在构建工作空间后所有节点将被安装的地方。

注意

你应该始终从工作空间目录的根目录运行colcon build,而不是从其他任何地方。如果你犯了一个错误,并从另一个目录(比如说,从工作空间的src目录,或者在一个包内部)运行此命令,只需简单地删除在错误位置创建的新installbuildlog目录。然后回到工作空间根目录并重新构建。

源代码工作空间

如果你导航到新创建的install目录内部,你可以看到一个setup.bash文件:

$ cd install/
$ ls
COLCON_IGNORE       _local_setup_util_ps1.py   setup.ps1 
local_setup.bash    _local_setup_util_sh.py    setup.sh
local_setup.ps1     local_setup.zsh            setup.zsh
local_setup.sh      setup.bash

这可能看起来很熟悉。如果你记得,在我们安装 ROS 2 之后,我们从 ROS 2 安装目录(/opt/ros/jazzy/setup.bash)源代码了一个类似的 bash 脚本,以便我们可以在环境中使用 ROS 2。我们还需要为我们的工作空间做同样的事情。

每次你构建工作空间时,都必须源代码它,以便环境(你所在的会话)了解工作空间中的新更改。

要源代码工作空间,源代码这个setup.bash脚本:

$ source ~/ros2_ws/install/setup.bash

然后,就像我们之前做的那样,我们将把这一行添加到我们的.bashrc中。这样,你就不需要在每次打开新终端时都源代码工作空间。

使用你想要的任何文本编辑器打开你的.bashrc(位于你的家目录中,路径为~/.bashrc):

$ gedit ~/.bashrc

在全局 ROS 2 安装的源代码行之后添加一行,用于源代码工作空间的setup.bash脚本。这里的顺序非常重要。您必须首先源代码全局 ROS 2 安装,然后是您的 workspace,而不是反过来:

source /opt/ros/jazzy/setup.bash
source ~/ros2_ws/install/setup.bash

确保保存.bashrc。现在,无论是 ROS 2 还是您的工作空间,在您打开任何新的终端时都会被源代码。

注意

如果您在一个已经源代码的环境中构建工作空间,您仍然需要再次源代码工作空间,因为有一些变化,环境并不知情。在这种情况下,您可以直接源代码工作空间的 setup.bash 脚本,源代码 .bashrc,或者打开一个新的终端。

您的工作空间现在已正确设置,您可以构建您的应用程序。下一步:创建一个包。

创建一个包

您创建的任何节点都将存在于一个包中。因此,要创建一个节点,您首先必须创建一个包(在您的 workspace 中)。您现在将学习如何创建自己的包,我们将看到 Python 包和 C++包之间的区别。

但首先,包究竟是什么?

ROS 2 包是什么?

ROS 2 包是您应用程序的一个子部分。

让我们考虑一个我们想要用来拾取和放置物体的机械臂。在我们创建任何节点之前,我们可以尝试将这个应用程序分成几个子部分,或者包。

我们可以有一个包来处理相机,另一个包用于硬件控制(电机),还有一个包用于计算机器人的运动规划。

图 4.1 – 机器人包组织示例

图 4.1 – 机器人包组织示例

每个包都是一个独立的单元,负责您应用程序的一个子部分。

包对于组织您的节点非常有用,并且可以正确处理依赖项,正如我们将在本书后面看到的那样。

现在,让我们创建一个包,在这里您必须做出选择。如果您想用 Python 创建节点,您将创建一个 Python 包,如果您想用 C++创建节点,您将创建一个 C++包。每种包类型的架构都相当不同。

创建一个 Python 包

您将创建所有包在您的 ROS 2 工作空间的 src 目录中。因此,在您做其他任何事情之前,请确保导航到这个目录:

$ cd ~/ros2_ws/src/

这是构建包的命令构造方法:

  1. ros2 pkg create <pkg_name>: 这是您需要编写的最小内容。

  2. 您可以使用--build_type <build_type>指定构建类型。对于 Python 包,我们需要使用ament_python

  3. 您还可以使用--dependencies <list_of_dependencies_separated_with_spaces>指定一些可选依赖项。在包中添加依赖项总是可能的。

让我们创建我们的第一个包,命名为my_py_pkg。我们将使用此名称作为示例来处理主要的 ROS 2 概念。然后,随着我们的进展,我们将使用更有意义的名称。在您工作区的src目录中运行以下命令:

$ ros2 pkg create my_py_pkg --build-type ament_python --dependencies rclpy

使用此命令,我们表示我们想要创建一个名为my_py_pkg的包,使用ament_python构建类型,并指定一个依赖项:rclpy——这是您将在每个 Python 节点中使用的 ROS 2 的 Python 库。

这将打印出很多日志,显示已经创建了哪些文件。您也可能收到一条关于缺少许可证的[警告]日志,但由于我们没有任何意图将此包发布到任何地方,我们现在不需要许可证文件。您可以忽略此警告。

您可以看到,有一个名为my_py_pkg的新目录。这是您新创建的 Python 包的架构:

/home/<user>/ros2_ws/src/my_py_pkg
├── my_py_pkg
│   └── __init__.py
├── package.xml
├── resource
│   └── my_py_pkg
├── setup.cfg
├── setup.py
└── test
    ├── test_copyright.py
    ├── test_flake8.py
    └── test_pep257.py

目前并非所有文件都很重要。我们将在稍后看到如何使用这些文件来配置和安装我们的节点。

这里是关于最重要的文件和目录的快速概述:

  • my_py_pkg:如您所见,在包内部,还有一个同名的目录。这个目录已经包含了一个__init__.py文件。这就是我们将创建我们的 Python 节点的地方。

  • package.xml:每个 ROS 2 包(Python 或 C++)都必须包含此文件。我们将使用它来提供有关包以及依赖项的更多信息。

  • setup.py:这是您将编写构建和安装您的 Python 节点的说明的地方。

创建 C++包

在这本书中,我们将大量使用 Python,但为了完整性,我还会包括所有示例的 C++代码。这些代码要么在书中解释,要么在 GitHub 仓库中提供。

创建 C++包与创建 Python 包非常相似;然而,包的架构将会有很大的不同。

确保您导航到您工作区的src目录,然后创建一个新的包。让我们使用与 Python 相同的模式,将包命名为my_cpp_pkg

$ cd ~/ros2_ws/src/
$ ros2 pkg create my_cpp_pkg --build-type ament_cmake --dependencies rclcpp

我们选择ament_cmake作为构建类型(这意味着这将是一个 C++包),并指定一个依赖项:rclcpp——这是 ROS 2 的 C++库,我们将用于每个 C++节点。

再次提醒,您应该会看到很多日志,包括新创建的文件,以及可能关于许可证的警告,但您可以忽略。

您的新 C++包的架构将如下所示:

/home/ed/ros2_ws/src/my_cpp_pkg/
├── CMakeLists.txt
├── include
│   └── my_cpp_pkg
├── package.xml
└── src

这里是关于每个文件或目录角色的快速解释:

  • CMakeLists.txt:这将用于提供如何编译您的 C++节点、创建库等的说明。

  • include目录:在 C++项目中,您可以将代码分成实现文件(.cpp扩展名)和头文件(.hpp扩展名)。如果您将 C++节点分成.cpp.hpp文件,您将把头文件放在include目录中。

  • package.xml:此文件是任何类型的 ROS 2 包所必需的。它包含有关包的更多信息,以及其他包的依赖关系。

  • src 目录:这是你将编写你的 C++ 节点(.cpp 文件)的地方。

构建包

现在你已经创建了一个或多个包,你可以构建它们,即使包中还没有任何节点。

要构建包,回到你的 ROS 2 工作空间根目录并运行 colcon build。再次强调,就像在本章前面所看到的,运行此命令的位置非常重要。

$ cd ~/ros2_ws/
$ colcon build
Starting >>> my_cpp_pkg
Starting >>> my_py_pkg
Finished <<< my_py_pkg [1.60s]
Finished <<< my_cpp_pkg [3.46s]
Summary: 2 packages finished [3.72s]

两个包都已经构建完成。每次你在包内添加或修改节点时,你都必须这样做。

重要的是要注意这一行:Finished <<< <package_name> [time]。这意味着包已正确构建。即使你看到额外的警告日志,如果你也看到了 Finished 行,你就知道包已经构建。

注意

在构建任何包之后,你还需要源你的工作空间,以便环境能够意识到新的更改。你可以执行以下任何一项操作:

  • 打开一个新的终端,因为所有配置都在 .bashrc 文件中

  • 直接源 setup.bash 脚本(source ~/ros2_ws/install/setup.bash

  • 手动源 .bashrc 文件 (source ~/.bashrc)

要仅构建特定的包,可以使用 --packages-select 选项,后跟包的名称。以下是一个示例:

$ colcon build --packages-select my_py_pkg
Starting >>> my_py_pkg
Finished <<< my_py_pkg [1.01s]
Summary: 1 package finished [1.26s]

这样,你不需要每次都构建整个应用程序,只需专注于一个包即可。

现在我们已经创建了一些包,并且我们知道如何构建它们,我们可以在包中创建节点。但我们如何组织它们呢?

包中的节点是如何组织的?

要开发 ROS 2 应用程序,你将在节点内部编写代码。如第三章中所示,节点只是 ROS 2 程序的名称。

节点是应用程序的子程序,负责一件事情。如果你有两个不同的功能需要实现,那么你将有两个节点。节点通过 ROS 2 通信(主题、服务和动作)相互通信。

你将在包内部组织你的节点。对于某个包(应用程序的子部分),你可以有多个节点(功能)。要完全理解如何组织包和节点,你需要实践和经验。现在,让我们用一个例子来获得一个概念。

让我们回到我们在 图 4**.1 中看到的包架构,并在包内添加节点:

图 4.2 – 包组织节点的示例

图 4.2 – 包组织节点的示例

如你所见,在相机包中,我们可以有一个节点负责处理相机硬件。这个节点会将图像发送到图像处理节点,而这个后者会提取机器人拾取物体的坐标。

同时,一个运动规划节点(在运动规划包中)将根据特定的命令计算机器人应执行的运动。路径校正节点可以使用从图像处理节点接收到的数据来支持这种运动规划。

最后,为了使机器人移动,一个硬件驱动节点将负责硬件通信(电机、编码器)并从运动规划节点接收命令。还可以有一个额外的状态发布节点,用于向其他节点发布有关机器人的额外数据。

这种节点组织纯粹是虚构的,这里只是为了给你一个 ROS 2 应用程序如何设计的总体概念,以及节点在这个应用程序中可以扮演哪些角色。

现在,你终于要编写你的第一个 ROS 2 节点了。ROS 2 在你可以实际编写代码之前需要相当多的安装和配置,但好消息是,我们已经完成了所有这些,现在我们可以专注于代码了。

目前我们不会做任何太复杂的事情;我们不会深入复杂的特性或通信。我们将编写一个基本的节点,你可以将其作为模板来开始任何未来的节点。我们还将构建这个节点,看看如何运行它。

创建一个 Python 节点

让我们创建我们的第一个 Python 节点,或者换句话说,我们的第一个 ROS 2 Python 程序。

创建 Python 和 C++节点的过程非常不同。这就是为什么我为每个都写了单独的部分。我们将从 Python 开始,提供完整的逐步说明。然后我们将看到如何用 C++做同样的事情。如果你想跟随 C++节点部分,请确保先阅读这一部分。

要创建一个节点,你必须做以下事情:

  1. 为节点创建一个文件。

  2. 编写节点。我们将使用面向对象编程OOP),这是 ROS 2 官方推荐的(并且你几乎可以找到的每个现有的 ROS 2 代码都使用 OOP)。

  3. 构建节点存在的包。

  4. 运行节点以测试它。

让我们开始我们的第一个 Python 节点。

为节点创建一个文件

要编写节点,我们首先需要创建一个文件。我们应该在哪里创建这个文件?

如果你记得,当我们创建my_py_pkg包时,在包内部创建了一个名为my_py_pkg的目录。这就是我们将编写节点的地方。对于每个 Python 包,你必须进入与包同名目录。如果你的包名是abc,那么你将进入~/ros2_ws/src/abc/abc/

在这个目录中创建一个新文件并使其可执行:

$ cd ~/ros2_ws/src/my_py_pkg/my_py_pkg/
$ touch my_first_node.py
$ chmod +x my_first_node.py

然后,打开这个文件进行编写。你可以使用任何你想要的文本编辑器或 IDE,只要你不迷失在所有文件中。

如果你不知道该使用什么,我建议使用带有 ROS 扩展的 VS Code(如第二章中所述)。这是我用于所有 ROS 开发的工具。

注意

如果你使用 VS Code,最佳方式是首先在终端中导航到你的工作区src目录,然后打开它。这样,你可以访问工作区中的所有包,并且这将使处理已识别的依赖项和自动完成变得更容易:

$ cd ~/ros2_ws/src/

$ code .

编写一个最小的 ROS 2 Python 节点

这是任何你将创建的 Python 节点的起始代码。你可以将此代码写入my_first_node.py文件:

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
class MyCustomNode(Node):
    def __init__(self):
        super().__init__('my_node_name')
def main(args=None):
    rclpy.init(args=args)
    node = MyCustomNode()
    rclpy.spin(node)
    rclpy.shutdown()
if __name__ == '__main__':
    main()

如你所见,我们在这里使用了面向对象编程(OOP)。在 ROS 2 中,OOP 无处不在,这是默认(也是推荐)的编写节点的方式。

让我们一步一步地回到这段代码,来理解它在做什么:

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node

我们首先导入rclpy,这是 ROS 2 的 Python 库。在这个库中,我们可以获取Node类。

我们接着创建一个新的类,它继承自rclpyNode类:

class MyCustomNode(Node):
    def __init__(self):
        super().__init__('my_node_name')

在这个类中,确保你使用super()调用父构造函数。这也是你指定节点名称的地方。

这个节点目前什么都没做;我们将在一分钟内添加一些功能。让我们完成代码:

def main(args=None):
    rclpy.init(args=args)
    node = MyCustomNode()
    rclpy.spin(node)
    rclpy.shutdown()

在类之后,我们在其中创建一个main()函数,执行以下操作:

  1. 使用rclpy.init()初始化 ROS 2 通信。这应该是你main()函数中的第一行。

  2. 从我们之前写的MyCustomNode类创建一个对象。这将初始化节点。不需要在之后销毁节点,因为当程序退出时,这会自动发生。

  3. 让节点旋转。如果你省略这一行,节点将被创建,然后程序将退出,节点将被销毁。让节点旋转意味着我们在这里阻塞执行,程序保持活跃,因此节点也保持活跃。在此期间,正如我们很快将看到的,所有注册的节点回调都可以被处理。当你按下Ctrl + C时,节点将停止旋转,这个函数将返回。

  4. 在节点被杀死后,使用rclpy.shutdown()关闭 ROS 2 通信。这将是你main()函数中的最后一行。

这就是所有你的 ROS 2 程序的工作方式。如你所见,节点实际上是我们程序中创建的一个对象(节点本身不是程序,但当我们谈论程序时,仍然非常常见地提到“节点”这个词)。创建后,节点可以在旋转时保持活跃并发挥其作用。我们很快会回到这个旋转的概念。

最后,我们还添加了这两行:

if __name__ == '__main__':
    main()

这是一个纯 Python 的东西,与 ROS 2 无关。它只是意味着如果你直接运行 Python 脚本,main()函数将被调用,因此你可以尝试你的程序而无需使用colcon安装它。

太好了,你已经编写了你的第一个最小 Python 节点。在你构建和运行它之前,在节点的构造函数中添加一行,这样它就可以做些事情:

class MyCustomNode(Node):
    def __init__(self):
        super().__init__('my_node_name')
        self.get_logger().info("Hello World")

这行代码将在节点启动时打印Hello World

由于 MyCustomNode 类继承自 Node 类,我们可以访问所有 ROS 2 的节点功能。这将使我们的事情变得相当方便。这里有一个使用日志功能的例子:我们从 Node 中获取 get_logger() 方法。然后,使用 info() 方法,我们可以打印一个信息级别的日志。

构建节点

你现在将要构建节点以便运行它。

你可能会想:为什么我们需要构建一个 Python 节点?Python 是一种解释型语言;我们难道不能直接运行文件本身吗?

是的,这是真的:你只需在终端中运行代码即可测试它 ($ python3 my_first_node.py)。然而,我们真正想要做的是在我们的工作区中安装文件,这样我们就可以使用 ros2 run 启动节点,稍后还可以从启动文件中启动。

我们通常使用“构建”这个词,因为要安装 Python 节点,我们必须运行 colcon build

要构建(安装)节点,我们需要在软件包中做一件事。打开 my_py_pkg 软件包中的 setup.py 文件。在文件末尾找到 entry_points'console_scripts'。对于我们要构建的每个节点,我们必须在 'console_scripts' 数组中添加一行:

entry_points={
    'console_scripts': [
        "test_node = my_py_pkg.my_first_node:main"
    ],
},

这里是语法:

<executable_name> = <package_name>.<file_name>:<function_name>.

在正确编写这一行时有一些重要的事情需要注意:

  • 首先,选择一个可执行文件名。这将是你使用 ros2 run <pkg_name> <executable_name> 时的名称。

  • 对于文件名,跳过 .py 扩展名。

  • 函数名是 main,因为我们已经在代码中创建了一个 main() 函数。

  • 如果你想要为另一个节点添加另一个可执行文件,别忘了在每个可执行文件之间添加逗号,并且每个可执行文件占一行。

注意

在学习 ROS 2 时,节点名、文件名和可执行文件名之间有一个常见的混淆:

  • 节点名:在代码中的构造函数内定义。这就是你在 ros2 node listrqt_graph 中看到的内容。

  • 文件名:你编写代码的文件。

  • 可执行文件名:在 setup.py 中定义并用于 ros2 run

在这个第一个例子中,我确保为每个使用了不同的名称,这样你可以意识到这些都是三件不同的事情。但有时这三个名称可以是相同的。例如,你可以创建一个 temperature_sensor.py 文件,然后给你的节点和可执行文件命名为 temperature_sensor

现在你已经给出了创建新可执行文件的指令,请前往你的工作区根目录并构建该软件包:

$ cd ~/ros2_ws/
$ colcon build

你也可以添加 --packages-select my_py_pkg 以仅构建此软件包。

可执行文件现在应该已创建并安装在工作区中(它将被放置在 install 目录中)。我们可以这样说,你的 Python 节点已经构建或安装了。

运行节点

现在你可以运行你的第一个节点了,但在运行之前,请确保工作区已在你的环境中源码:

$ source ~/.bashrc

这个文件已经包含了源码工作空间的行;你也可以打开一个新的终端,或者从工作空间中源码setup.bash脚本。

你现在可以使用ros2 run来运行你的节点(如果你有任何疑问,请回到我们在第三章中做的实验):

$ ros2 run my_py_pkg test_node
[INFO] [1710922181.325254037] [my_node_name]: Hello World

太好了,我们看到日志Hello World。你的第一个节点已经成功运行。请注意,我们在ros2 run命令中写了test_node,因为这是我们选择在setup.py文件中的可执行文件名。

现在,你可能会注意到程序在那里挂起了。节点仍然活着,因为它正在自旋。要停止节点,请按Ctrl + C

改进节点——计时器和回调

到目前为止,你可能觉得编写、构建和运行一个节点是一个漫长且复杂的过程。实际上,它并不那么复杂,而且随着你创建的新节点的增加而变得更容易。此外,修改现有的节点甚至更容易。我们现在就看看。

我们运行的这个节点非常基础。让我们再添加一个功能,做一些更有趣的事情。

我们的这个节点在启动时会打印一段文本。现在我们希望节点每秒打印一个字符串,只要它还活着。

“每 Y 秒执行 X 动作”这种行为在机器人技术中非常常见。例如,你可以有一个节点“每 2 秒读取一次温度”,或者“每 0.1 秒发送一个新的电机命令”。

如何做到这一点?我们将在我们的节点中添加一个计时器。计时器将以指定的速率触发一个回调函数。

让我们回到代码中,并修改MyCustomNode类。其余的代码保持不变:

class MyCustomNode(Node):
    def __init__(self):
        super().__init__('my_node_name')
        self.counter_ = 0
        self.timer_ = self.create_timer(1.0, self.print_hello)
    def print_hello(self):
        self.get_logger().info("Hello " + str(self.counter_))
        self.counter_ += 1

我们仍然有带有super()的构造函数,但现在日志在单独的方法中。此外,我们不再只是打印Hello World,在这里我们创建了一个counter_属性,每次我们使用日志时都会增加它。

注意

如果你想知道为什么每个类属性末尾都有一个尾随下划线_,这是一个常见的面向对象编程约定,我遵循这个约定来指定一个变量是类属性。它仅仅是一个视觉上的帮助,没有其他功能。你可以遵循相同的约定或使用另一个约定——只要确保在一个项目中保持一致性。

最重要的一行是创建计时器的代码。为了创建计时器,我们使用Node类的create_timer()方法。我们需要提供两个参数:我们想要调用函数的速率(浮点数)和回调函数。请注意,回调函数应指定不带任何括号。

这条指令意味着我们想要每1.0秒调用一次print_hello方法。

现在我们来尝试一下代码。因为我们已经在setup.py文件中指定了如何从这个文件创建可执行文件,所以我们不需要再次做这件事。

我们需要做的只是构建、源码和运行。记住:“构建、源码、运行。”每次你创建一个新的节点或修改现有的节点时,你都必须“构建、源码、运行。”

在终端中,转到你的 ROS 2 工作空间的根目录并构建包:

$ cd ~/ros2_ws/
$ colcon build --packages-select my_py_pkg

注意

--packages-select <pkg_name> 的基础上,你可以添加 --symlink-install 选项,这样你就不必每次修改 Python 节点时都构建包;例如,$ colcon build --packages-select my_py_pkg --symlink-install

你可能会看到一些警告日志,但只要看到以 Finished <<< my_py_pkg 开头的行,就说明它已经正确工作了。这将安装可执行文件,但如果修改了代码,你应该能够运行它而无需再次构建。

两个重要的事情:这仅适用于 Python 包,并且你仍然需要为任何新创建的可执行文件构建包。

然后,从这个终端或另一个终端,执行以下操作:

$ source ~/.bashrc
$ ros2 run my_py_pkg test_node
[INFO] [1710999909.533443384] [my_node_name]: Hello 0
[INFO] [1710999910.533169531] [my_node_name]: Hello 1
[INFO] [1710999911.532731467] [my_node_name]: Hello 2
[INFO] [1710999912.534052411] [my_node_name]: Hello 3

如您所见,构建、源代码和运行的过程相当快,并不复杂。在这里,我们可以看到节点每秒打印一条日志,并且计数器在每条新日志中递增。

现在这是怎么做到的?print_hello() 方法是如何被调用的?我们确实创建了一个计时器,但是代码中并没有直接调用 print_hello()

它之所以能工作,是因为节点正在旋转,这要归功于 rclpy.spin(node)。这意味着节点被保持活跃状态,并且在此期间可以调用所有已注册的回调。我们使用 create_timer() 做的事情仅仅是注册一个回调,这样当节点在旋转时就可以被调用。

这是你第一个回调示例,正如你将在本书的后续章节中看到的,在 ROS 2 中,一切操作都是通过回调来运行的。到目前为止,如果你在语法、回调和旋转方面还有一些困难,不要过于担心。随着你对本书的进步,你会多次重复这个过程。在学习 ROS 2 时,理解伴随着动手实践。

我们现在已经完成了这个 Python 节点。根据你所看到的,你应该能够创建你自己的新 Python 节点(在同一个包或另一个包中)。现在让我们切换到 C++。如果你目前只对使用 Python 学习 ROS 2 感兴趣,你可以跳过 C++ 部分。

创建一个 C++ 节点

我们将要做的与为 Python 节点所做的是完全相同的:创建一个文件,编写节点,构建,源代码,然后运行。

确保你已经阅读了之前的 Python 部分,因为我不会在这里重复所有内容。我们基本上只是看看如何为一个 C++ 节点应用这个过程。

要创建一个 C++ 节点,我们首先需要一个 C++ 包。我们将使用之前创建的 my_cpp_pkg 包。

编写 C++ 节点

让我们为节点创建一个文件。转到 my_cpp_pkg 包内的 src 目录并创建一个 .cpp 文件:

$ cd ~/ros2_ws/src/my_cpp_pkg/src/
$ touch my_first_node.cpp

你也可以直接从你的 IDE 创建文件,而不使用终端。

现在,如果你之前没有这样做,请使用 VS Code 或任何其他 IDE 打开你的工作空间:

$ cd ~/ros2_ws/src/
$ code .

打开 my_first_node.cpp。以下是编写 C++ 节点的最小代码:

#include "rclcpp/rclcpp.hpp"
class MyCustomNode : public rclcpp::Node
{
public:
    MyCustomNode() : Node("my_node_name")
    {
    }
private:
};
int main(int argc, char **argv)
{
    rclcpp::init(argc, argv);
    auto node = std::make_shared<MyCustomNode>();
    rclcpp::spin(node);
    rclcpp::shutdown();
    return 0;
}

注意

如果你使用 VS Code 并输入此代码,你可能会看到 rclcpp 库的包含错误。请确保保存文件并等待几秒钟。如果包含仍然没有被识别,请转到 扩展 选项卡,禁用并重新启用 ROS 扩展。

正如你所见(这与 Python 类似),在 ROS 2 中,我们大量使用 C++ 节点的面向对象编程。

让我们一步一步分析这段代码:

#include "rclcpp/rclcpp.hpp"

我们首先包含 rclcpp,这是 ROS 2 的 C++ 库。这个库包含 rclcpp::Node 类:

class MyCustomNode : public rclcpp::Node
{
public:
    MyCustomNode() : Node("my_node_name")
    {
    }
private:
};

就像我们对 Python 所做的那样,我们创建了一个继承自 Node 类的类。语法不同,但原理相同。从这个 Node 类,我们将能够访问所有 ROS 2 功能:记录器、计时器等。正如你所见,我们在构造函数中也指定了节点名称。目前,节点什么也不做;我们将在一分钟内添加更多功能:

int main(int argc, char **argv)
{
    rclcpp::init(argc, argv);
    auto node = std::make_shared<MyCustomNode>();
    rclcpp::spin(node);
    rclcpp::shutdown();
    return 0;
}

如果你想要能够运行你的 C++ 程序,你需要一个 main() 函数。在这个函数中,我们与 Python 完全相同,只是在语法上有些差异:

  1. 使用 rclcpp::init() 初始化 ROS 2 通信。

  2. 从你新编写的类中创建一个节点对象。正如你所见,我们不是直接创建一个对象,而是创建对该对象的智能指针。在 ROS 2 和 C++ 中,你创建的几乎所有内容都将是一个智能指针(共享、唯一等)。

  3. 然后我们使用 rclcpp::spin() 让节点运行。

  4. 最后,当节点停止时(Ctrl + C),我们使用 rclcpp::shutdown() 关闭所有 ROS 2 通信。

这个 main() 函数的结构将非常类似于你所有的 ROS 2 程序。正如你所见,再次强调,节点本身不是程序。节点是在程序内部创建的。

在我们进一步构建、源代码和运行我们的节点之前,让我们现在通过计时器、回调和日志来改进它。

修改 MyCustomNode 类,其余保持不变:

class MyCustomNode : public rclcpp::Node
{
public:
    MyCustomNode() : Node("my_node_name"), counter_(0)
    {
        timer_ = this->create_wall_timer(std::chrono::seconds(1), std::bind(&MyCustomNode::print_hello, this));
    }
    void print_hello()
    {
        RCLCPP_INFO(this->get_logger(), "Hello %d", counter_);
        counter_++;
    }
private:
    int counter_;
    rclcpp::TimerBase::SharedPtr timer_;
};

这个代码示例将执行与 Python 节点相同的功能。我们创建一个计时器,以便每 1.0 秒调用一次回调函数。在这个回调函数中,我们打印 Hello 后跟一个计数器,每次调用时都会增加。

与 C++ 相关有一些特定性:

  • 对于计时器,我们必须创建一个类属性。正如你所见,我们在这里也创建了一个共享指针:rclcpp::TimerBase::SharedPtr

  • 我们使用 this->create_wall_timer() 创建计时器。this-> 在这里不是必需的,但我添加它来强调我们正在使用 Node 类的 create_wall_timer() 方法。

  • 要在计时器中指定回调,由于我们处于 C++ 类中,我们必须使用 std::bind(&ClassName::method_name, this)。确保你不对方法名称使用任何括号。

节点现在已经完成,因此我们可以构建它。

构建和运行节点

我们不能直接运行 C++ 文件;我们首先必须编译它并创建一个可执行文件。为此,我们将编辑 CMakeLists.txt 文件。打开此文件,在几行之后,你会找到类似以下的内容:

# find dependencies
find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)

查找 rclcpp 的行在这里,因为我们创建包时使用了 ros2 pkg create 并提供了 --dependencies rclcpp。之后,如果这个包中的节点需要更多的依赖,你可以在这里添加依赖,每行一个。

在此行之后,添加一个额外的空行,然后添加以下指令:

add_executable(test_node src/my_first_node.cpp)
ament_target_dependencies(test_node rclcpp)
install(TARGETS
  test_node
  DESTINATION lib/${PROJECT_NAME}/
)

要构建一个 C++ 节点,我们需要做三件事:

  1. 使用 add_executable() 函数添加一个新的可执行文件。在这里,你必须为可执行文件选择一个名称(将用于 ros2 run <pkg_name> <executable_name>),我们还需要指定 C++ 文件的相对路径。

  2. 使用 ament_target_dependencies() 函数链接此可执行文件的所有依赖。

  3. 使用 install() 指令安装可执行文件,这样我们就可以在运行 ros2 run 时找到它。在这里,我们将可执行文件放在 lib/<package_name> 目录中。

然后,对于你创建的每个新的可执行文件,你需要重复 步骤 1步骤 2,并在 install() 指令内添加可执行文件,每行一个,不要加逗号。不需要为每个可执行文件创建一个新的 install() 指令。

注意

你的 CMakeLists.txt 文件的末尾将包含一个以 if(BUILD_TESTING) 开头的块,然后是 ament_package()。由于我们这里没有进行任何构建测试,你可以删除整个 if 块。只需确保保留 ament_package() 行,这应该是文件的最后一行。

现在,你可以使用 colcon build 构建包,这将创建并安装可执行文件:

$ cd ~/ros2_ws/
$ colcon build --packages-select my_cpp_pkg

如果在构建过程中遇到任何错误,请首先修复你的代码,然后再次构建。然后,你可以源环境,并运行你的可执行文件:

$ source ~/.bashrc
$ ros2 run my_cpp_pkg test_node
[INFO] [1711006463.017149024] [my_node_name]: Hello 0
[INFO] [1711006464.018055674] [my_node_name]: Hello 1
[INFO] [1711006465.015927319] [my_node_name]: Hello 2
[INFO] [1711006466.015355747] [my_node_name]: Hello 3

如你所见,我们运行了 test_node 可执行文件(由 my_first_node.cpp 文件构建),这将启动 my_node_name 节点。

你现在已经成功编写了一个 C++ 节点。对于你创建的每个新节点,你将必须创建一个新的 C++ 文件,编写节点类,在 CMakeLists.txt 中为新可执行文件设置构建指令,并构建包。然后,要启动节点,源环境并使用 ros2 run 运行可执行文件。

Python 和 C++ 节点的模板

本书开始的所有节点都将遵循相同的结构。为了快速入门提供额外的帮助,我创建了一个节点模板,你可以用它来编写任何 Python 或 C++ 节点的基类。我在创建新节点时也使用这些模板,因为代码可能相当重复。

你可以直接从这本书复制粘贴模板,或者从 GitHub 仓库下载它们:github.com/PacktPublishing/ROS-2-from-Scratch

Python 节点的模板

使用此代码来启动任何新的 Python 节点:

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
class MyCustomNode(Node): # MODIFY NAME
    def __init__(self):
        super().__init__("node_name") # MODIFY NAME
def main(args=None):
    rclpy.init(args=args)
    node = MyCustomNode() # MODIFY NAME
    rclpy.spin(node)
    rclpy.shutdown()
if __name__ == "__main__":
    main()

您需要做的就是移除 MODIFY NAME 注释,并更改类名(MyCustomNode)和节点名("node_name")。使用有意义的名称会更好。例如,如果您正在编写一个从温度传感器读取数据的节点,您可以将类命名为 TemperatureSensorNode,节点可以是 temperature_sensor

C++ 节点的模板

使用此代码来启动任何新的 C++ 节点:

#include "rclcpp/rclcpp.hpp"
class MyCustomNode : public rclcpp::Node // MODIFY NAME
{
public:
    MyCustomNode() : Node("node_name") // MODIFY NAME
    {
    }
private:
};
int main(int argc, char **argv)
{
    rclcpp::init(argc, argv);
    auto node = std::make_shared<MyCustomNode>(); // MODIFY NAME
    rclcpp::spin(node);
    rclcpp::shutdown();
    return 0;
}

移除 MODIFY NAME 注释并重命名类和节点。

这两个模板将允许您更快地启动节点。我建议您尽可能多地使用它们。

检查您的节点

为了完成本章,我们将使用 ros2 node 命令行进行一些练习。

到目前为止,您已经看到了如何编写节点、构建它并运行它。一个缺失的部分是了解如何检查您的节点。即使节点可以运行,这也并不意味着它将完全按照您希望的方式执行。

能够检查您的节点将帮助您修复您可能在代码中犯的错误。它还将允许您轻松地找到有关您启动但未编写的其他节点的更多信息(如我们在 第三章 中的发现阶段所做的那样)。

对于 第二部分 中的每个核心概念,我们将花一些时间来实验与该概念相关的命令行工具。节点的命令行工具是 ros2 node

首先,在我们使用 ros2 node 之前,我们必须启动一个节点。作为回顾,要启动节点,我们使用 ros2 run <package_name> <executable_name>。如果我们启动本章中创建的 Python 节点,我们使用这个:

$ ros2 run my_py_pkg test_node

只有在我们启动了一个节点之后,我们才能使用 ros2 node 进行一些检查。

ros2 node 命令行

要列出所有正在运行的节点,使用 ros2 node list

$ ros2 node list
/my_node_name

我们找到了在代码中定义的节点名称。

一旦我们有了节点名称,我们可以使用 ros2 node info <node_name> 获取更多关于它的信息:

$ ros2 node info /my_node_name
/my_node_name
  Subscribers:
  Publishers:
    /parameter_events: rcl_interfaces/msg/ParameterEvent
    /rosout: rcl_interfaces/msg/Log
  Service Servers:
    /my_node_name/describe_parameters: rcl_interfaces/srv/DescribeParameters
    /my_node_name/get_parameter_types: rcl_interfaces/srv/GetParameterTypes
    /my_node_name/get_parameters: rcl_interfaces/srv/GetParameters
    /my_node_name/get_type_description: type_description_interfaces/srv/GetTypeDescription
    /my_node_name/list_parameters: rcl_interfaces/srv/ListParameters
    /my_node_name/set_parameters: rcl_interfaces/srv/SetParameters
    /my_node_name/set_parameters_atomically: rcl_interfaces/srv/SetParametersAtomically
  Service Clients:
  Action Servers:
  Action Clients:

如您所见,终端上有很多东西。我们将在接下来的章节中了解它们。使用 ros2 node info <node_name> 可以查看此节点运行的所有主题(发布者/订阅者)、服务和动作。

在运行时更改节点名称

随着我们在本书中的进展,我将为您提供一些关于使用 ROS 2 和命令行的额外技巧。这里有一个:在启动可执行文件时,您可以选择使用默认的节点名称(代码中定义的名称)或用新名称替换它。

要向 ros2 run 添加任何额外的参数,首先需要添加 --ros-args(只添加一次)。

然后,要重命名节点,添加 -r __node:=<new_name>-r 表示重映射;您也可以使用 --remap。例如,如果我们想将节点命名为 abc,我们可以使用这个:

$ ros2 run my_py_pkg test_node --ros-args -r __node:=abc
[INFO] [1711010078.801996629] [abc]: Hello 0
[INFO] [1711010079.805748394] [abc]: Hello 1

如从日志中所示,我们看到的不是 my_node_name,而是 abc

列出所有正在运行的节点:

$ ros2 node list
/abc

这个功能非常有用,它让你能够更好地控制如何启动节点,而无需直接修改代码。

注意

当运行多个节点时,你应该确保每个节点都有一个独特的名称。如果有两个节点具有相同的名称,可能会导致一些意想不到的问题,这些问题可能需要很长时间才能调试。在未来,你可能需要运行同一个节点多次,例如,三个temperature_sensor节点,每个节点对应不同的传感器。你可以将它们重命名为temperature_sensor_1temperature_sensor_2temperature_sensor_3

摘要

在本章中,你已经创建了你的第一个节点。让我们快速回顾一下所有步骤。

在创建任何节点之前,你需要遵循以下步骤:

  1. 你首先需要创建并设置一个 ROS 2 工作区。

  2. 在这个工作区中,你可以创建几个包(Python 或 C++),它们代表应用程序的不同子部分。

然后,在一个包中你可以创建一个或多个节点。对于每个节点,你都需要执行以下操作:

  1. 在包内创建一个文件。

  2. 编写节点(以 OOP 模板为基础)。

  3. 设置构建说明(Python 的setup.py,C++的CMakeLists.txt)。

  4. 构建包。

要运行节点,别忘了首先源代码工作区,然后使用ros2 run <pkg_name> <executable_name>启动节点。

最后,当你启动节点时,可以使用ros2 node命令行来检查你的节点,甚至更改它们的名称。

随时可以回到本章,查看创建 Python 和 C++节点的完整过程。所有代码都可在 GitHub 上找到,网址为github.com/PacktPublishing/ROS-2-from-Scratch。在那里你可以找到 Python 和 C++的 OOP 模板代码,my_py_pkg包和my_cpp_pkg包。

在本章中,你还看到了如何创建计时器和回调函数。你对自旋机制的工作原理有了更好的理解,以及它是如何使节点保持活跃并运行回调的。这将在后续章节中非常有用。

在下一章中,我们将看到节点如何通过主题进行通信。你将在节点内部编写自己的主题(发布者/订阅者)并对其进行实验。

第五章:主题 – 在节点之间发送和接收消息

现在你能够编写节点了,你如何让多个节点相互通信,以及如何与应用程序中的现有节点交互?

ROS 2 中有三种通信方式:主题、服务和动作。在本章中,我们将深入研究 ROS 2 主题。

为了理解主题是如何工作的,我们将从现实生活中的类比开始。这将帮助你利用现有的和常见的知识来掌握这个概念。然后,你将深入代码,在节点内部编写一个发布者和一个订阅者——首先使用现有的接口,然后通过构建自定义接口。你还将使用 ROS 2 工具,如 ros2 命令行和 rqt_graph 来检查主题并解锁更多功能。

到本章结束时,你将能够使用 ROS 2 主题让你的节点相互通信。你将通过编写代码来学习,并在本章末尾提供额外的挑战。

主题在 ROS 2 中无处不在。无论你希望从头开始创建一个应用程序还是使用现有的 ROS 插件,你都将不得不使用主题。

我们将使用本书 GitHub 仓库中 ch4 文件夹内的代码(github.com/PacktPublishing/ROS-2-from-Scratch)作为起点。你可以在 ch5 文件夹中找到最终代码。

本章将涵盖以下主题:

  • ROS 2 主题是什么?

  • 编写主题发布者

  • 编写主题订阅者

  • 处理主题的附加工具

  • 为主题创建自定义接口

  • 主题挑战 – 闭环控制

ROS 2 主题是什么?

你通过在 第三章 中的动手实验发现了主题的概念。通过这个,你应该对事物是如何工作的有一个基本的直觉。

我现在将从零开始再次解释主题——不是通过运行代码,而是通过使用使理解更简单的现实生活类比。我们将逐步构建一个示例,然后总结最重要的要点。

发布者和订阅者

对于这个类比,我将使用无线电发射机和接收机。由于这是一个简化的例子,我说的关于无线电的每一件事可能都不完全正确,但这里的重点是理解 ROS 2 主题。

让我们从一台无线电发射机开始。这台无线电发射机将在指定的频率上发送一些数据。为了便于人们记忆,这个频率通常用一个数字表示,例如 98.7。我们甚至可以把 98.7 看作一个名字。如果你想收听广播,你知道你需要将你的设备连接到 98.7

在这种情况下,我们可以这样说,98.7 是一个主题。这个主题上的无线电发射机是一个 发布者

图 5.1 – 无线电发射机向 98.7 主题发布

图 5.1 – 无线电发射机向 98.7 主题发布

现在,假设你想用你的手机收听那台收音机。你将要求你的手机连接到 98.7 来接收数据。

通过这个类比,手机就是 98.7 主题的 订阅者

这里需要注意的一个重要事项是,无线电发射台和手机必须使用相同类型的频率。例如,如果无线电发射台使用调幅信号,而手机试图解码调频信号,那么它将不起作用。

类似地,在 ROS 2 主题中,发布者和订阅者必须使用相同的数据类型。这种数据类型被称为 接口

这就是定义主题的内容:一个 名称 和一个接口:

图 5.2 – 使用相同接口的发布者和订阅者

图 5.2 – 使用相同接口的发布者和订阅者

这样,通信就完成了。无线电发射台在 98.7 主题上发布调幅信号。电话订阅 98.7 主题,解码调幅信号。

多个发布者和订阅者

在现实生活中,不会只有一个设备试图收听广播。让我们添加一些更多的设备,每个设备都订阅 98.7 主题并解码调幅信号:

图 5.3 – 具有多个订阅者的主题

图 5.3 – 具有多个订阅者的主题

如您所见,一个主题可以有多个订阅者。每个订阅者都会得到相同的数据。另一方面,我们也可以为同一个主题有多个发布者。

想象一下,还有一个无线电发射台,也在向 98.7 发送调幅信号。在这种情况下,来自第一个发射台和第二个发射台的数据都被所有收听设备接收:

图 5.4 – 多个发布者和订阅者

图 5.4 – 多个发布者和订阅者

前面的图中显示了方框。每个方框代表一个节点。因此,我们有两个无线电发射台节点,都包含一个向 98.7 主题发布的发布者。我们还有三个节点(电话、收音机和汽车),每个节点都包含一个 98.7 的订阅者。

注意,一个订阅者并不知道其他订阅者。当你用手机收听广播时,你不知道还有谁在收听广播,以及他们在什么设备上。

此外,电话、收音机和汽车并不知道谁在广播。它们只知道它们需要订阅 98.7;它们不知道背后是什么。

在另一边,两个无线电发射台并不知道彼此以及谁在接收数据。它们只是在主题上发布,不管谁在收听。因此,我们说主题是 匿名的。发布者和订阅者并不知道其他发布者和订阅者。他们只通过名称和接口发布或订阅主题。

发布者和订阅者的任何组合都是可能的。例如,你可以在一个主题上有两个发布者而没有订阅者。在这种情况下,数据仍然被正确发布,但没有人为其接收。或者,你也可以有零个发布者和一个或多个订阅者。订阅者将监听主题,但不会收到任何内容。

一个节点内部有多个发布者和订阅者

节点不仅限于只有一个发布者或一个订阅者。

让我们在我们的例子中添加另一个无线电。我们将命名为101.3,其数据类型为 FM 信号。

第二个无线电发射器现在同时在98.7主题和101.3主题上发布,为每个主题发送适当类型的数据。让我们也让汽车监听101.3主题:

图 5.5 – 具有两个发布者的节点

图 5.5 – 具有两个发布者的节点

如你所见,第二个无线电发射器可以在多个主题上发布,只要它为每个主题使用正确的名称和接口。

现在,想象一下,当汽车在收听广播的同时,也将它的 GPS 坐标发送到一个远程服务器。我们可以创建一个名为car_location的主题,其接口将包含纬度和经度。汽车节点现在包含一个订阅98.7主题的订阅者,和一个发布car_location主题的发布者:

图 5.6 – 具有发布者和订阅者的节点

图 5.6 – 具有发布者和订阅者的节点

在前面的图中,我还为服务器添加了另一个节点,用一台计算机表示。服务器节点将订阅car_location主题,以便接收 GPS 坐标。当然,发布者和订阅者都在使用相同的接口(纬度和经度)。

因此,在一个节点内部,你可以有任意数量的发布者和订阅者,针对不同主题和不同数据类型。一个节点可以同时与多个节点通信。

总结

ROS 2 节点可以使用主题向其他节点发送消息。

主题主要用于发送数据流。例如,你可以为相机传感器创建一个硬件驱动程序,并发布从相机拍摄的照片。其他节点可以订阅该主题并接收照片。你也可以发布一个用于机器人移动的命令流,等等。

使用主题的可能性有很多,随着你在本书中的进展,你会了解更多关于它们的信息。

以下是关于主题如何工作的一些重要点:

  • 主题由一个名称和一个接口定义。

  • 主题名称必须以字母开头,可以跟其他字母、数字、下划线、波浪符和斜杠。对于与无线电的真实类比,我使用了带点的数字作为主题名称。虽然这使例子更容易理解,但这对于 ROS 2 主题是不适用的。为了使其有效,我们不应该使用98.7,而应该创建一个名为radio_98_7的主题。

  • 任何主题的发布者或订阅者都必须使用相同的接口。

  • 发布者和订阅者是匿名的。它们不知道彼此;它们只知道它们正在发布或订阅一个主题。

  • 一个节点可以包含多个不同主题的发布者和订阅者。

现在如何创建发布者或订阅者?

你将通过向你的节点添加一些代码来完成这项工作。正如你之前看到的,你可以使用rclpy编写 Python 节点,使用rclcpp编写 C++节点。使用这两个库,你可以在你的节点中直接创建发布者和订阅者。

编写主题发布者

在本节中,你将编写你的第一个 ROS 2 发布者。为了处理核心概念,我们将创建一个新的 ROS 2 应用程序,并在接下来的章节中在此基础上构建。这个应用程序将非常简约,这样我们就可以专注于我们想要学习的概念,其他什么都不考虑。

我们现在想要做的是就一个主题发布一个数字。这个主题是新的,我们将创建它。你实际上并不是创建一个主题——你创建的是对该主题的发布者或订阅者。这将自动创建主题名称,该名称将在图中注册。

要编写发布者,我们需要一个节点。我们可以使用上一章中创建的第一个节点,但节点的目的并不相同。因此,我们将创建一个新的节点,命名为number_publisher。在这个节点中,我们将创建一个发布者。至于我们想要发布的主题,我们必须选择一个名称和接口。

现在,让我们开始使用 Python。

编写 Python 发布者

要编写发布者,我们需要创建一个节点;要创建节点,我们需要一个包。为了简化事情,让我们继续使用my_py_pkg包。

创建一个节点

my_py_pkg包内部导航,创建一个 Python 文件,并使其可执行:

$ cd ~/ros2_ws/src/my_py_pkg/my_py_pkg/
$ touch number_publisher.py
$ chmod +x number_publisher.py

现在,打开这个文件,使用节点 OOP 模板(在第四章中给出),并修改所需的字段,以给出有意义的名称:

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
class NumberPublisherNode(Node):
    def __init__(self):
        super().__init__("number_publisher")
def main(args=None):
    rclpy.init(args=args)
    node = NumberPublisherNode()
    rclpy.spin(node)
    rclpy.shutdown()
if __name__ == "__main__":
    main()

现在你已经有了你的节点的主函数和NumberPublisherNode类,我们可以创建一个发布者。

在节点中添加发布者

我们在哪里可以创建一个发布者?我们将在构造函数中完成这项工作。

在我们编写代码之前,我们需要问自己一个问题:这个主题的名称和接口是什么?

  • 情况 1:你正在向一个已存在的主题发布(该主题的其他发布者或订阅者),然后你使用相同的名称和接口

  • 情况 2:你为一个新的主题创建发布者(我们现在正在做),然后你必须选择一个名称和接口

对于名称,让我们保持简单,使用number。如果我们发布一个数字,我们可以在number主题上期望接收到这个数字。如果你要发布温度,你可以将主题命名为temperature

对于接口,你有两个选择:使用现有的接口或创建一个自定义接口。为了开始,我们将使用现有的接口。为了使这个过程更简单,我将直接告诉你使用什么;你将在以后自己学习如何找到其他接口。

让我们使用example_interfaces/msg/Int64。要获取接口中包含的更多详细信息,我们可以在终端中运行ros2 interface show <interface_name>

$ ros2 interface show example_interfaces/msg/Int64
# Some comments
int64 data

太好了——这正是我们需要的:一个int64数字。

现在我们有了这些信息,让我们创建发布者。首先,导入接口,然后在构造函数中创建发布者:

import rclpy
from rclpy.node import Node
from example_interfaces.msg import Int64
class NumberPublisherNode(Node):
    def __init__(self):
        super().__init__("number_publisher")
        self.number_publisher_ = self.create_publisher(Int64, "number", 10)

要导入接口,我们必须指定包的名称(example_interfaces),然后是主题消息的文件夹名称(msg),最后是接口的类(Int64)。

要创建发布者,我们必须使用Node类的create_publisher()方法。从该类继承使我们能够访问所有 ROS 2 功能。在这个方法中,你必须提供三个参数:

  • 来自example_interfaces包的Int64

  • number

  • 每次使用10

这样,我们现在在number主题上有一个发布者。然而,如果你只是这样运行你的代码,什么也不会发生。发布者不会自动在主题上发布。你必须编写代码来实现这一点。

使用计时器发布

在机器人技术中,一个常见的做法是每Y秒执行X个动作——例如,每0.5秒从摄像头发布一张图像,或者在这种情况下,每1.0秒在主题上发布一个数字。如第四章中所示,为了做到这一点,你必须实现一个计时器和回调函数。

修改节点内的代码,以便在计时器回调中发布到主题:

def __init__(self):
    super().__init__("number_publisher")
    self.number_ = 2
    self.number_publisher_ = self.create_publisher(Int64, "number", 10)
    self.number_timer_ = self.create_timer(1.0, self.publish_number)
    self.get_logger().info("Number publisher has been started.")
def publish_number(self):
    msg = Int64()
    msg.data = self.number_
    self.number_publisher_.publish(msg)

在使用self.create_publisher()创建发布者后,我们使用self.create_timer()创建一个计时器。在这里,我们说我们希望每1.0秒调用一次publish_number()方法。这将在节点旋转时发生。

此外,我在构造函数的末尾添加了一个日志,说明节点已启动。我通常这样做作为最佳实践,这样我就可以在终端上看到节点何时完全初始化。

publish_number()方法中,我们在主题上发布:

  1. 我们从Int64类创建一个对象。这就是接口——换句话说,是要发送的消息。

  2. 此对象包含一个data字段。我们是如何知道这个的?我们之前在运行ros2 interface show example_interfaces/msg/Int64时找到了这个。因此,我们在消息的data字段中提供一个数字。为了简单起见,我们每次运行回调函数时都指定相同的数字。

  3. 我们使用发布者的publish()方法发布消息。

这种代码结构在 ROS 2 中非常常见。每次你想从传感器发布数据时,你都会编写类似的内容。

构建发布者

要尝试你的代码,你需要安装节点。

在我们这样做之前,由于我们使用了一个新的依赖项(example_interfaces 包),我们还需要向 my_py_pkg 包的 package.xml 文件中添加一行:

<depend>rclpy</depend>
<depend>example_interfaces</depend>

随着您在包内添加更多功能,您将在这里添加任何其他 ROS 2 依赖项。

要安装节点,打开 my_py_pkg 包中的 setup.py 文件并添加一行以创建另一个可执行文件:

entry_points={
    'console_scripts': [
        "test_node = my_py_pkg.my_first_node:main",
        "number_publisher = my_py_pkg.number_publisher:main"
    ],
},

确保在每一行之间添加一个逗号;否则,在构建包时可能会遇到一些奇怪的错误。

在这里,我们创建了一个名为 number_publisher 的新可执行文件。

注意

这次,如您从这个示例中看到的那样,节点名称、文件名和可执行文件名是相同的:number_publisher。这是一件常见的事情。只需记住,这些名称代表三件不同的事情。

现在,前往您的工作空间根目录并构建 my_py_pkg 包:

$ cd ~/ros2_ws/
$ colcon build --packages-select my_py_pkg

如果您想,可以添加 --symlink-install,这样您就不需要每次修改 number_publisher 节点时都运行 colcon build

运行发布者

在包成功构建后,源工作空间并启动节点:

$ source install/setup.bash # or source ~/.bashrc
$ ros2 run my_py_pkg number_publisher
[INFO] [1711526444.403883187] [number_publisher]: Number publisher has been started.

节点正在运行,但除了初始日志外,没有显示任何内容。这是正常的——我们没有要求节点打印其他任何内容。

我们如何知道发布者正在工作?我们可以立即编写一个订阅者节点并查看我们是否收到消息。但在我们这样做之前,我们可以直接从终端测试发布者。

打开一个新的终端窗口并列出所有主题:

$ ros2 topic list
/number
/parameter_events
/rosout

在这里,您可以找到 /****number 主题。

注意

如您所见,主题名称前增加了一个前置斜杠。我们在代码中只写了 number,而不是 /number。这是因为 ROS 2 名称(节点、主题等)是在命名空间内组织的。稍后,我们将看到您可以为所有主题或节点添加一个命名空间,例如放入 /abc 命名空间中。在这种情况下,主题名称将是 /abc/number。在这里,因为没有提供命名空间,所以名称前添加了一个前置斜杠,即使我们在代码中没有提供它。我们可以称这为 全局 命名空间。

使用 ros2 topic echo <topic_name> 命令,您可以直接从订阅者订阅主题并查看正在发布的内容。我们将在本章后面了解更多关于这个命令的信息:

$ ros2 topic echo /number
data: 2
---
data: 2
---

如您所见,我们每秒得到一条新消息,其中包含一个值为 2data 字段。这正是我们在代码中想要做的。

这样,我们就完成了我们的第一个 Python 发布者。让我们切换到 C++。

编写 C++ 发布者

在这里,过程与 Python 相同。我们将创建一个新的节点,并在该节点中添加一个发布者和计时器。在计时器回调函数中,我们将创建一个消息并发布它。

我会在这一节中稍微快一点,因为解释是相同的。我们只需关注 ROS 2 中 C++ 语法的具体性。

注意

对于本书中与 C++相关的所有内容,请确保你遵循使用 GitHub 代码旁边的解释。我可能不会提供完整的代码,只提供对理解至关重要的重要片段。

创建一个带有发布者和计时器的节点

首先,让我们在my_cpp_pkg包中为我们的number_publisher节点创建一个新的文件:

$ cd ~/ros2_ws/src/my_cpp_pkg/src/
$ touch number_publisher.cpp

打开这个文件并编写节点的代码。你可以从 OOP 模板开始,添加发布者、计时器和回调函数。本章的完整代码可以在本书的 GitHub 仓库中找到:github.com/PacktPublishing/ROS-2-from-Scratch

我现在将对几行重要的代码进行注释:

#include "rclcpp/rclcpp.hpp"
#include "example_interfaces/msg/int64.hpp"

要包含一个主题的接口,使用"<package_name>/msg/<message_name>.hpp"

然后,在构造函数中添加以下内容:

number_publisher_ = this->create_publisher<example_interfaces::msg::Int64>("number", 10);

在 C++中,我们也使用Node类的create_publisher()方法。由于使用了模板,语法略有不同,但你仍然可以找到主题接口、主题名称和队列大小(作为提醒,你可以每次都设置为10)。

发布者也在类中声明为私有属性:

rclcpp::Publisher<example_interfaces::msg::Int64>::SharedPtr number_publisher_;

如你所见,我们使用了rclcpp::Publisher类,对于 ROS 2 中的许多事物,我们使用共享指针。对于几个常见的类,ROS 2 提供了::SharedPtr,这和写std::shared_ptr是同一回事。

让我们回到构造函数:

number_timer_ = this->create_wall_timer(std::chrono::seconds(1), std::bind(&NumberPublisherNode::publishNumber, this));
RCLCPP_INFO(this->get_logger(), "Number publisher has been started.");

在创建发布者之后,我们创建一个计时器,每1.0秒调用一次publishNumber方法。最后,我们打印一条日志,以便我们知道构造函数代码已被执行:

void publishNumber()
{
    auto msg = example_interfaces::msg::Int64();
    msg.data = number_;
    number_publisher_->publish(msg);
}

这是回调方法。至于 Python,我们从接口类创建一个对象,然后填充这个接口的任何字段并发布消息。

构建和运行发布者

一旦你写好了带有发布者、计时器和回调函数的节点,就是时候构建它了。

就像我们对 Python 所做的那样,打开my_cpp_pkg包的package.xml文件,并为example_interfaces依赖项添加一行:

<depend>rclcpp</depend>
<depend>example_interfaces</depend>

然后,从my_cpp_pkg包中打开CMakeLists.txt文件并添加以下行:

find_package(rclcpp REQUIRED)
find_package(example_interfaces REQUIRED)
add_executable(test_node src/my_first_node.cpp)
ament_target_dependencies(test_node rclcpp)
add_executable(number_publisher src/number_publisher.cpp)
ament_target_dependencies(number_publisher rclcpp example_interfaces)
install(TARGETS
  test_node
  number_publisher
  DESTINATION lib/${PROJECT_NAME}/
)

对于任何新的依赖项,我们需要添加一个新的find_package()行。

然后,我们创建一个新的可执行文件。请注意,我们还在ament_target_dependencies()的参数中提供了example_interfaces。如果你省略了这个,你将在编译时遇到错误。

最后,没有必要重新创建install()块。只需在新的一行中添加可执行文件,行与行之间不要有任何逗号。

现在,你可以构建、源和运行:

$ cd ~/ros2_ws/
$ colcon build --packages-select my_cpp_pkg
$ source install/setup.bash
$ ros2 run my_cpp_pkg number_publisher
[INFO] [1711528108.225880935] [number_publisher]: Number publisher has been started.

包含发布者的节点正在运行。通过使用ros2 topic listros2 topic echo <topic_name>,你可以找到主题并查看正在发布的内容。

现在你已经创建了一个发布者并且知道它在工作,是时候学习如何为该主题创建一个订阅者了。

编写主题订阅者

为了继续改进我们的应用程序,让我们创建一个新的节点,该节点将订阅/number主题。接收到的每个数字都将添加到计数器中。我们希望在计数器更新时打印计数器。

正如我们之前所做的那样,让我们先用 Python 进行完整解释,然后看看 C++的语法特性。

编写 Python 订阅者

你可以在 GitHub 上找到这个 Python 节点的完整代码。这里我们需要做的许多事情与之前所做的相同,所以我不将每一步都详细说明。相反,我们将关注最重要的事情,以便我们可以编写订阅者。

创建具有订阅者的 Python 节点

my_py_pkg包内创建一个名为number_counter的新节点:

$ cd ~/ros2_ws/src/my_py_pkg/my_py_pkg/
$ touch number_counter.py
$ chmod +x number_counter.py

在这个文件中,你可以编写节点的代码并添加一个订阅者。以下是逐步解释:

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from example_interfaces.msg import Int64

由于我们想要创建一个订阅者来接收我们通过发布者发送的内容,因此我们需要使用相同的接口。因此,我们也导入Int64。然后,我们可以创建订阅者:

class NumberCounterNode(Node):
    def __init__(self):
        super().__init__("number_counter")
        self.counter_ = 0
        self.number_subscriber_ = self.create_subscription(Int64, "number", self.callback_number, 10)
       self.get_logger().info("Number Counter has been started.")

对于发布者,我们将在节点的构造函数中创建订阅者。在这里,我们使用Node类的create_subscription()方法。使用此方法时,你需要提供四个参数:

  • Int64。这需要发布者和订阅者都相同。

  • number。这与发布者的名称相同。注意,我这里没有提供任何额外的斜杠。这将被自动添加,因此主题名称将是/number

  • 当接收到/number主题时,它将在这里被接收,我们将在回调方法(我们需要实现的方法)内部使用它和处理它。

  • 10并暂时忘记它。

现在,让我们看看回调方法的实现,我将它命名为callback_number

注意

作为最佳实践,我建议为主题命名回调方法为callback_<topic>。通过添加callback_前缀,你可以清楚地表明这是一个回调方法,不应该在代码中直接调用。这可以防止未来出现许多错误。

def callback_number(self, msg: Int64):
    self.counter_ += msg.data
    self.get_logger().info("Counter:  " + str(self.counter_))

在订阅者回调中,你直接在函数的参数中接收消息。由于我们知道Int64包含一个data字段,我们可以使用msg.data来访问它。

现在,我们将接收到的数字添加到counter_属性中,并使用 ROS 2 日志在每次更新时打印计数器。

注意

作为最佳实践,我已经指定了方法的msg参数的Int64类型。这对于 Python 代码工作不是强制性的,但它增加了额外的安全级别(我们确信我们应该接收Int64而不是其他任何东西),有时它可以使你的 IDE 在自动完成时工作得更好。

要完成节点,别忘了在NumberCounterNode类之后添加main()函数。

运行 Python 订阅者

现在,为了尝试代码,向你的 Python 包的setup.py文件中添加一个新的可执行文件:

entry_points={
    'console_scripts': [
        "test_node = my_py_pkg.my_first_node:main",
        "number_publisher = my_py_pkg.number_publisher:main",
        "number_counter = my_py_pkg.number_counter:main"
    ],
},

然后,构建包并源码化工作空间(从现在起,我将不会每次都写这些命令,因为它们总是相同的)。

现在,在不同的终端中运行每个节点(number_publishernumber_counter)。

$ ros2 run my_py_pkg number_publisher
[INFO] [1711529824.816514561] [number_publisher]: Number publisher has been started.
$ ros2 run my_py_pkg number_counter
[INFO] [1711528797.363370081] [number_counter]: Number Counter has been started.
[INFO] [1711528815.739270510] [number_counter]: Counter:  2
[INFO] [1711528816.739186942] [number_counter]: Counter:  4
[INFO] [1711528817.739050485] [number_counter]: Counter:  6
[INFO] [1711528818.738992607] [number_counter]: Counter:  8

如你所见,number_counter 节点每 1.0 秒将 2 添加到计数器中。如果你看到这个,那么你两个节点之间的发布/订阅通信是正常工作的。

你可以启动和停止 number_publisher 节点,并看到每次启动它时,number_counter 都会从当前计数继续添加数字。

编写 C++ 订阅者

让我们在 C++ 中创建一个 number_counter 节点。原理是相同的,所以我们只需关注这里的语法。

创建一个具有订阅者的 C++ 节点

为你的节点创建一个新文件:

$ cd ~/ros2_ws/src/my_cpp_pkg/src/
$ touch number_counter.cpp

打开此文件并编写节点的代码(再次提醒,完整的代码在 GitHub 上)。

要在你的节点中创建一个订阅者,请运行以下代码:

number_subscriber_ = this->create_subscription<example_interfaces::msg::Int64>(
           "number",
           10,
           std::bind(&NumberCounterNode::callbackNumber, this, _1));

我们找到了与 Python 相同的组件(但顺序不同):主题接口、主题名称、队列大小和接收消息的回调。为了 _1 能正常工作,别忘了在它之前添加 using namespace std::placeholders;

注意

即使 rclpyrclcpp 库应该基于相同的底层代码,API 之间仍然可能存在一些差异。如果代码有时在 Python 和 C++ 之间看起来不同,请不要担心。

订阅者对象被声明为一个私有属性:

rclcpp::Subscription<example_interfaces::msg::Int64>::SharedPtr number_subscriber_;

我们在这里使用的是 rclcpp::Subscription 类,并且再次创建了一个指向该对象的智能指针。

然后,我们有回调方法,callbackNumber

void callbackNumber(const example_interfaces::msg::Int64::SharedPtr msg)
{
    counter_ += msg->data;
    RCLCPP_INFO(this->get_logger(), "Counter: %d", counter_);
}

在回调中接收到的消息也是一个(const)智能指针。因此,在访问 data 字段时,别忘了使用 ->

在这个回调中,我们将接收到的数字添加到计数器中并打印出来。

运行 C++ 订阅者

为该节点创建一个新的可执行文件。打开 CMakeLists.txt 并添加以下代码:

add_executable(number_counter src/number_counter.cpp)
ament_target_dependencies(number_counter rclcpp example_interfaces)
install(TARGETS
  test_node
  number_publisher
  number_counter
  DESTINATION lib/${PROJECT_NAME}/
)

然后,构建 my_cpp_pkg,源码化工作空间,并在不同的终端中运行发布者和订阅者节点。你应该会看到与使用 Python 时类似的输出。

同时运行 Python 和 C++ 节点

我们刚刚为 Python 和 C++ 都创建了一个发布者和订阅者。我们使用的主题具有相同的名称(number)和接口(example_interfaces/msg/Int64)。

如果主题相同,这意味着你可以用 C++ 的 number_counter 节点启动 Python 的 number_publisher 节点,例如。

让我们验证一下:

$ ros2 run my_py_pkg number_publisher
[INFO] [1711597703.615546913] [number_publisher]: Number publisher has been started.
$ ros2 run my_cpp_pkg number_counter
[INFO] [1711597740.879160448] [number_counter]: Number Counter has been started.
[INFO] [1711597741.607444197] [number_counter]: Counter: 2
[INFO] [1711597742.607408224] [number_counter]: Counter: 4

你也可以尝试相反的操作,通过运行 C++ 的 number_publisher 节点与 Python 的 number_counter 节点。

为什么它会工作?简单来说,因为 ROS 2 是语言无关的。你可以用任何(支持的)编程语言编写任何节点,并且这个节点可以与网络中的所有其他节点通信,使用主题和其他 ROS 2 通信。

ROS 2 通信在较低级别发生,使用数据分发服务DDS)。这是中间件部分,负责在节点之间发送和接收消息。当你编写 Python 或 C++节点时,你正在使用相同的 DDS 功能,API 在rclpyrclcpp中实现。

我不会对这个解释过于深入,因为它相当高级,并且并不真正属于这本书的范围。如果你从这个中只记住一件事,那就是 Python 和 C++节点可以使用 ROS 2 通信功能相互通信。你可以在 Python 中创建一些节点,在 C++中创建其他节点;只需确保在两边使用相同的通信名称和接口。

处理主题的附加工具

你已经编写了一组包含发布者和订阅者的节点。现在我们将探索 ROS 2 工具如何帮助你使用主题做更多的事情。

我们将探索以下主题:

  • 使用rqt_graph进行自省

  • 使用ros2 topic命令行进行自省和调试

  • 在启动节点时更改主题名称

  • 使用 bags 重放主题数据

使用 rqt_graph 自省主题

我们使用rqt_graph第三章中可视化节点。让我们再次运行它,看看如何自省我们刚刚创建的发布者和订阅者。

首先,启动number_publishernumber_counter节点(从任何包:my_py_pkgmy_cpp_pkg)。

然后,在另一个终端中启动rqt_graph

$ rqt_graph

如果需要,刷新视图几次,并选择节点/主题(全部)。你也可以取消选择死端框和叶主题框。这将允许你看到主题,即使只有一个订阅者没有发布者,或者只有一个发布者没有订阅者:

图 5.7 – rqt_graph 上的数字主题

图 5.7 – rqt_graph 上的数字主题

在那里,我们可以看到number_publisher节点和number_counter节点。中间是/number主题,我们可以看到哪个节点是发布者或订阅者。

rqt_graph包在调试主题时可以非常有用。想象一下,你运行了一些节点,你想知道为什么主题消息没有被订阅者接收。可能这些节点没有使用相同的主题名称。你可以很容易地用rqt_graph看到这一点:

图 5.8 – 发布者和订阅者之间的主题名称不匹配

图 5.8 – 发布者和订阅者之间的主题名称不匹配

在这个例子中,我在发布者内部的主题名称中故意犯了一个错误。我应该写number,但我写了numberr。使用rqt_graph,我可以看到问题所在。这两个节点没有相互通信。

ros2 topic 命令行

使用ros2 node,我们为节点获得了额外的命令行工具。对于主题,我们将使用ros2 topic

如果你运行ros2 topic -h,你会看到有很多命令。你已经知道其中的一些。在这里,我将快速回顾一下,并探索一些在调试主题时可能有用的更多命令。

首先,要列出所有主题,使用ros2 topic list

$ ros2 topic list
/number
/parameter_events
/rosout

如你所见,我们得到了/number主题。你还会始终得到/parameter_events/rosout(所有 ROS 2 日志都发布在这个主题上)。

使用ros2 topic info <topic_name>,你可以获取该主题的接口,以及该主题的发布者和订阅者数量:

$ ros2 topic info /number
Type: example_interfaces/msg/Int64
Publisher count: 1
Subscription count: 1

然后,为了进一步查看接口的详细信息,你可以运行以下命令:

$ ros2 interface show example_interfaces/msg/Int64
# some comments
int64 data

使用这个命令,我们就有了创建附加发布者或订阅者所需的所有信息。

此外,我们还可以直接使用ros2 topic echo <topic_name>从终端订阅主题。这就是我们在编写发布者之后所做的那样,以确保在编写任何订阅者之前它能够正常工作:

$ ros2 topic echo /number
data: 2
---
data: 2
---

另一方面,你可以直接从终端向主题发布消息,使用ros2 topic pub -r <topic_name> <message_in_json>。为了测试这个,停止所有节点,并在一个终端中只启动number_counter节点。除了第一条日志外,不会打印任何内容。然后,在另一个终端中运行以下命令:

$ ros2 topic pub -r 2.0 /number example_interfaces/msg/Int64 \"{data: 7}"
publisher: beginning loop
publishing #1: example_interfaces.msg.Int64(data=7)
publishing #2: example_interfaces.msg.Int64(data=7)

这将在/number主题上以2.0赫兹(每0.5秒)发布。当你运行这个命令时,你会在number_counter节点上看到一些日志,这意味着消息已经被接收:

[INFO] [1711600360.459298369] [number_counter]: Counter: 7
[INFO] [1711600360.960216275] [number_counter]: Counter: 14
[INFO] [1711600361.459896877] [number_counter]: Counter: 21

这样,你可以在不先写一个发布者的情况下测试一个订阅者。请注意,这仅适用于具有简单接口的主题。当接口包含太多字段时,在终端上写所有内容会变得过于复杂。

注意

ros2 topic echoros2 topic pub都可以节省你很多时间,这对于与其他人合作进行项目也非常有用。你可以负责编写发布者,而其他人则编写订阅者。使用这些命令行工具,你们两个人都可以确保主题通信正常工作。然后,当你一起运行这两个节点时,你知道你发送或接收的数据是正确的。

在运行时更改主题名称

第四章中,你学习了如何在运行时更改节点名称——即通过在ros2 run命令后添加--ros-args -r __node:=<new_name>

因此,在ros2 run之后传递的任何附加参数,都添加**--ros-args**,但只需添加一次。

然后,你还可以在运行时更改主题名称。为此,添加另一个-r,后跟<topic_name>:=<new_topic_name>

例如,让我们将我们的主题从number重命名为my_number

$ ros2 run my_py_pkg number_publisher --ros-args -r number:=my_number

现在,如果我们启动number_counter节点,为了能够接收消息,我们还需要修改主题名称:

$ ros2 run my_py_pkg number_counter --ros-args -r number:=my_number

使用这个命令,通信将正常工作,但这次使用的是my_number主题。

为了让事情更有趣,让我们让这两个节点继续运行,并运行另一个发布者到这个主题,使用相同的number_publisher节点。正如你所知,我们不能有两个节点使用相同的名称。因此,我们必须重命名节点和主题。在第三个终端中,运行以下代码:

$ ros2 run my_py_pkg number_publisher --ros-args -r \ __node:=number_publisher_2 -r number:=my_number

运行此命令后,你会发现number_counter接收消息的速度是原来的两倍,因为有两个节点每1.0秒发布一条消息。

此外,让我们启动rqt_graph

图 5.9 – 两个发布者和一个订阅者,主题已重命名

图 5.9 – 两个发布者和一个订阅者,主题已重命名

我们会看到有两个节点在my_number主题上有一个发布者,还有一个节点有一个订阅者。

在运行时更改主题名称将对你非常有用,特别是当你想运行几个无法修改的现有节点时。即使你不能重写代码,你也可以在运行时修改名称。现在,让我们继续使用工具并探索 ROS 2 bags。

使用 bags 回放主题数据

想象这样一个场景:你正在为一个移动机器人工作,该机器人在户外导航时以及下雨时应该以某种方式表现。

现在,这意味着你需要在这些条件下运行机器人,以便你可以开发你的应用程序。有几个问题:你可能不会每次都能接触到机器人,或者你不能将它带出去,或者它并不是每天都下雨。

解决这个问题的方法是使用 ROS 2 bags。Bags 允许你记录一个主题并在以后回放它。因此,你可以一次在所需条件下运行实验,然后回放数据,就像它被记录下来一样。有了这些数据,你可以开发你的应用程序。

让我们考虑另一个场景:你与一个还不稳定的硬件一起工作。大多数时候,它不能正常工作。你可以在硬件正常工作时记录一个 bags,然后回放这个 bags 来开发你的应用程序,而不是反复运行硬件并浪费时间。

要使用 ROS 2 bags,你必须使用ros2 bag命令行工具。让我们学习如何使用 bags 保存和回放一个主题。

首先,停止所有节点,只运行number_publisher节点。

我们已经知道主题名称是/number。如果需要,你可以使用ros2 topic list检索它。然后,在另一个终端中,使用ros2 bag record -o <bag_name>记录 bags。为了使事情更有条理,我建议你创建一个bags文件夹,并在该文件夹内进行记录:

$ mkdir ~/bags
$ cd ~/bags/
$ ros2 bag record /number -o bag1
...
[INFO] [1711602240.190476880] [rosbag2_recorder]: Subscribed to topic '/number'
[INFO] [1711602240.190542569] [rosbag2_recorder]: Recording...
[INFO] [1711602240.190729185] [rosbag2_recorder]: All requested topics are subscribed. Stopping discovery...

到目前为止,bags 正在记录并保存数据库中所有传入的消息。让它运行几秒钟,然后使用Ctrl + C停止它:

[INFO] [1711602269.786924027] [rosbag2_cpp]: Writing remaining messages from cache to the bag. It may take a while
[INFO] [1711602269.787416646] [rosbag2_recorder]: Event publisher thread: Exiting
[INFO] [1711602269.787547010] [rosbag2_recorder]: Recording stopped

ros2 bag 命令将退出,你将得到一个名为 bag1 的新目录。在这个目录中,你会找到一个包含记录消息的 .mcap 文件和一个包含更多信息的 YAML 文件。如果你打开这个 YAML 文件,你会看到记录的持续时间、记录的消息数量和记录的主题。

现在,你可以回放数据包,这意味着你将按照记录时的方式在主题上发布。

停止 number_publisher 节点,并使用 ros2 bag play <path_to_bag> 回放数据包:

$ ros2 bag play ~/bags/bag1/

这将发布所有记录的消息,持续时间与记录相同。所以,如果你记录了 3 分 14 秒,数据包将回放主题 3 分 14 秒。然后,数据包将退出,如果你想再次播放,可以继续播放。

当数据包播放时,你可以运行你的订阅者。你可以使用 ros2 topic echo /number 来进行快速测试并查看数据。你还可以运行你的 number_counter 节点,你会看到消息被接收。

你现在可以使用 ROS 2 数据包来保存和回放一个主题。你可以使用 ros2 bag -h 来探索更多高级选项。

正如你所看到的,有很多可用的工具用于主题。尽可能多地使用这些工具来检查、调试和测试你的主题。它们将在你开发 ROS 2 应用程序时为你节省大量时间。

我们几乎完成了主题的学习。到目前为止,我们所做的一切都是使用现有接口。现在,让我们学习如何创建一个自定义接口。

为主题创建自定义接口

当为主题创建发布者或订阅者时,你知道你必须使用一个名称和一个接口。

发布或订阅现有主题非常简单:你将使用 ros2 命令行找到名称和接口,并在你的代码中使用它。

现在,如果你想为一个新的主题启动发布者或订阅者,你需要自己选择名称和接口:

  • 名称:没问题——它只是一系列字符

  • 接口:你有两个选择——使用与你的主题一起工作的现有接口或创建一个新的接口

让我们尝试应用 ROS 2 的哲学,即不重新发明轮子。当你创建一个新的主题时,检查是否有任何现有的接口可以满足你的需求。如果有,那么就使用它;不要重新创建。

首先,你会学习在哪里可以找到现有接口。然后,你会学习如何创建一个新的接口。

注意

在谈论主题接口时使用“消息”这个词是很常见的。我本可以把这个部分命名为“创建自定义消息”。在接下来的部分中,当我提到消息时,我指的是主题接口。

使用现有接口

在你为主题启动新的发布者或订阅者之前,花点时间思考你想要发送或接收哪种类型的数据。然后,检查是否已经存在的接口包含你所需要的内容。

接口的位置

就像节点一样,接口也是按包组织的。您可以在以下位置找到 ROS 2 接口最常见的包:github.com/ros2/common_interfaces。这里并没有列出所有现有的接口,但已经相当多了。对于其他接口,简单的网络搜索应该会带您到相应的 GitHub 仓库。

在这个常见的接口仓库中,您可以在 geometry_msgs 包中找到我们与 Turtlesim 一起使用的 Twist 消息。如您所见,对于主题接口,我们随后有一个额外的 msg 文件夹,其中包含该包的所有消息定义。

现在,假设您想为相机创建一个驱动节点并将图像发布到主题。如果您查看 sensor_msgs 包,然后查看 msg 文件夹,您会找到一个名为 Image.msg 的文件。这个 Image 消息可能适合您的需求。它也被很多人使用,这将使您的生活更加容易。

在您的代码中使用现有接口

要使用此消息,请确保您已安装包含消息的包——在这种情况下,sensor_msgs。作为一个快速提醒,要安装 ROS 2 包,您可以在终端中运行 sudo apt install ros--

$ sudo apt install ros-jazzy-sensor-msgs

可能该包已经安装了。如果没有,安装后再次 source 您的环境。然后,您可以使用 ros2 interface show 命令找到有关接口的详细信息:

$ ros2 interface show sensor_msgs/msg/Image

要在您的代码中使用此消息,只需遵循本章中我们所做的那样(使用 example_interfaces/msg/Int64 消息):

  1. 在您编写节点的包的 package.xml 文件中,添加对接口包的依赖。

  2. 在您的代码中导入消息并在发布者或订阅者中使用它。

  3. 仅限 C++:在 CMakeLists.txt 文件中将依赖项添加到接口包中。

在我们创建接口之后,很快我们就会看到这个过程的另一个例子。

到目前为止,您已经知道如何在代码中查找和使用现有的消息。但您是否应该总是这样做?

当不使用现有消息时

对于常见的用例、传感器和执行器,您可能会找到您需要的东西。然而,如果接口与您想要的完全不符,您将不得不创建一个新的接口。

有几个包包含基本接口,例如 example_interfaces,甚至 std_msgs。您可能会被诱惑在代码中使用它们。作为一个最佳实践,最好是避免这样做。只需阅读消息定义中的注释以确保这一点:

$ ros2 interface show example_interfaces/msg/Int64
# This is an example message of using a primitive datatype, int64.
# If you want to test with this that's fine, but if you are deploying it into a system you should create a semantically meaningful message type.
# If you want to embed it in another message, use the primitive data type instead.
int64 data
$ ros2 interface show std_msgs/msg/Int64
# This was originally provided as an example message.
# It is deprecated as of Foxy
# It is recommended to create your own semantically meaningful message.
# However if you would like to continue using this please use the equivalent in example_msgs.
int64 data

如您所见,std_msgs 包已被弃用,而example_interfaces 仅推荐用于测试——这正是我们在本章到目前为止所做的一切,以帮助我们学习各种主题。

作为一般规则,如果您在现有的接口包中没有找到您需要的东西,那么就创建自己的接口。这并不难做,而且总是同一个过程。

创建一个新的主题接口

现在,你将创建你的第一个针对主题的自定义接口。我们将看到如何为该包设置,如何创建和构建接口,以及如何在我们的代码中使用它。

创建和设置接口包

在我们创建任何主题接口(消息)之前,我们需要创建一个新的包,并为其构建接口设置。作为一个最佳实践,在你的应用程序中,你将有一个专门用于自定义接口的包。这意味着你只在这个包中创建接口,并且你只保留这个包用于接口——没有节点或其他东西,只有接口。这将使你在扩展应用程序时更加容易,并帮助你避免创建依赖混乱。

在命名这个接口包时,一个常见的做法是以你的应用程序或机器人的名称开头,并添加 _interfaces 后缀。所以如果你的机器人命名为 abc,你应该使用 abc_interfaces

在这个例子中我们没有机器人,所以我们只需将包命名为 my_robot_interfaces

使用 ament_cmake 构建类型创建一个新的包,并且没有依赖项。你甚至不需要提供构建类型,因为 ament_cmake 是默认使用的。导航到你的工作空间的 src 目录并创建这个包:

$ cd ~/ros2_ws/src/
$ ros2 pkg create my_robot_interfaces

到目前为止,你的工作空间应该包含三个包:my_py_pkgmy_cpp_pkgmy_robot_interfaces

我们需要设置这个新包并修改一些设置,以便它可以构建消息。进入包中,删除 srcinclude 目录,并创建一个新的 msg 文件夹:

$ cd my_robot_interfaces/
$ rm -r src/ include/
$ mkdir msg

现在,打开这个包的 package.xml 文件。在 <buildtool_depend>ament_cmake</buildtool_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 文件就完成了,你现在不需要对它做任何事情。打开 CMakeLists.txt 文件。在 find_package(ament_cmake REQUIRED) 之后,在 ament_package() 之前,添加以下几行(你也可以删除 if(BUILD_TESTING) 块):

find_package(rosidl_default_generators REQUIRED)
rosidl_generate_interfaces(${PROJECT_NAME}
  # we will add the name of our custom interfaces here
)
ament_export_dependencies(rosidl_default_runtime)

这些你添加的行并没有太多需要理解的。它们将找到一些依赖项(rosidl 包),并为你的包准备,以便它可以构建接口。

到目前为止,你的包已经准备好了,你可以添加新的接口。你只需要进行一次这个设置阶段。在这个阶段,添加一个新的接口非常快。

创建和构建一个新的主题接口

假设我们想要创建一个发布者来发送机器人的一些硬件状态,包括机器人版本、内部温度、一个标志来知道电机是否就绪,以及一个调试信息。

我们已经查看过现有的接口,但没有一个匹配。你该如何命名这个新的接口?以下是你必须遵循的规则:

  • 使用大驼峰命名法——例如,HardwareStatus

  • 不要在名称中使用MsgInterface,因为这会增加不必要的冗余

  • 使用 .msg 作为文件扩展名

按照这些规则,在msg文件夹中创建一个名为HardwareStatus.msg的新文件:

$ cd ~/ros2_ws/src/my_robot_interfaces/msg/
$ touch HardwareStatus.msg

在这个文件中,我们可以添加消息的定义。以下是你可以使用的内容:

为了简化,我们在这里只使用内置类型。在消息文件中写入以下内容:

int64 version
float64 temperature
bool are_motors_ready
string debug_message

对于每个字段,我们提供数据类型,然后是字段名。

现在,我们如何构建这个消息?我们如何获取一个 Python 或 C++类,我们可以将其包含并用于我们的代码中?

要构建消息,你只需在CMakelists.txt中添加一行,指定消息文件的相对路径:

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/HardwareStatus.msg"
)

对于在这个包中构建的每个新接口,你将在rosidl_generate_interfaces()函数内部添加一行。不要在行之间添加任何逗号

现在,保存所有文件并构建你的新包:

$ cd ~/ros2_ws/
$ colcon build --packages-select my_robot_interfaces
Starting >>> my_robot_interfaces
Finished <<< my_robot_interfaces [4.00s]
Summary: 1 package finished [4.28s]

构建系统将使用你编写的接口定义来生成 Python 和 C++的源代码:

图 5.10 – 接口构建系统

图 5.10 – 接口构建系统

一旦构建了包,请确保源环境。你应该能在终端中看到你的接口(别忘了使用自动完成来快速构建命令并确保你有正确的名称):

$ source ~/.bashrc
$ ros2 interface show my_robot_interfaces/msg/HardwareStatus
int64 version
float64 temperature
bool are_motors_ready
string debug_message

如果你看到这个,这意味着构建过程成功。如果你在终端中看不到接口,那么你需要回去检查你是否正确完成了所有步骤。

在你的代码中使用自定义消息

假设你想要在这个章节中创建的number_publisher节点中使用你的新接口,在my_py_pkg包内。

首先,打开my_py_pkg包中的package.xml文件,并添加对my_robot_interfaces的依赖项:

<depend>rclpy</depend>
<depend>example_interfaces</depend>
<depend>my_robot_interfaces</depend>

然后,对于 Python,执行以下操作:

  1. 通过在代码中添加以下导入行来导入消息:

    from my_robot_interfaces.msg import HardwareStatus
    
  2. 创建一个发布者并指定HardwareStatus接口。

  3. 在你的代码中创建一个消息,如下所示:

    msg = HardwareStatus()
    msg.temperature = 34.5
    

注意

如果你使用 VS Code,导入消息后可能无法识别。关闭 VS Code,然后在源环境中重新打开它。所以,请确保接口已正确构建,然后源环境,并打开 VS code。

如果你想在my_cpp_pkg包的 C++节点中使用此消息,请将my_robot_interfaces的依赖项添加到my_cpp_packagepackage.xml文件中。然后执行以下操作:

  1. 通过在代码中添加以下include行来导入消息:

    #include "my_robot_interfaces/msg/hardware_status.hpp"
    
  2. 创建一个发布者并指定接口为<my_robot_interfaces::msg::HardwareStatus>

  3. 在你的代码中创建一个消息,如下所示:

    auto msg = my_robot_interfaces::msg::HardwareStatus();
    msg.temperature = 34.5;
    

当使用 VS code 时,C++的包含文件将不会被识别。你需要向自动生成的c_cpp_properties.json文件(位于.vscode文件夹内)添加一行新内容。你可以使用 VS Code 的左侧资源管理器找到此文件。然后在includePath数组中添加以下行:

"includePath": [
        "/opt/ros/jazzy/include/**",
        "/home/<user>/ros2_ws/install/my_robot_interfaces/include/**",
        "/usr/include/**"
    ],

你现在可以创建并使用你自己的主题界面。正如你所看到的,首先检查是否有任何现有的界面符合你的需求。如果有,就别重新发明轮子。然而,如果没有任何东西完全匹配,请不要犹豫,创建你自己的界面。为此,你必须创建一个专门用于界面的新包。一旦你完成了这个包的设置过程,你就可以添加你想要的任意数量的界面。

在我们结束之前,我将给你一个额外的挑战,这样你就可以练习本章中涵盖的概念。

主题挑战 – 闭环控制

这里有一个挑战给你,这样你就可以继续练习创建节点、发布者和订阅者。我们将开始一个新的 ROS 2 项目,并在接下来的章节中随着我们发现更多概念来改进它。

我鼓励你阅读说明,并在检查解决方案之前花时间完成这个挑战。练习是有效学习的关键。

我不会提供所有步骤的完整解释,只是对重要点进行一些说明。你可以在 GitHub 上找到完整的解决方案代码,包括 Python 和 C++。

你的挑战是编写turtlesim节点的控制器。到目前为止,我们只是使用简单的数字来发布和订阅主题。有了这个,你可以像在实际机器人上工作一样进行练习。

挑战

目标很简单:我们希望让海龟在圆形路径上移动。除此之外,我们还希望修改海龟的速度,无论它在屏幕的左侧还是右侧。

要获取屏幕上海龟的X坐标,你可以订阅该海龟的pose主题。然后,找到屏幕中间很容易:左侧的最小X值是0,右侧的最大X值大约是11。我们将假设屏幕中间的X坐标是5.5

你可以通过向海龟的cmd_vel主题发布命令速度来发送速度命令。为了使海龟在圆形路径上移动,你只需要发布恒定的线性X和角速度Z值。如果海龟在左侧(X < 5.5),则两个速度都使用1.0;如果海龟在右侧,则两个速度都使用2.0

按照以下步骤开始:

  1. 创建一个新的包(让我们称它为turtle_controller)。你可以决定创建 Python 或 C++包。如果你两者都创建,请确保给每个包一个不同的名称。

  2. 在这个包中,创建一个名为 turtle_controller 的新节点。

  3. 在节点的构造函数中添加一个发布者(命令速度)和一个订阅者(姿态)。

  4. 这与之前有点不同:不是创建一个定时器并从定时器回调中发布,而是可以直接从订阅者回调中发布。turtlesim 节点持续在 pose 主题上发布。从订阅者回调中发布命令允许你创建某种闭环控制。你可以获取当前的 X 坐标,并根据海龟的位置发送不同的速度命令。

要测试你的代码,从你的代码中创建一个可执行文件。然后,在一个终端中运行 turtlesim,在另一个终端中运行你的节点。你应该看到海龟在画圈,速度根据海龟的位置不同而不同。

解决方案

你可以在 GitHub 上找到完整的代码(Python 和 C++)以及包的组织结构。

这里是 Python 节点最重要的步骤。代码从所有必需的导入行开始:

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
from turtlesim.msg import Pose

在这里,我们导入 Twist 来自 geometry_msgsPose 来自 turtlesim。你可以通过运行 turtlesim_node 并使用 ros2 topicros2 interface 命令行工具探索主题来找到这些接口。

然后,我们为我们的节点创建一个类,并包含一个构造函数:

class TurtleControllerNode(Node):
    def __init__(self):
        super().__init__("turtle_controller")
        self.cmd_vel_pub_ = self.create_publisher(Twist, "/turtle1/cmd_vel", 10)
        self.pose_sub_ = self.create_subscription(Pose, "/turtle1/pose", self.callback_pose, 10)

如你所见,我们只创建了一个发布者和一个订阅者。我们没有使用定时器,因为我们计划直接从订阅者回调中使用发布者:

def callback_pose(self, pose: Pose):
    cmd = Twist()
    if pose.x < 5.5:
        cmd.linear.x = 1.0
        cmd.angular.z = 1.0
    else:
        cmd.linear.x = 2.0
        cmd.angular.z = 2.0
    self.cmd_vel_pub_.publish(cmd)

这是我们订阅者回调。每当收到一个新的 Pose 消息时,我们创建一个新的命令(一个 Twist 消息)。然后,根据海龟当前的 X 坐标,我们给出不同的速度值。最后,我们发布新的速度命令。

这就是这个挑战的全部内容。理解如何开始可能有点挑战性,但最终你会发现要编写的代码并不多。我鼓励你在几天后再次回到这个挑战,尝试不查看解决方案。这样,你可以检查你是否正确理解了主题的概念。

摘要

在这一章中,你学习了 ROS 2 主题。

主题允许节点通过发布/订阅机制相互通信。主题是为单向数据流设计的,并且是匿名的。

你可以直接在你的节点中使用 rclpy(Python)和 rclcpp(C++)来编写主题发布者和订阅者。

要编写一个发布者,你必须执行以下操作:

  1. 首先,检查你必须发送的主题名称和接口。将接口导入到代码中,并在节点构造函数中创建一个发布者。

  2. 要发布消息,你必须创建一个消息,填写不同的字段,并使用你的发布者发布该消息。

你可以在代码的任何位置发布消息。一个常见的结构是在定时器中添加并从定时器回调中发布。如果合理,你也可以直接从订阅者回调中发布。

要向订阅者发送消息,你必须执行以下操作:

  1. 对于发布者而言,您需要知道要接收的名称和接口。导入接口并在节点构造函数中创建一个订阅者。

  2. 当创建订阅者时,您需要指定一个回调函数。正是在这个回调函数中,您可以接收和处理传入的消息。

如果您为新的主题创建发布者或订阅者,且没有接口符合您的需求,您可能需要创建一个自定义接口。在这种情况下,您必须执行以下操作:

  1. 为您的机器人或应用程序创建并配置一个专门用于接口的新包。

  2. 在包内添加您的主题接口并构建包。

  3. 现在,您可以在发布者/订阅者中使用这个自定义接口,就像使用任何其他接口一样。

要尝试一个发布者或订阅者,只需在节点所在的位置构建包,源环境,并运行节点。然后,您可以使用ros2命令行工具,以及rqt_graph,来内省您的应用程序并解决潜在问题。

在主题之后,下一个逻辑步骤是了解 ROS 2 服务。这正是我们将在下一章中介绍的内容。

第六章:服务 – 节点之间的客户端/服务器交互

节点可以使用三种通信类型之一相互通信。你在上一章中发现了主题。现在是时候转向第二常用的通信方式:ROS 2 服务。

正如我们在主题部分所做的那样,我们将首先通过现实生活中的类比来理解服务。我还会分享更多关于何时使用主题与服务的思考。之后,你将深入代码,在节点中使用自定义服务接口编写服务服务器和客户端。你还将探索从终端处理服务的额外工具。

本章我们将编写的所有代码都从上一章的最终代码开始。我们将改进数字应用来学习如何使用服务,然后处理带有额外挑战的海龟控制器应用。如果你想和我有相同的起点,你可以从 GitHub (github.com/PacktPublishing/ROS-2-from-Scratch)下载代码,在ch5文件夹中,并将其作为起点。最终代码可以在ch6文件夹中找到。

到本章结束时,你将理解服务的工作原理,并且能够创建自己的服务接口、服务服务器和服务客户端。

在开始使用 ROS 2 时,对主题和服务的信心是最重要的事情之一。有了这个,你将能够为你的项目编写自定义代码,并与大多数现有的 ROS 2 应用进行交互。

本章我们将涵盖以下主题:

  • ROS 2 服务是什么?

  • 创建自定义服务接口

  • 编写服务服务器

  • 编写服务客户端

  • 处理服务的额外工具

  • 服务挑战 – 客户端和服务器

ROS 2 服务是什么?

你在第三章服务部分发现了 ROS 2 服务的概念,在那里你运行了第一个服务服务器和客户端,以获得它们如何工作的直观感受。你也熟悉了用于从终端处理服务的ros2命令行工具。

从这里,我将从头开始再次解释服务是什么,使用现实生活中的类比。我们将逐步构建一个示例,然后总结最重要的要点。

服务器和客户端

首先,我将使用在线天气服务作为类比。

在我们发送我们的位置后,这个在线天气服务可以告诉我们当地的天气。为了获取你所在城市的天气预报,你需要与这个服务进行交互。你可以使用你的电脑通过服务提供的 URL 发送一个网络请求。

会发生什么?首先,你的电脑将向天气服务发送一个请求。请求中包含你的位置。服务将接收请求,处理它,如果位置有效,它将返回该位置的天气。然后,你的电脑接收一个包含天气信息的响应。这就是通信的结束。以下是这个过程的说明:

图 6.1 – 客户端/服务器交互

图 6.1 – 客户端/服务器交互

这基本上就是 ROS 2 服务的工作方式。在一侧,你有一个节点内的服务服务器,在另一侧,你有一个节点内的服务客户端

要开始通信,服务客户端需要向服务器发送一个请求。然后,服务器将处理该请求,执行任何适当的操作或计算,并将响应返回给客户端。

如你所见,服务,就像对于主题一样,有一个名称和一个接口。接口不仅仅是一条消息,它是一对消息:请求和响应。客户端和服务器必须使用相同的名称和接口才能成功相互通信。

在这个例子中,HTTP URL 是服务名称,而这对(位置,天气)是服务接口(请求,响应)。

一个服务的多个客户端

在现实生活中,许多人将尝试从这个在线服务(在不同时间或同一时间)获取天气。这不是问题:每个客户端都会通过 HTTP URL 向服务器发送带有位置的请求。服务器将单独处理每个请求,并将适当的天气信息返回给每个客户端。

现在非常重要的一点是:只能有一个服务器。一个 URL 只对应一个服务器,就像一个物理地址是唯一的。想象一下,如果你向某人发送包裹,有两个地方有相同的地址。邮递员怎么知道把包裹送到哪里?

这对于 ROS 2 服务也是一样的。你可以让多个客户端向同一服务发送请求。然而,对于一项服务,只能存在一个服务器。参见以下图示:

图 6.2 – 具有多个客户端的服务服务器

图 6.2 – 具有多个客户端的服务服务器

这里,你可以看到一些方框,每个方框代表一个节点。因此,我们有四个节点。三个节点包含一个服务客户端并与天气服务节点通信,该节点包含一个服务服务器

这里需要注意的是,客户端不知道确切要通信的节点。他们必须通过 URL(服务名称)进行通信。在这个例子中,客户端并不知道服务器的 IP 地址——他们只知道他们必须使用 URL 来连接到服务器。

此外,没有任何客户端知道其他客户端的存在。当你尝试从这个服务获取天气信息时,你不知道谁也在尝试访问该服务,甚至不知道有多少人在发送请求。

另一个与机器人相关的服务示例

让我们再举一个可能是 ROS 应用程序一部分的例子。

假设你有一个负责控制 LED 面板(三个 LED)的节点。这个节点可以包含一个服务服务器,允许其他节点请求打开或关闭 LED。

你还有一个监控电池的节点。在你的应用程序中,你想做的是当电池电量低时打开一个 LED,然后当电池电量再次变高时关闭它。

你可以使用 ROS 2 服务来实现这一点。LED 面板节点将包含一个名为 set_led 的服务服务器。要向该服务器发送请求,你必须提供 LED 号码和该 LED 的状态(开启或关闭)。然后,你将收到一个包含布尔值的响应,以查看请求是否被服务器成功处理。

因此,电池现在电量不足。下面将要发生的事情:

图 6.3 – 客户端请求打开 LED 号码 3

图 6.3 – 客户端请求打开 LED 号码 3

在这里,电池节点 将向 set_led 服务发送一个 请求请求 包含 LED 号码 3状态开启 的详细信息,以便它可以打开面板上的 LED 3

LED 面板节点 中的 服务 服务器接收 请求。服务器可能会决定验证 请求(例如,如果 LED 号码是 4,则这不是有效的)并处理它。在这里处理 请求 意味着打开第三个 LED。之后,服务器向 客户端 发送一个 响应,包含一个布尔标志。客户端 接收这个 响应,通信结束。

然后,当电池完全充电时,电池节点 发送另一个 请求,这次是关闭 LED 3

图 6.4 – 客户端请求关闭 LED 号码 3

图 6.4 – 客户端请求关闭 LED 号码 3

过程相同。客户端 发送一个 请求,这次为 LED 3状态关闭。位于 LED 面板节点 内部的 服务器 接收这个 请求 并关闭第三个 LED。然后,服务器客户端 发送一个 响应

总结

在主题之上,ROS 2 节点可以使用服务相互通信。

你应该在何时使用主题而不是服务?你应该使用主题来发布单向数据流,当你想有客户端/服务器类型的通信时使用服务。

例如,如果你想每秒连续发送 10 次速度命令给机器人,或者发送从传感器读取的数据,你将使用主题。如果你想让节点执行快速计算或按需执行某些操作(启用/禁用电机,启动/停止机器人),那么你应该使用服务。

给出明确的答案可能会有点困难。每个应用程序都是不同的。大多数时候,选择将是显而易见的,但有时你必须走一条路才能意识到那是错误的方向。随着你对 ROS 2 经验的增加,你将能够做出更好的设计决策。

这里有一些关于服务如何工作的重要点:

  • 服务由名称和接口定义。

  • 服务的名称遵循与主题相同的规则。它必须以字母开头,后面可以跟其他字母、数字、下划线、波浪线和斜杠。

  • 接口包含两个东西:一个请求和一个响应。客户端和服务器必须使用相同的接口才能相互通信。

  • 服务服务器只能存在一次,但可以有多个客户端。

  • 服务客户端不知道彼此,也不知道服务器节点。为了到达服务器,他们只知道他们必须使用服务名称并提供正确的接口。

  • 一个节点可以包含多个服务服务器和客户端,每个服务名称不同。

现在,你该如何编写服务客户端和服务器?

正如节点和主题一样,你将在rclpyrclcpp库中找到你需要的一切。使用这些库,你可以在节点内编写服务服务器和客户端。这正是我们现在要做的。

由于没有服务器就无法测试客户端,让我们先从服务器端开始。但在我们开始编写服务器之前,我们将需要使用什么接口来为服务?

创建自定义服务接口

第五章中,当我们使用number_publishernumber_counter节点创建 ROS 2 应用程序时,我们使用了现有的number主题接口。由于我们想要发布一个整数,因此example_interfaces/msg/Int64接口似乎正是我们所需要的。此时,你知道你必须避免在实际应用中使用example_interfaces包,但在第一次测试中,这并不是问题。

我们将继续在这个应用程序上工作并添加更多功能,以便我们可以练习使用服务。在这里,我们将重点关注number_counter节点。目前,在这个节点中,每次我们从number主题接收到消息时,我们将把这个数字加到计数器上并打印计数器。

我们想要做的是允许number_counter节点在我们请求时将计数器重置为指定的数字。为此,我们将在节点内添加一个服务服务器。然后,任何其他节点都可以发送一个请求,指定计数器的重置值。例如,假设计数器当前为 76,你发送一个请求将其重置为 20。如果请求被服务服务器接受,计数器现在将变为 20 并从该值开始递增。

太好了——我们知道我们必须做什么。现在,我们应该使用哪个接口?我们能找到一个现有的接口来满足我们的需求,还是我们必须创建一个自定义的接口?根据本节的标题,你大概已经猜到了答案。不过,让我们看看如果我们查看现有的接口,我们能找到什么。

为我们的服务寻找现有的接口

当涉及到服务接口时,我们需要考虑两件事:请求和响应。

在我们的应用中,从客户端发送到服务器的请求应该包含一个整数。这是计数器的重置值。

对于从服务器发送到客户端的响应,我们可以决定使用一个布尔标志来指定我们是否能够执行请求,如果出了问题,还可以发送一条消息来解释发生了什么。

问题在于,我们会找到一个与我们的需求相匹配的现有接口吗?不幸的是,这次似乎没有匹配的接口。我们可以检查example_interfaces包:

$ ros2 interface list | grep example_interfaces/srv
example_interfaces/srv/AddTwoInts
example_interfaces/srv/SetBool
example_interfaces/srv/Trigger

我们甚至可以检查std_srvs包:

$ ros2 interface list | grep std_srvs/srv
std_srvs/srv/Empty
std_srvs/srv/SetBool
std_srvs/srv/Trigger

注意

如你所见,服务接口被放置在包内的srv文件夹中。对于主题,我们有一个msg文件夹。这是一种很容易区分两种接口类型的好方法。

如果你更仔细地查看这些接口,特别是SetBoolTrigger,你会发现没有办法在请求中发送一个整数。这里有一个我们尝试使用SetBool的例子:

$ ros2 interface show example_interfaces/srv/SetBool
# some comments
bool data # e.g. for hardware enabling / disabling
---
bool success   # indicate successful run of triggered service
string message # informational, e.g. for error messages

当查看接口定义时,你可以看到请求和响应由三个短横线(---)分隔。在响应中,我们可以找到一个布尔值和一个字符串,这是我们想要的。然而,请求只包含一个布尔值,没有整数。

你可以查看 GitHub 上常见的接口仓库中的其他接口(github.com/ros2/common_interfaces),但你不会找到我们正在寻找的精确匹配。

因此,在为服务编写代码之前,我们将创建我们自己的服务接口。对于number主题,我们足够幸运,找到了一个可以直接在代码中使用的接口(尽管对于实际应用,最佳实践是尽量避免使用example_interfacesstd_srvs)。在这里,我们首先需要创建接口。

创建一个新的服务接口

要创建服务接口,就像为主题接口一样,你需要创建并配置一个专门用于接口的包。

好消息:我们在第五章为主题创建自定义接口部分已经做了这件事。由于我们正在处理同一个应用,我们将所有主题和服务接口放在同一个包中:my_robot_interfaces(如果你还没有这个包,请回到上一章并设置它)。

我们可以直接在那个包内部创建一个新的服务接口;没有其他事情要做。所以,这个过程将会非常快。

首先,导航到my_robot_interfaces包内部(你已经有了一个msg文件夹)并创建一个新的srv文件夹:

$ cd ~/ros2_ws/src/my_robot_interfaces/
$ mkdir srv

在这个新文件夹中,你需要放置所有特定于你的应用程序(或机器人)的服务接口。

现在,为服务创建一个新文件。以下是关于文件名的规则:

  • 使用 UpperCamelCase(PascalCase)——例如,ActivateMotor

  • 不要在名称中写SrvInterface,因为这会添加不必要的冗余。

  • 使用.srv作为文件扩展名。

  • 作为最佳实践,在接口名称中使用一个动词——例如,TriggerSomethingActivateMotorComputeDistance。服务是关于执行一个动作或计算,所以通过使用动词,你可以非常清楚地知道服务正在做什么。

由于我们想要重置计数器,所以我们可以简单地称这个接口为ResetCounter

$ cd ~/ros2_ws/src/my_robot_interfaces/srv/
$ touch ResetCounter.srv

打开这个文件并编写服务接口的定义。在这里要做的一件非常重要的事情是添加三个短横线(---),并将请求定义放在顶部,然后响应定义放在短横线下方。

对于请求和响应,你可以使用以下内容:

  • 内置类型(boolbyteint64等等)。

  • 现有的消息接口。例如,服务的请求可以包含geometry_msgs/Twist

注意

你不能在另一个服务定义中包含服务定义。你只能在服务的请求或响应中包含一个消息(主题定义)。请求和响应可以被视为两个独立的消息。

让我们编写我们的服务接口。由于它并不复杂,我们可以使用简单的内置类型:

int64 reset_value
---
bool success
string message

这样,客户端将发送一个包含一个整数值的请求,响应将包含一个布尔标志以及一个字符串。定义内部的所有字段都必须遵循 snake_case 约定(单词之间使用下划线,所有字母小写,没有空格)。

注意

确保你所有的服务定义中都有三个短横线,即使请求或响应为空。

现在我们已经编写了接口,我们需要构建它,以便我们可以在代码中使用它。

返回到my_robot_interfaces包的CMakeLists.txt。由于包已经配置好了,我们只需要添加一行。在rosidl_generate_interfaces()函数中添加接口的相对路径,并在新行中添加:

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/HardwareStatus.msg"
  "srv/ResetCounter.srv"
)

然后,保存所有文件并构建my_robot_interfaces包:

$ colcon build --packages-select my_robot_interfaces

一旦构建,源环境。你应该能够找到你的新接口:

$ ros2 interface show my_robot_interfaces/srv/ResetCounter
int64 reset_value
---
bool success
string message

如果你看到这个,你就知道服务接口已经成功构建,你可以在你的代码中使用它。所以,让我们来做这件事,写一个服务服务器。

编写服务服务器

现在,你将编写你的第一个服务服务器。如前所述,我们将继续使用上一章开始的数量应用程序。我们在这里想要做的是允许 number_counter 在我们要求它这样做时将计数器重置到指定的数字。这是一个使用服务的完美例子。

创建新的服务时首先要考虑的是你需要什么服务接口。我们已经做到了这一点,所以现在我们可以专注于代码。

要编写服务服务器,你需要导入接口,然后在节点的构造函数中创建一个新的服务。你还需要添加一个回调函数,以便能够处理请求,执行所需的操作或计算,并向客户端返回响应。

像往常一样,让我们先用 Python 进行详细解释,然后我们将看到如何用 C++ 完成同样的操作。

编写 Python 服务服务器

要编写 Python 服务服务器,我们首先需要一个 Python 节点。由于我们正在向现有的节点(number_counter)添加功能,所以这里不会创建一个新的节点。

注意

你可以在一个节点内部拥有任意数量的发布者、订阅者和服务。只要保持整洁,这不会成为问题。

让我们开始吧。像往常一样,你可以在本书的 GitHub 仓库中找到完整的代码。在这里,我不会展示节点的完整代码,只展示添加服务所需的新行。

导入服务接口

创建服务的第一个重要部分是找到一个现有的接口或创建一个新的接口。这正是我们刚才做的,所以让我们使用 my_robot_interfaces 包中的 ResetCounter 接口。

首先,我们需要将依赖项添加到我们编写带有服务的节点的包内部。打开 my_py_pkg 中的 package.xml 文件并添加新的依赖项:

<depend>rclpy</depend>
<depend>example_interfaces</depend>
<depend>my_robot_interfaces</depend>

这将确保在用 colcon 构建包含 my_py_pkg 包时安装接口包。现在,将依赖项导入到你的代码中(number_counter.py):

#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from example_interfaces.msg import Int64
from my_robot_interfaces.srv import ResetCounter

要导入服务,我们必须指定包名(my_robot_interfaces),然后是服务文件夹的名称(srv),最后是接口的类(ResetCounter)。

注意

我已经提到过这一点,但如果你使用 VS Code 并且自动完成不起作用,或者服务没有被识别(导入错误),请按照以下过程操作。

关闭 VS code。然后,打开一个新的终端,确保环境已正确设置,并找到接口(ros2 interface show <interface_name>)。之后,导航到 ROS 2 工作空间的 src 目录,并使用以下命令打开 VS Code:

$ code .

将服务服务器添加到节点

现在你已经正确地导入了服务接口,你可以创建服务服务器。

就像为发布者和订阅者做的那样,你将在节点的构造函数中添加你的服务服务器。

这是NumberCounterNode类的构造函数,其中包含之前创建的订阅者和新的服务服务器:

def __init__(self):
    super().__init__("number_counter")
    self.counter_ = 0
    self.number_subscriber_ = self.create_subscription(Int64, "number", self.callback_number, 10)
    self.reset_counter_service_ = self.create_service(ResetCounter, "reset_counter", self.callback_reset_counter)
    self.get_logger().info("Number Counter has been started.")

我们在添加用户数量和结束日志之前同时添加服务服务器。

要创建服务服务器,我们使用Node类的create_service()方法。再次说明,通过从该类继承,我们可以轻松地访问所有 ROS 2 功能。在这个方法中,你必须提供三个参数:

  • 我们导入的ResetCounter类。

  • reset_counter

  • 服务回调:正如其名称所暗示的,服务服务器是一个服务器。这意味着它不会自己执行任何操作。你需要有一个客户端发送请求,以便服务器执行某些操作。因此,当节点正在旋转时,服务器将处于“等待模式”。在接收到请求后,服务回调将被触发,并将请求传递给这个回调。

现在,我们需要实现这个回调。首先,让我们写一个最小化的代码示例:

def callback_reset_counter(self, request: ResetCounter.Request, response: ResetCounter.Response):
    self.counter_ = request.reset_value
    self.get_logger().info("Reset counter to " + str(self.counter_))
    response.success = True
    response.message = "Success"
    return response

在服务回调中,我们接收两个东西:一个是请求的对象,另一个是响应的对象。请求对象包含客户端发送的所有数据。响应对象为空,我们需要填充它以及返回它。

为了命名回调,我通常会在服务名称前写上callback_。这样做使得在代码中更容易识别,并且可以防止未来犯错误,因为你想要确保不要直接调用这个方法。它应该只在节点正在旋转并且客户端从另一个节点发送请求时调用。

注意

在方法参数中,我还指定了两个参数的类型。这样,我们使代码更加健壮,并且可以使用 IDE(如 VS Code)的自动完成功能。

当你为话题创建接口时,你只会得到一个该接口的类(例如,Int64)。正如你所看到的,在服务中,我们得到两个类:一个用于请求(Interface.Request)和一个用于响应(Interface.Response)。

在这个回调中,我们从请求中获取reset_value并根据需要修改counter_变量。然后,我们从响应中填充成功和消息字段并返回响应。

这是一个非常简单的服务服务器代码片段。在现实生活中,你可能想在使用请求中的值之前检查请求是否有效。例如,如果你有一个将修改移动机器人的最大速度的服务,你可能想确保你收到的值不是太高,以防止机器人失控并损坏自己或环境。

让我们改进回调,以便在修改counter_变量之前验证reset_value

验证请求

假设我们想要添加这两个验证规则:重置值必须是一个正数,并且不能高于当前计数器的值。

修改callback_reset_counter方法中的代码,如下所示:

def callback_reset_counter(self, request: ResetCounter.Request, response: ResetCounter.Response):
    if request.reset_value < 0:
        response.success = False
        response.message = "Cannot reset counter to a negative value"
    elif request.reset_value > self.counter_:
        response.success = False
        response.message = "Reset value must be lower than current counter value"
    else:
        self.counter_ = request.reset_value
        self.get_logger().info("Reset counter to " + str(self.counter_))
        response.success = True
        response.message = "Success"
    return response

首先,我们检查值是否为负。如果是,我们不对counter_变量做任何操作。我们将布尔标志设置为False,并提供适当的错误信息。

然后,我们检查值是否大于当前的counter_值。如果是这样,我们就像之前一样做,但错误信息不同。

最后,如果这些条件都不成立(这意味着我们已经验证了请求),那么我们处理请求并修改counter_变量。

这里是服务服务器回调步骤的回顾:

  1. (可选但推荐)验证请求,或者验证回调处理所需的外部条件是否满足。例如,如果服务是激活电机,但与电机的通信尚未开始,那么你无法激活电机。

  2. 如果需要,使用请求中的数据处理动作或计算。

  3. 填写响应的适当字段。不是必须填写所有字段。如果你省略了一些,将使用默认值(数字为0,字符串为"")。

  4. 返回响应。这是一个非常重要的一步,很多人在开始时都会忘记。如果你不返回响应,你将在运行时遇到错误。

你现在必须做的就是构建包含节点的包,source,并运行节点。

当你运行number_counter节点时,你会看到以下内容:

$ ros2 run my_py_pkg number_counter
[INFO] [1712647809.789229368] [number_counter]: Number Counter has been started.

服务服务器已经在节点内部启动,但当然,除非你从客户端发送请求来尝试服务器,否则什么都不会发生。

我们将在下一分钟做这件事,但在那之前,让我们学习如何编写 C++中的服务服务器。如果你现在不想用 C++学习 ROS 2,你可以跳过这部分,直接进入本章的下一节。

编写 C++服务服务器

让我们在我们的 C++ number_counter节点内部添加一个服务服务器,使用与 Python 创建的那个相同的名称和接口。过程是相同的:导入接口,创建服务服务器,并添加回调函数。

如本书之前所述,确保在 GitHub 代码旁边打开的情况下,遵循所有 C++解释。

导入服务接口

首先,由于我们将依赖于my_robot_interfaces,请打开my****_cpp_pkg包的package.xml文件,并添加以下一行:

<depend>rclcpp</depend>
<depend>example_interfaces</depend>
<depend>my_robot_interfaces</depend>

然后,打开number_counter.cpp文件并包含ResetCounter接口:

#include "rclcpp/rclcpp.hpp"
#include "example_interfaces/msg/int64.hpp"
#include "my_robot_interfaces/srv/reset_counter.hpp"

要在 C++中导入服务接口,你必须使用#****include "<package_name>/srv/<service_name>.hpp"

注意

作为提醒,为了使 VS Code 能够识别这个include,请确保你将以下内容添加到.vscode文件夹中,该文件夹是在你打开 VS Code 时生成的c_cpp_properties.json文件中:"/home/<user>/ros2_ws/install/my_robot_interfaces/include/**"

然后,我添加了一条额外的行,使用了using关键字,这样我们就可以在代码中直接写ResetCounter,而不是my_robot_interfaces::srv::ResetCounter

using ResetCounter = my_robot_interfaces::srv::ResetCounter;

这将帮助我们使代码更易于阅读。使用 C++,你很快就会得到非常长的类型,几乎需要多行来编写。由于我们经常需要使用服务接口,添加这条using行是一个最佳实践,以保持事情简单。

我在之前处理主题时没有用example_interfaces::msg::Int64做这件事,但如果你想,你也可以写using Int64 = example_interfaces::msg::Int64;然后减少订阅者的代码。

将服务服务器添加到节点

现在我们已经包含了接口,让我们创建服务服务器。我们将将其存储为类中的私有属性:

rclcpp::Service<ResetCounter>::SharedPtr reset_counter_service_;

如你所见,我们使用了rclcpp::Service类,然后,一如既往地,我们使用::SharedPtr将其转换为共享指针。

现在,我们可以在构造函数中初始化服务:

reset_counter_service_ = this->create_service<ResetCounter>("reset_counter",  std::bind(&NumberCounterNode::callbackResetCounter, this, _1, _2));

要创建服务,我们必须使用rclcpp::Node类的create_service()方法。对于 Python,我们需要提供服务接口、服务名称以及处理传入请求的回调。为了使_1_2工作,别忘了事先添加using namespace std::placeholders;

这里是回调方法,包括验证请求的代码:

void callbackResetCounter(const ResetCounter::Request::SharedPtr request, const ResetCounter::Response::SharedPtr response)
{
    if (request->reset_value < 0) {
        response->success = false;
        response->message = "Cannot reset counter to a negative value";
    }
    else if (request->reset_value > counter_) {
        response->success = false;
        response->message = "Reset value must be lower than current counter value";
    }
    else {
        counter_ = request->reset_value;
        RCLCPP_INFO(this->get_logger(), "Reset counter to %d", counter_);
        response->success = true;
        response->message = "Success";
    }
}

在回调中,我们接收两个参数——请求和响应。两者都是const共享指针。

在这个回调中我们做的事情与 Python 相同。这里最大的不同之处在于我们不需要返回任何东西(在 Python 中,我们必须返回响应),因为回调的返回类型是void

现在,我们可以构建包以编译和安装节点。然而,在我们运行colcon build之前,我们必须修改my_cpp_pkg包的CMakeLists.txt文件。由于我们对my_robot_interfaces有新的依赖,我们需要将number_counter可执行文件与该依赖项链接。

首先,在所有find_package()行下面添加一行:

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(example_interfaces REQUIRED)
find_package(my_robot_interfaces REQUIRED)

然后,将my_robot_interfaces添加到ament_target_dependencies()函数中,用于number_counter可执行文件:

add_executable(number_counter src/number_counter.cpp)
ament_target_dependencies(number_counter rclcpp example_interfaces my_robot_interfaces)

对于你在本可执行文件中使用的每个新依赖项,你必须在构建之前将其链接。

如果你忘记了这一点,那么当你运行colcon build时,你会得到这种错误:

fatal error: my_robot_interfaces/srv/reset_counter.hpp: No such file or directory
Failed   <<< my_cpp_pkg [1.49s, exited with code 2]

现在你可以构建 C++包、源代码并运行number_counter节点。

$ ros2 run my_cpp_pkg number_counter
[INFO] [1712726520.316615636] [number_counter]: Number Counter has been started.

我们现在处于与完成 Python 服务服务器相同的点。下一步是尝试服务服务器。为了做到这一点,我们需要一个服务客户端。

编写服务客户端

为了使服务通信工作,你需要一个服务服务器和一个服务客户端。作为提醒,你只能有一个服务服务器,但可以有多个客户端。

到目前为止,我们已经在number_counter节点内部完成了我们的服务服务器。现在,让我们在另一个节点内部创建一个服务客户端,以便您可以尝试该服务。

您将在哪里编写客户端的代码?在实际应用中,您将在需要调用服务的节点中创建服务客户端。就本章开头提到的电池和 LED 示例而言,LED 面板节点包含服务服务器。负责监控电池状态的电池节点包含一个服务客户端,可以向服务器发送一些请求。

然后,何时发送请求取决于应用。在先前的例子中,我们决定当电池充满或放空时,我们使用节点内的服务客户端向服务器发送请求,以便我们可以打开/关闭 LED。

为了现在使事情简单,我们将创建一个名为reset_counter_client的新节点。此节点只会做一件事:向服务服务器发送请求并获取响应。有了这个,我们就可以只专注于编写服务客户端。像往常一样,我们首先从 Python 开始,然后查看 C++代码。

编写 Python 服务客户端

my_py_pkg包内部创建一个名为reset_counter_client.py的新文件。使此文件可执行。该文件应放置在与您之前创建的所有其他 Python 文件相同的目录中。

打开文件,首先导入接口:

from my_robot_interfaces.srv import ResetCounter

在节点的构造函数中创建一个服务客户端:

def __init__(self):
    super().__init__("reset_counter_client")
    self.client_ = self.create_client(ResetCounter, "reset_counter")

要创建服务客户端,我们使用Node类的create_client()方法。我们需要提供服务接口和服务名称。确保您使用与服务器中定义的相同名称和接口。

然后,为了调用服务,我们创建一个新的方法:

def call_reset_counter(self, value):
    while not self.client_.wait_for_service(1.0):
        self.get_logger().warn("Waiting for service...")
    request = ResetCounter.Request()
    request.reset_value = value
    future = self.client_.call_async(request)
    future.add_done_callback(
        self.callback_reset_counter_response)

制作服务调用的步骤如下:

  1. 确保服务正在运行,使用wait_for_service()。此函数将在找到服务后立即返回True,或者在提供的超时后返回False,这里为1.0秒。

  2. 从服务接口创建一个请求对象。

  3. 填写请求字段。

  4. 使用call_async()发送请求。这将给您一个 Python Future对象。

  5. 为节点收到服务器响应时注册回调。

要处理服务响应,添加一个回调方法:

def callback_reset_counter_response(self, future):
    response = future.result()
    self.get_logger().info("Success flag: " + str(response.success))
    self.get_logger().info("Message: " + str(response.message))

在回调中,我们使用future.result()获取响应,并且可以访问响应的每个字段。在这个例子中,我们简单地使用日志打印响应。

那么,会发生什么?在您使用call_async()发送请求后,服务器将接收并处理请求。任务完成后,服务器将向客户端所在的节点返回响应。当客户端节点收到响应时,它将在您编写的回调中处理它。

注意

您可能想知道,为什么我们需要回调?为什么我们不能只在发送请求的同一种方法中等待响应?这是因为如果您阻塞此方法(换句话说,这个线程),那么节点将无法旋转。如果旋转被阻塞,那么您为此节点收到的任何响应都不会被处理,这就是所谓的死锁。

剩下的唯一事情就是调用call_reset_counter()方法。如果我们不调用它,什么都不会发生。在实际应用中,您会在需要时调用此方法(它可能来自计时器回调、订阅者回调等)。在这里,为了进行测试,我们只是在创建节点后、旋转前在main()函数中调用该方法:

node = ResetCounterClientNode()
node.call_reset_counter(20)
rclpy.spin(node)

服务客户端将发送一个请求并为响应注册一个回调。之后,call_reset_counter()方法退出,节点开始旋转。

代码部分到此结束。您可以将此结构用于任何其他节点中的客户端(一个用于发送请求的方法和一个用于处理响应的回调)。

现在,让我们测试客户端/服务器通信。

同时运行客户端和服务器节点

setup.py文件中创建一个名为reset_counter_client的可执行文件,例如。

然后,构建工作空间并打开三个终端。在终端 1 和 2 中启动number_publishernumber_counter。后者将启动reset_counter服务服务器。

在终端 3 中启动reset_counter_client节点。由于我们希望将计数器重置为 20,如果发送请求时number_counter节点内的计数器小于 20,您将得到以下响应:

$ ros2 run my_py_pkg reset_counter_client
[INFO] [1713082991.940407360] [reset_counter_client]: Success flag: False
[INFO] [1713082991.940899261] [reset_counter_client]: Message: Reset value must be lower than current counter value

如果计数器是 20 或更多,您将得到以下响应:

$ ros2 run my_py_pkg reset_counter_client
[INFO] [1713082968.101789868] [reset_counter_client]: Success flag: True
[INFO] [1713082968.102277613] [reset_counter_client]: Message: Success

此外,在启动节点后,客户端有时需要一点时间来找到服务。在这种情况下,您可能还会看到以下日志:

[WARN] [1713082991.437932627] [reset_counter_client]: Waiting for service...

在服务器端(number_counter节点),如果计数器正在重置,您将看到以下日志:

[INFO] [1713083108.125753986] [number_counter]: Reset counter to 20

因此,我们已经测试了两种情况:当计数器小于请求的重置值时,以及当计数器大于请求的重置值时。如果您愿意,也可以测试第三种情况:当请求的重置值小于 0。

现在我们已经完成了两个节点之间的客户端/服务器通信,让我们转向 C++。

编写 C++服务客户端

C++代码遵循与 Python 代码相同的逻辑。

首先,我们包含接口:

#include "my_robot_interfaces/srv/reset_counter.hpp"

然后,我们添加一些using行以减少后续代码:

using ResetCounter = my_robot_interfaces::srv::ResetCounter;
using namespace std::chrono_literals;
using namespace std::placeholders;

接下来,我们将服务客户端声明为类中的私有属性:

rclcpp::Client<ResetCounter>::SharedPtr client_;

然后,我们在构造函数中初始化客户端:

ResetCounterClientNode() : Node("reset_counter_client")
{
    client_ = this->create_client<ResetCounter>("reset_counter");
}

然后,就像 Python 一样,我们添加一个方法来调用服务:

void callResetCounter(int value)
{
    while (!client_->wait_for_service(1s)) {
        RCLCPP_WARN(this->get_logger(), "Waiting for the server...");
    }
    auto request = std::make_shared<ResetCounter::Request>();
    request->reset_value = value;
    client_->async_send_request(request, std::bind(&ResetCounterClientNode::callbackResetCounterResponse, this, _1));
}

在这种方法中,我们等待服务(别忘了在 client->wait_for_service(1s) 前面的感叹号),创建一个请求,填写请求,然后使用 async_send_request() 发送它。我们将回调方法作为参数传递,这将注册当节点正在旋转时的回调。

这里是响应的回调方法:

void callbackResetCounterResponse(
    rclcpp::Client<ResetCounter>::SharedFuture future)
{
    auto response = future.get();
    RCLCPP_INFO(this->get_logger(), "Success flag: %d, Message: %s", (int)response->success, response->message.c_str());
}

最后,为了能够发送请求,我们在创建节点后立即调用 callResetCounter() 方法,在旋转之前:

auto node = std::make_shared<ResetCounterClientNode>();
node->callResetCounter(20);
rclcpp::spin(node);

现在,在 CMakeLists.txt 中创建一个新的可执行文件。构建包,打开几个终端,并启动 number_publishernumber_counter 节点。然后,启动 reset_counter_client 节点以尝试服务通信。

现在你已经编写了服务服务器和客户端的代码,让我们来看看你可以使用 ROS 2 工具做什么。对于具有简单接口的服务,你将能够直接从终端测试它们,甚至在编写客户端代码之前。

处理服务的附加工具

我们在这本书中已经大量使用了 ros2 命令行工具。使用这个工具,每个核心 ROS 2 概念在终端中都会获得额外的功能。这对于服务也不例外。

我们现在将更深入地探索 ros2 service,这样我们就可以从终端检查服务和发送请求。我们还将学习如何在运行时更改服务名称(ros2 run)。

要查看 ROS 2 服务的所有命令,请输入 ros2 service -h

列出和检查服务

首先,rqt_graph 不支持服务(目前还不支持——计划在未来的 ROS 2 发行版中可能添加此功能),所以我们在这里不会使用它。我们只会使用 ros2 命令行工具。

停止所有节点并启动 number_counter 节点。然后,要列出所有服务,运行以下命令:

$ ros2 service list
/number_counter/describe_parameters
/number_counter/get_parameter_types
/number_counter/get_parameters
/number_publisher/get_type_description
/number_counter/list_parameters
/number_counter/set_parameters
/number_counter/set_parameters_atomically
/reset_counter

对于你启动的每个节点,你将得到七个额外的服务,大多数与参数相关。你可以忽略那些。如果你查看列表,除了那七个服务之外,我们可以检索我们的 /****reset_counter 服务。

注意

注意,服务名称前面有一个额外的斜杠。服务名称遵循与节点和主题相同的规则。如果你没有提供任何命名空间(例如,/abc/reset_counter),你处于“全局”命名空间,并且会在前面添加一个斜杠。

一旦你得到了想要的服务名称,你可以使用 ros2 service type <service_name> 来获取服务接口:

$ ros2 service type /reset_counter
my_robot_interfaces/srv/ResetCounter

从这里,你可以看到接口内部的详细信息:

$ ros2 interface show my_robot_interfaces/srv/ResetCounter
int64 reset_value
---
bool success
string message

当你需要为现有的服务器创建服务客户端时,这个过程非常有用。你甚至不需要阅读服务器代码——你可以从终端获取所有你需要的信息。

发送服务请求

要测试服务服务器,你通常需要编写服务客户端。

好消息:你不需要编写客户端,可以直接从终端调用服务。这可以节省你一些开发时间。

首先,你必须知道服务名称和接口。然后,使用 ros2 call <service_name> <interface_name> "<request_in_json>" 命令。让我们用我们的 reset_counter 服务试一试:

$ ros2 service call /reset_counter \ my_robot_interfaces/srv/ResetCounter {reset_value: 7}"
waiting for service to become available...
requester: making request: my_robot_interfaces.srv.ResetCounter_Request(reset_value=7)
response:
my_robot_interfaces.srv.ResetCounter_Response(success=True, message='Success')

你可以看到请求被发送,然后是响应。然后,命令退出。这很实用,在这种情况下,我们节省了很多时间。

我们也可以轻松地测试不同的案例。例如,让我们发送一个负值作为重置数字:

$ ros2 service call /reset_counter \my_robot_interfaces/srv/ResetCounter "{reset_value: -7}"
waiting for service to become available...
requester: making request: my_robot_interfaces.srv.ResetCounter_Request(reset_value=-7)
response:
my_robot_interfaces.srv.ResetCounter_Response(success=False, message='Cannot reset counter to a negative value')

在这个例子中,由于请求非常简单(只有一个整数),所以很容易。对于包含大量嵌套字段和数组的更复杂的服务请求,在终端中编写完整的请求可能会变得相当繁琐,你将花费大量时间来确保它正确无误。

因此,对于简单的接口,使用 ros2 service call 首先尝试服务。对于更复杂的接口,你首先需要编写客户端代码。这并不是真正的问题:你可以使用我们用于 ResetCounterClientNode 的代码作为任何其他客户端的模板。最终,这两种方法都允许你快速测试服务服务器。

在运行时更改服务名称

当你使用 ros2 run 启动一个节点时,你可以更改节点名称以及节点内的任何主题名称。你也可以对服务做同样的操作。

提醒一下,对于你传递给 ros2 run 的任何额外参数,添加 --ros-args,但只需添加一次。

然后,为了重命名服务,添加 -r 后跟 <service_name>:=<new_service_name>

例如,当启动 number_counter 节点时,让我们将 reset_counter 服务重命名为 reset_counter1

$ ros2 run my_py_pkg number_counter --ros-args -r \ reset_counter:=reset_counter1

现在,让我们用 ros2 service list 来验证这一点:

$ ros2 service list
# Some other services
/reset_counter1

现在服务名称是 /reset_counter1。如果我们启动一个带有服务客户端的节点,我们也需要修改名称;否则,节点之间将无法相互通信:

$ ros2 run my_py_pkg reset_counter_client --ros-args -r \ reset_counter:=reset_counter1

做这件事非常有用,尤其是当你想运行几个节点(自己写的或别人的)时,这些节点使用略微不同的服务名称,或者位于不同的命名空间中。

现在,你能够编写服务服务器/客户端,并从终端对其进行内省/测试。在进入下一章之前,让我们通过一个额外的挑战来进一步练习。

服务挑战 – 客户端和服务器

在这个新的挑战中,你将练习本章涵盖的所有内容:自定义服务接口、服务服务器和服务客户端。

我们将以我们在上一章挑战中编写的 turtle_controller 节点作为起点。我们在这里不会创建一个新的节点;相反,我们将改进现有的代码。你可以从你写的代码开始,或者从我在本书 GitHub 仓库的 ch5 文件夹中提供的代码开始。

和往常一样,我会解释你需要做什么来完成挑战,然后详细说明 Python 解决方案最重要的要点。你可以在 GitHub 上找到 Python 和 C++的完整解决方案代码。

挑战

这个挑战分为两部分。我建议你按顺序进行。

挑战 1 – 服务客户端

到目前为止,我们的turtle_controller节点正在订阅/turtle1/pose主题。在订阅者回调中,我们向/turtle1/cmd_vel主题发送速度命令。

结果是海龟在屏幕上画圆,速度取决于它是在屏幕的右侧还是左侧。

我们现在想要根据海龟所在的位置改变笔的颜色。如果海龟在屏幕的右侧,我们希望笔的颜色是红色。在左侧,颜色应该是绿色。

为了做到这一点,我们需要在节点中添加一个服务客户端,以便我们可以调用服务来在turtlesim节点中更改笔的颜色(我不会给你服务名称——这是挑战的一部分)。

这里是一些你可以采取的起步步骤:

  1. 启动turtlesim节点,并使用ros2 service命令行来查找要调用的服务以及要导入的接口(可选:在那个阶段,你也可以直接从终端使用ros2 service call测试该服务)。

  2. turtle_controller节点中,添加该服务的服务客户端。

  3. 创建一个调用服务的方法。

  4. 从现有的订阅者回调中调用此方法。在你发布新的速度命令后,检查海龟是在屏幕的右侧还是左侧。当海龟切换到不同的侧面时,调用服务并更新颜色。

挑战 2 – 自定义接口和服务服务器

完成第一个挑战后,尝试这个挑战。这次,你将在服务的服务器端进行练习。

在这里,我们希望允许turtle_controller节点根据外部请求激活或停用海龟(意味着启动或停止海龟),为此,我们将创建一个服务服务器。

这里是一些你可以采取的起步步骤:

  1. 定义一个服务名称和该服务的接口。

  2. 如果没有现有的接口符合你的需求,你需要创建并构建一个新的接口(提示:这正是我们在这里要做的)。

  3. turtle_controller节点中,添加一个服务服务器和一个回调,在其中激活或停用海龟。提示:你可以在类中使用一个简单的布尔属性来存储海龟的激活状态。

  4. 如果海龟被激活,那么在订阅者回调中,你可以继续发送额外的速度命令。如果没有激活,则不发送任何命令。

根据这些说明,你应该能够开始操作。花时间做这个练习可能是你为了更快学习 ROS 所能做的最佳投资。

解决方案

让我们从第一个挑战开始。

挑战 1

对于这个挑战,我们处于客户端,这意味着我们需要找出需要调用哪个服务。我将快速回顾一下查找服务名称和接口的步骤。

启动turtlesim节点并列出所有服务。你应该看到/turtle1/set_pen服务通过ros2 service list

现在,获取此服务的类型并查看接口中的内容:

$ ros2 service type /turtle1/set_pen
turtlesim/srv/SetPen
$ ros2 interface show turtlesim/srv/SetPen
uint8 r
uint8 g
uint8 b
uint8 width
uint8 off
---

在服务请求中,我们可以发送一个(rgb)值(红色、绿色、蓝色)。还有widthoff属性,但我们不会使用它们。

在此阶段,在你甚至开始编写客户端代码之前,你可以从终端尝试使用服务:

$ ros2 service call /turtle1/set_pen turtlesim/srv/SetPen \ "{r: 255, g: 0, b: 0}"

然后,执行ros2 run turtlesim turtle_teleop_key并移动乌龟。你会看到笔现在使用红色。

回到代码,在turtle_controller.py文件中,导入接口:

from turtlesim.srv import SetPen

由于我们已经在turtle_controller包的package.xml文件中添加了对turtlesim的依赖(在上一章中),因此不需要再次添加。

然后,在构造函数中创建服务客户端:

self.set_pen_client_ = self.create_client(SetPen, "/turtle1/set_pen")

编写调用服务的方法以及响应的回调:

def call_set_pen(self, r, g, b):
    while not self.set_pen_client_.wait_for_service(1.0):
        self.get_logger().warn("Waiting for service...")
    request = SetPen.Request()
    request.r = r
    request.g = g
    request.b = b
    future = self.set_pen_client_.call_async(request)
    future.add_done_callback(
self.callback_set_pen_response)
def callback_set_pen_response(self, future):
    self.get_logger().info("Successfully changed pen color")

我们只发送请求的rgb部分。其他值(widthoff)将保持不变。

如你所见,在响应的回调中,我们不会检查响应内部的内容,因为响应是空的(它存在,但不包含字段)。

我们现在唯一需要做的就是调用这个新的call_set_pen()方法。我们将从订阅者回调中这样做,因为这是我们能够访问乌龟的X位置的地方。

callback_pose()方法中,并在发布到主题的代码之后,添加处理笔颜色的代码:

if pose.x > 5.5 and self.previous_x_ <= 5.5:
    self.previous_x_ = pose.x
    self.get_logger().info("Set color to red.")
    self.call_set_pen(255, 0, 0)
elif pose.x <= 5.5 and self.previous_x_ > 5.5:
    self.previous_x_ = pose.x
    self.get_logger().info("Set color to green.")
    self.call_set_pen(0, 255, 0)

如果乌龟在右侧,我们将颜色设置为红色(255, 0, 0),如果它在左侧,我们将颜色设置为绿色(0, 255, 0)。

此外,我们还在构造函数中定义了一个新的属性,以便我们可以跟踪之前的X坐标:

self.previous_x_ = 0.0

我们使用这个来仅在乌龟从一侧切换到另一侧时调用服务。我们为什么要这样做?即使颜色与上一个相同,我们也可以每次都发送服务请求。为什么要“优化”代码?

原因是callback_pose()方法会被频繁调用。在终端检查/turtle1/pose主题的频率:

$ ros2 topic hz /turtle1/pose
average rate: 62.515

这意味着我们大约每秒执行callback_pose()约 62 次。这并不是真正的问题。我们也在/turtle1/cmd_vel主题上以 62 Hz 的频率发布。同样,这也不是问题。发布者和订阅者可以承受高频率(如果消息大小更大,这可能会变得复杂,但在这里,消息非常小)。

现在,如果我们每秒向服务发送 62 次请求会怎样?问题就在这里。服务不是为高频请求设计的,这可能会严重影响应用程序的性能。此外,如果你发现自己需要以 62 Hz 的频率调用服务,那么你可能有一个设计问题,你可能需要修改代码以降低频率或使用发布/订阅机制。

因此,我们在代码中确保只有在需要时才调用服务——也就是说,当海龟从一边切换到另一边时。

代码现在已完成!在这个时候,你可以再次构建你的turtle_controller包(除非你已经使用--symlink-install构建了它),源环境,然后启动turtlesimturtle_controller节点以查看结果。

挑战 2

现在,我们想在节点内部添加一个服务服务器,以便我们可以激活或停用海龟。由于我们正在定义服务器,我们需要想出一个名称和一个接口:

  • activate_turtle。我们将从一个动词开始,并尝试使名称尽可能明确。

  • 来自example_interfacesSetBool服务。它包含请求中的布尔值和响应中的字符串。然而,如前所述,如果你的应用程序是严肃的,最好避免使用std_srvsexample_interfaces包。因此,在这种情况下,我们将创建我们自己的接口。

让我们为我们的服务创建一个新的接口。由于我们已经有my_robot_interfaces包完全配置,这将非常快且简单。

my_robot_interfaces包的srv文件夹中,创建一个名为ActivateTurtle.srv的新服务文件。在这个文件中,编写服务定义:

bool activate
---
string message

请求中我们需要的所有内容就是一个布尔值来激活或停用海龟。我们还添加了一个字符串在响应中,以便我们知道发生了什么,但你也可以选择有一个空响应。

然后,将接口添加到my_robot_interfaces包的CMakeLists.txt文件中,并构建该包。源环境,并确保你可以通过以下方式看到接口:

ros2 interface show my_robot_interfaces/srv/ActivateTurtle

现在,让我们回到turtle_controller包。

由于我们将依赖于my_robot_interfaces,请在turtle_controller包的package.xml文件中添加新行:

<depend>my_robot_interfaces</depend>

现在,是时候在turtle_controller.py中编写代码了。导入接口:

from my_robot_interfaces.srv import ActivateTurtle

在构造函数中,添加一个布尔标志来跟踪海龟的激活状态,并创建一个新的服务服务器:

self.is_active_ = True
self.activate_turtle_service_ = self.create_service(ActivateTurtle, "activate_turtle", self.callback_activate_turtle)

实现该服务的回调方法:

def callback_activate_turtle(self, request: ActivateTurtle.Request, response: ActivateTurtle.Response):
    self.is_active_ = request.activate
    if request.activate:
        response.message = "Starting the turtle"
    else:
        response.message = "Stopping the turtle"
    return response

我们所做的是简单的——我们只是将is_active_布尔值设置为从请求中的布尔值得到的值。现在,每次你调用这个服务时,is_active_布尔值都会更新为你发送的值。

最后一步,要使海龟在激活或停用时开始或停止,需要修改callback_pose()方法中的代码:

def callback_pose(self, pose: Pose):
    if self.is_active_:
        # Entire code for the callback, inside the "if"

这样,只有当海龟被激活时,我们才会发布新的命令速度。如果没有,我们则不发布任何内容。此外,只有当海龟被激活时,服务请求才会生效。

要尝试这个新服务,请启动turtlesim节点和turtle_controller节点。在第三个终端中,使用ros2命令行工具发送一个服务请求。以下是一个示例:

$ ros2 service call /activate_turtle \
my_robot_interfaces/srv/ActivateTurtle "{activate: false}"

这应该会使海龟停止。你可以再次发送请求,这次使用"{activate: true}",这将使海龟再次移动。

这就是关于服务的挑战的结束。如果你自己完成了这个挑战,你对服务有很好的理解。如果你没有查看解决方案就完成了它,也不要担心。几天后再回来看看你是否能再次解决这个挑战。

摘要

在本章中,你学习了 ROS 2 服务,这是你可以与主题一起使用的另一种 ROS 2 通信方式。

使用服务,节点可以通过客户端/服务器类型的通信相互交谈。对于服务,只能存在一个服务器,但你可以从多个客户端发送多个请求。

你可以直接在你的节点中使用rclpy(Python)和rclcpp(C++)实现服务服务器和客户端。

要编写服务服务器,你必须执行以下操作:

  1. 由于名称和接口由服务器定义,你必须在这里选择它们。作为一个最佳实践,使用动词作为名称的第一个单词。

  2. 在你的代码中导入接口,并在构造函数中创建服务服务器。

  3. 添加一个回调方法来处理任何接收到的请求。

在选择服务接口时,如果你找不到一个完全符合你需求的现有接口,那么你必须创建并构建自己的接口。为此,你必须执行以下操作:

  1. 创建并设置一个专门用于接口的包。如果你已经有了为你的应用程序创建的一个包,请使用它。

  2. 将新的服务接口添加到包中并构建它。

  3. 现在,你可以在你的服务服务器中使用这个接口。

要编写服务客户端,请执行以下操作:

  1. 如果你正在编写客户端,这意味着在另一边有一个现有的服务器。找出你需要使用的名称和接口。

  2. 将接口导入到你的代码中,并在构造函数中创建服务客户端。

  3. 创建一个调用服务的方法。在这个方法中,你异步发送请求,然后注册一个回调来处理响应。

  4. 你可以在代码的任何地方调用服务。

使用ros2 service命令行,你可以检查你节点中的服务,并查看它们使用的是什么接口。

要尝试服务服务器,你可以在另一个节点内部编写相应的服务客户端,或者如果请求很简单,可以直接从终端使用ros2 service call调用服务。

现在,你已经看到了 ROS 2 中最常见的两种通信类型:主题和服务。在下一章中,我们将处理第三种和最后一种:动作。

第七章:动作 – 当服务不足时

在本章中,我们将探讨 ROS 2 的第三种通信类型:动作。为了理解动作,你需要阅读关于节点、主题和服务的先前章节。

在我们开始之前,我想提醒你,本章涵盖的内容比我们之前遇到的以及将要遇到的内容更为高级。

如果你已经具备一定程度的经验,本章将满足你的需求,因为它将为你提供所有三种 ROS 2 通信类型的全面概述。然而,如果你是 ROS 的初学者,没有任何经验,现在可能有点过于复杂。这是可以的,主题和服务已经足够你开始使用 ROS 2。你现在可以跳过这一章(它独立于未来的章节),继续学习参数和启动文件。在你通过在 ROS 2 项目中工作建立更多信心之后,回头再看它可能是个好主意。

在本章中,你将通过逐步构建的示例来了解为什么需要动作以及它们是如何工作的。然后,你将编写代码使两个节点相互通信。我们将使用ch6文件夹中的代码(在此书的 GitHub 仓库:github.com/PacktPublishing/ROS-2-from-Scratch)作为起点。你可以在ch7文件夹中找到最终代码。

到本章结束时,你将能够编写动作服务器和客户端,并利用所有动作功能,如反馈和取消机制。

尽管主题和服务已经足够开始,但 ROS 2 动作同样重要,因为它们帮助你将代码提升到下一个层次,并在你的机器人应用中实现更复杂的行为。

本章将涵盖以下主题:

  • 什么是 ROS 2 动作?

  • 创建自定义动作接口

  • 编写动作服务器

  • 编写动作客户端

  • 充分利用所有动作机制

  • 处理动作的附加工具

什么是 ROS 2 动作?

为了理解 ROS 2 动作,我们需要了解为什么需要它们。这是我们首先关注的内容。之后,我将通过一个真实生活中的例子解释动作是如何工作的。

你通过运行一些现有的节点和命令行工具,很快就在第三章中发现了动作。在那里建立的感觉将帮助你更好地理解本章的概念。

让我们深入探讨,看看为什么以及何时在 ROS 2 应用中需要动作。

为什么需要动作?

到目前为止,我们已经探讨了 ROS 2 的两种通信形式:主题和服务。

主题被节点用来发送和接收消息。发布者将在主题上发布数据,而订阅者将订阅主题以接收数据。因此,主题非常适合在应用中发送数据流。

服务用于节点之间的客户端/服务器交互。客户端向服务器发送请求,然后服务器执行或计算某些内容,并将响应返回给客户端。

所以,这就应该是全部了,对吧?我们还需要什么?

在其早期阶段,ROS 只从主题和服务开始。然而,ROS 开发者很快意识到,对于某些机器人应用来说,某些东西是缺失的。让我们用一个例子来看看。

假设你有一个有两个轮子的移动机器人。首先,你会创建一个负责控制轮子的节点。这个节点还能够接收命令,例如移动到(x, y)坐标。这些命令将被转换成应用于轮子的速度。然而,你也会希望能够在机器人完成移动时得到通知。

根据我们目前所知,ROS 2 服务似乎是一个不错的选择。在这个服务器节点中,你可以实现一个/move_robot服务,该服务将接收来自客户端的坐标。一旦收到命令,控制器开始移动轮子。然后,当机器人到达目的地时,服务器向客户端返回一个响应。

为了完成通信,我们必须在另一个节点上添加一个服务客户端。客户端将发送一个包含要到达的(x,y)坐标的请求到服务器。当服务器返回响应时,我们知道机器人已经完成移动——要么成功到达目的地,要么被某些因素阻止,我们得到一个错误:

图 7.1 – 使用服务控制两轮机器人

图 7.1 – 使用服务控制两轮机器人

这有什么问题吗?

好吧,移动机器人空间中的物理部分可能需要一些时间。在某些情况下,这可能只是几秒钟,也可能是几秒钟,甚至几分钟。关键是服务执行可能需要相当长的时间。

话虽如此,当机器人移动时,你可能还想做几件事情,而这些事情在使用服务时是缺失的:

  • 由于执行需要一些时间,从服务器获取一些反馈将会很棒。使用服务时,客户端对服务器端发生的事情一无所知。因此,客户端完全处于盲状态,需要等待响应以获取一些信息。

  • 如何取消当前执行?这似乎是一个合理的功能。一旦你在服务器端开始执行,客户端可能想要取消它。例如,假设客户端节点还使用摄像头监控环境。如果检测到障碍物,客户端可以要求服务器停止执行。根据我们现在所拥有的,客户端除了等待服务器完成执行外,什么也不能做。

  • 现在先说最后一个要点,尽管我们可以找到更多:服务器如何正确处理多个请求?假设你有两个或更多的客户端,每个客户端发送不同的请求。你如何在服务器上选择这些请求?服务器如何拒绝执行请求,或者选择用新的请求替换旧的请求,而不完成第一个请求?或者,在另一个场景中,服务器如何同时处理多个请求?作为一个类比,当你电脑上下载文件时,电脑不会只卡在一个文件上。它可以同时下载多个文件。你甚至可以在其他下载仍在运行时取消一个下载。

回到我们的例子,你可以看到简单的服务是不够的。对于这个用例,我们需要更多的功能。我们可以做的是实现额外的服务,比如一个用于取消请求的服务。我们还可以添加一个新的主题来发布关于机器人在执行过程中的位置的一些反馈。

有好消息——你不必这样做。所有这些问题都是由 ROS 2 动作解决的。反馈机制、取消机制以及其他功能也在动作中直接实现。

总结来说,服务对于客户端/服务器通信来说非常完美,但只有当动作/计算快速执行时。如果执行可能需要一些时间,并且你想要额外的功能,如反馈或取消,那么动作就是你需要的。

现在你已经知道了为什么我们需要动作,现在是时候了解它们是如何工作的了。

动作是如何工作的?

让我们用之前的例子,这次使用 ROS 2 动作而不是服务。我会向你展示动作在高级别是如何工作的,以及客户端和服务器之间的不同交互。在本章的后面部分,我们将深入代码,查看实现细节。

我们将使用两个节点:一个包含动作客户端,另一个包含动作服务器(这是负责控制轮子的那个)。

为了理解动作是如何工作的,让我们跟随一个动作的执行流程:

图 7.2 – ROS 2 动作的执行流程

图 7.2 – ROS 2 动作的执行流程

下面是这个流程的步骤:

  1. 动作客户端将通过向动作服务器发送请求来开始通信。对于动作,请求被命名为目标。因此,我们不会在这里讨论请求,而是关于目标。在这里,目标可以是到达的 (x, y) 坐标。

  2. 动作服务器接收目标并决定接受或拒绝它。客户端立即从服务器收到这个响应。如果目标被拒绝,那么通信结束。

  3. 如果目标被接受,服务器就可以开始处理它并执行相应的动作。在这个例子中,服务器节点会让机器人移动。

  4. 一旦客户端知道目标已被接受,它将发送一个请求以获取结果并等待它(通过注册回调异步地)。对于服务,我们谈论的是响应。对于动作,这将是一个结果

  5. 当服务器完成目标的执行(无论是成功还是不成功)后,它将向客户端发送结果。以本例为例,结果可能是最终到达的(x,y)坐标。

  6. 客户端在收到结果后,通信结束。

这就是动作的工作方式,具有最小化的功能集。从服务器端来看,接收一个目标,接受或拒绝它,然后执行,并返回结果。从客户端来看,发送一个目标,如果被接受,则发送一个请求,并从服务器接收结果。

此外,您还可以添加额外的功能,所有这些都是可选的。以下是动作的附加机制:

图 7.3 – 包含所有通信机制的动作

图 7.3 – 包含所有通信机制的动作

让我们更仔细地看看:

  • 反馈:在执行目标的过程中,服务器可以向客户端发送一些反馈。以本例为例,反馈可以是机器人的当前坐标,甚至是完成率。因此,客户端可以了解目标执行过程中的情况。

  • 取消:在服务器接受目标并正在服务器端执行目标之后,客户端可以决定取消该目标。为此,它将发送一个取消请求,该请求必须得到服务器的批准。如果取消请求被接受,那么服务器将尝试完成执行。因此,在本例中,它可以使机器人停止。最后,无论目标是否成功、失败或取消,服务器都将向客户端返回一个结果。

  • 目标状态:这对您来说并不那么重要,因为它是动作的内部机制,您不会直接在代码中使用它(我只是为了完整性而添加了它)。每个目标都将获得一个状态机,具有如接受执行等状态。对于每个目标状态的变化,服务器将通知客户端。

通过这种方式,你已经看到了可以在动作中实现的所有可能的通信机制。

注意,在前面的图中,一些通信用红线表示,而其他通信用绿线表示。幕后,动作仅使用主题和服务。即使动作本身是 ROS 2 通信,底层的代码也在使用另外两种通信类型。在这里,红线代表服务,绿线代表主题。

因此,在一个动作中,你有三个服务(发送目标、取消目标和接收结果)和两个主题(反馈和目标状态)。好消息是,你不必自己创建这些主题和服务——它们已经在动作机制中实现了。你只需要使用 ROS 2 库中的动作客户端和服务器功能。

要创建一个动作,你需要给它一个名称(例如,move_robot),这样客户端就知道将目标发送到哪里。你还需要使用一个接口(目标、结果、反馈)

另一个需要注意的事项是,只能有一个动作服务器。就像服务一样,你不能有两个使用相同名称的服务器。另一方面,你可以有多个动作客户端。每个客户端也可以发送多个目标;这不是问题。

总结

在主题和服务之上,你可以使用动作使你的节点相互通信。现在,你何时应该使用主题、服务或动作?

当你想在节点之间发送数据流时,你应该使用主题。使用主题时,没有响应。例如,这可以用于发布传感器数据或向另一个节点发送命令流,如果你不需要任何确认的话。

当你想进行客户端/服务器通信,或者要执行的动作非常快,例如计算或简单的动作,如打开 LED 时,服务是完美的。

最后,你将使用动作来进行任何需要客户端/服务器通信且可能需要一些时间来执行的事情,以及当你还想要有反馈和取消等机制时。

以下是关于动作如何工作的一些重要点:

  • 动作由一个名称和一个接口定义。

  • 动作名称遵循与主题和服务相同的规则。它必须以字母开头,后跟其他字母、数字、下划线、波浪号和斜杠。此外,由于动作是在执行某事,最佳实践是以动词开头命名。

  • 界面包含三个元素:一个目标、一个结果和反馈。客户端和服务器必须使用相同的界面。

  • 一个动作服务器只能存在一次,但你可以从一个或多个动作客户端发送多个目标。

  • 动作客户端不知道包含服务器的节点。它们只知道必须使用动作名称和接口来连接服务器。

要实现动作通信,你至少需要做以下事情:

  • 从客户端向服务器发送一个目标。

  • 接受(或不接受)目标并在服务器上执行它。

  • 一旦目标完成,服务器需要向客户端返回一个结果。

以下是一些你可以添加的可选功能:

  • 在目标执行期间,从服务器向客户端发送一些执行反馈。

  • 允许客户端向服务器发送取消请求。如果被接受,则在服务器端完成目标执行。

要在代码中编写动作服务器和客户端,你必须使用来自 rclpy.actionrclcpp_action 库的动作功能。

到目前为止,我们可以开始编写一些代码。如果你仍然有点困惑,不要担心——动作最初相当复杂。它们包含很多不同的机制。随着我们创建动作并编写客户端和服务器代码,一切都会更加清晰。

由于没有服务器我们无法测试客户端,因此我们将像为服务所做的那样,从服务器端开始。要创建一个服务器,我们需要一个动作接口,因此这将是我们的起点。

创建自定义动作接口

要创建一个动作接口,我们首先需要清楚地定义我们希望通过动作实现什么。然后,我们可以将接口添加到 my_robot_interfaces 包(在本节中,我们将继续使用我们在前几章中创建的包)。

定义所需的应用程序和接口

在本章我们将编写的应用程序中,动作服务器将负责计数直到一个给定的数字,每次计数之间有延迟,以便我们可以模拟动作需要一些时间并且不会立即返回。客户端需要向服务器发送一个数字,以便服务器可以开始计数。当服务器完成时,它将结果(最后达到的数字)发送回客户端。

例如,假设客户端向服务器发送数字 5,并且延迟为 0.5 秒。服务器将从 0 开始计数,直到 5,并在每次迭代之间等待 0.5 秒。当完成时,如果服务器能够数到终点,则返回 5,或者如果执行提前结束(目标被取消,或任何其他可能导致服务器停止目标的原因),则返回最后达到的数字。此外,我们将在服务器执行目标时添加一些关于当前计数的反馈。

在我们编写任何代码之前,我们需要知道要使用哪个接口进行动作。从前一段中,我们可以看到我们需要以下内容:

  • 目标:一个表示目标数字的整数和一个表示延迟的浮点数

  • 结果:一个表示最后达到的数字的整数

  • 反馈:一个表示当前计数的整数

对于主题和服务,你必须首先检查是否可以找到一个现有的接口来满足你的需求,因为已经有很多你可以使用而不需要创建一个新的接口。

对于动作,你可以尝试做同样的事情,但现有的动作接口并不多。动作比其他通信类型更复杂,因此你需要找到一个与你的应用程序的目标、结果和反馈完全匹配的接口。这种情况的可能性非常低,因为每个动作都将非常不同。因此,对于动作,我们不会尝试寻找现有的接口,而是直接创建一个自定义接口。

创建一个新的动作接口

创建动作接口的过程将与主题和接口接口相同。我们将遵循类似的方法。

首先,你需要创建和配置一个专门用于接口的包。我们在 第五章为话题创建自定义接口 部分中做到了这一点,使用的是 my_robot_interfaces 包。你可以重用这个包来添加你的动作接口。如果你没有它,请先返回并配置它,然后继续以下步骤。

在这个包中,我们已经有 msgsrv 文件夹,分别用于主题和接口接口。我们将添加一个名为 action 的第三个文件夹,用于——正如你可能猜到的——动作接口:

$ cd ros2_ws/src/my_robot_interfaces/
$ mkdir action

在这个新文件夹中,你将放置所有特定于你的机器人或应用程序的动作接口。

现在,为你的动作创建一个新文件。以下是关于文件名的规则:

  • 使用大驼峰命名法——例如,CountUntil

  • 不要在名称中使用 ActionInterface,因为这会增加不必要的冗余。

  • 使用 .action 作为文件扩展名。

  • 作为最佳实践,在接口的名称中使用一个动词——例如,NavigateToPositionOpenDoorPickObjectFromTableFetchDrinkFromFridge。动作,就像服务一样,是关于执行一个动作或计算(这可能需要一些时间),所以通过使用一个动词,你可以非常清楚地知道动作在做什么。

由于我们想要计数到指定的数字,因此我们可以将接口命名为 CountUntil

$ cd ~/ros2_ws/src/my_robot_interfaces/action/
$ touch CountUntil.action

你可以在该文件中编写动作的定义。由于我们有三个不同的部分(目标、结果和反馈),我们需要将它们分开。你必须在目标和结果之间添加三个短横线(---),并在结果和反馈之间再添加三个短横线。

即使你不想发送任何反馈,或者结果为空,你仍然必须添加两个带有三个短横线的分隔符(---)。一个非常简单的动作定义,结果和反馈中没有任何内容,看起来像这样:

int64 goal_number
---
---

对于目标、结果和反馈,你可以使用以下内容:

  • 内置类型(boolbyteint64 等)。

  • 现有的消息接口。例如,动作的目标可以包含 geometry_msgs/Twist

注意

你不能在动作定义中包含动作或服务定义。你只能在目标、结果或反馈中包含消息(主题定义)。这三个部分可以被视为三个独立的消息。

由于我们正在创建一个相对简单的应用程序,我们在这里将只使用内置类型:

# Goal
int64 target_number
float64 delay
---
# Result 
int64 reached_number 
---
# Feedback 
int64 current_number

对于主题和接口接口,定义内部的所有字段都必须遵循 snake_case 规范(单词之间使用下划线,所有字母必须小写,且没有空格)。

我还添加了注释来指定哪个部分是目标、结果和反馈。你不需要这样做——我只是为了你的第一个动作定义这样做,以免你感到困惑。人们经常在顺序上犯错误,把反馈放在结果之前,这可能导致难以调试的错误。顺序是目标、结果,然后是反馈。

现在我们已经编写了接口,我们需要构建它,以便我们可以在代码中使用它。回到my_robot_interfaces包的CMakeLists.txt文件。由于包已经配置好了,我们只需要做一件事:在rosidl_generate_interfaces()函数内部的新行上添加接口的相对路径。行与行之间不要使用任何逗号:

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/HardwareStatus.msg"
  "srv/ResetCounter.srv"
  "srv/ActivateTurtle.srv"
  "action/CountUntil.action"
)

然后,保存所有文件并构建my_robot_interfaces包:

$ colcon build --packages-select my_robot_interfaces

构建完成后,源环境。你应该能够找到你的新接口:

$ ros2 interface show my_robot_interfaces/action/CountUntil
# Action interface definition here

如果你看到了动作定义,你就知道你的动作接口已经成功构建,你现在可以在你的代码中使用它了。这就是我们将要做的,从我们的应用程序的动作服务器开始。

编写动作服务器

在本节中,你将编写你的第一个动作服务器。在这个服务器中,我们将能够接收目标。当接收到目标时,我们将决定是否接受或拒绝它。如果接受,我们将执行目标。对于这个应用,执行目标意味着我们将从零开始计数到目标数字,并在每次迭代之间等待提供的延迟。一旦执行了目标,我们将向客户端返回一个结果。

我们将在代码中实现这一点,从 Python 开始,然后是 C++。在本节中,我们只开始实现动作通信所需的最小功能。我们将在稍后添加反馈和取消机制。由于动作比主题和服务稍微复杂一些,让我们从简单开始,一步一步来。

为了获得更好的学习体验,确保你在跟随时使用 GitHub 代码,因为我不会显示本章的所有行,只显示重要的行。本节的代码位于count_until_server_minimal文件中(文件末尾带有.py.cpp扩展名)。我们在这里不会使用number_publishernumber_counter节点。

在为服务器编写任何代码之前,我们需要为我们的动作选择一个名称和接口。由于我们想要计数到一个给定的数字,我们将动作命名为count_until,并使用我们刚刚创建的CountUntil接口。

我们现在已经有了一切,可以开始编写 Python 代码。

编写 Python 动作服务器

你需要在节点内部编写你的动作服务器。在my_py_pkg包(与其他 Python 文件一起)内创建一个名为count_until_server_minimal.py的新文件。使此文件可执行。

导入接口并创建服务器

让我们先设置动作服务器。

首先,我们必须导入我们将在代码中需要的许多库和类:

import rclpy
import time
from rclpy.node import Node
from rclpy.action import ActionServer, GoalResponse
from rclpy.action.server import ServerGoalHandle

与主题和服务不同,动作服务器不是直接包含在Node类中的。因此,我们需要从rclpy.action导入ActionServer类。

在此之后,您还必须导入动作的接口:

from my_robot_interfaces.action import CountUntil

当您从另一个包导入接口时,请确保将依赖项添加到my_py_pkgpackage.xml文件中的my_robot_interfaces(如果您一直跟随,您应该已经这样做过了):

<depend>my_robot_interfaces</depend>

回到count_until_server_minimal.py文件,让我们在节点的构造函数中创建动作服务器(如本节介绍中所述,我将只显示重要和相关的片段;完整的构造函数代码可在 GitHub 上找到):

self.count_until_server_ = ActionServer(
           self,
           CountUntil,
           "count_until",
           goal_callback=self.goal_callback,
           execute_callback=self.execute_callback)

要使用 Python 创建动作服务器,您必须使用我们之前导入的ActionServer类。提供以下参数:

  • self.create…()。在这里,它有点不同:对象(self)作为第一个参数提供。

  • 我们导入的CountUntil接口。

  • count_until

  • 目标回调:当接收到目标时,它将在该回调内部处理。

  • 执行回调:如果在目标回调中接受了目标,它将在执行回调中处理。这是您执行动作的地方。

在创建动作服务器时,我们指定了两个回调方法。当节点旋转时,动作服务器将处于等待模式。一旦接收到目标,节点将触发目标回调,如果需要,然后触发执行回调。让我们实现这些回调。

接受或拒绝目标

动作服务器现在可以接收目标。我们需要决定是否接受或拒绝它们。

让我们开始编写目标回调,这是服务器接收到目标时首先调用的方法:

def goal_callback(self, goal_request: CountUntil.Goal):
    self.get_logger().info("Received a goal")
    if goal_request.target_number <= 0:
        self.get_logger().warn("Rejecting the goal, target number must be positive")
        return GoalResponse.REJECT
    self.get_logger().info("Accepting the goal")
    return GoalResponse.ACCEPT

在此回调中,我们接收客户端发送的目标(它是CountUntil.Goal类型)。

注意

动作接口包含一个目标、一个结果和反馈。您为每个消息获得一个类:CountUntil.GoalCountUntil.ResultCountUntil.Feedback。我们将在本章中使用这三个类。

最好的做法是在为服务器编写代码时,每次都验证您接收到的数据。对于这个应用程序,让我们假设我们只想接受正数目标。如果数字是负数,我们将拒绝目标。

在验证数据后,您需要返回GoalResponse.ACCEPTGoalResponse.REJECT以分别接受或拒绝目标。客户端将立即被告知该决定。然后,如果目标被拒绝,服务器端将不再发生任何事情。如果目标被接受,将触发执行回调。

执行目标

让我们实现执行回调。以下是代码的开始部分:

def execute_callback(self, goal_handle: ServerGoalHandle):
    target_number = goal_handle.request.target_number
    delay = goal_handle.request.delay
    result = CountUntil.Result()
    counter = 0

在这个回调中,您会得到一个所谓的目标句柄,它属于ServerGoalHandle类型。我已经将参数类型明确化,这样我们就可以在 VS Code 中获得自动完成功能。这个目标句柄包含目标信息,但您也可以用它来设置目标的最终状态,我们将在下一分钟看到这一点。

您通常必须做的第一件事是从目标中提取数据。在这里,我们获取目标数字和延迟,这些是我们执行动作时将使用的。然后,我们初始化一些事情:CountUntil.Result类的结果,以及从0开始的计数器。

这样,我们就可以开始执行目标:

    self.get_logger().info("Executing the goal")
    for i in range (target_number):
        counter += 1
        self.get_logger().info(str(counter))
        time.sleep(delay)

这部分代码每次都会不同,因为它完全取决于您的应用程序。在这里,我们正在增加计数器,直到达到目标数字,每次迭代之间都有延迟。

在这里使用延迟的目的只是为了让这个方法花费更多时间,以便我们可以模拟动作的行为。如果我们想尽可能快地计数,没有任何延迟,我们可以使用服务,因为动作几乎会立即完成。

一旦执行完成,我们需要做两件事——为目标设置最终状态,并返回一个结果给客户端:

    goal_handle.succeed()
    result.reached_number = counter
    return result

在动作执行期间,目标是处于执行中状态。当完成执行时,您需要让它过渡到最终状态。

在这种情况下,由于一切都很顺利,我们预计在执行过程中不会出现任何问题,所以我们通过在目标句柄上使用succeed()方法将目标设置为成功。例如,如果您的动作负责移动机器人的轮子,并且在执行过程中与轮子的通信丢失,您将停止动作,并使用abort()方法将目标设置为已取消。最后可能的状态是已取消,我们将在本章稍后看到。

我们现在已经编写了动作服务器正常工作的最小代码。在我们编写动作客户端之前,让我们切换到 C++。如果您只想跟随 Python 的解释,那么请继续跳过下一节。

编写 C++ 动作服务器

C++ 动作的代码逻辑与 Python 非常相似,但语法有一些特定的差异。我们将主要关注这些差异。此外,由于代码开始变得相当庞大,我可能不会显示完整的代码,只显示理解所需的重要部分。请确保查看这本书的 GitHub 仓库以查看完整的代码。

导入接口并创建服务器

让我们先设置动作服务器。首先,在您的my_cpp_pkg包的src目录中创建一个名为count_until_server_minimal.cpp的新文件。

打开文件,首先添加必要的包含:

#include "rclcpp/rclcpp.hpp"
#include "rclcpp_action/rclcpp_action.hpp"
#include "my_robot_interfaces/action/count_until.hpp"

如您所见,动作库不是rclcpp的子库——它是一个完全独立的库,来自不同的包:rclcpp_action

对于我们使用的每个新包,我们需要将依赖项添加到my_cpp_pkg包的package.xml文件中:

<depend>my_robot_interfaces</depend>
<depend>rclcpp_action</depend>

你还需要在CMakeLists.txt文件中指定这些依赖项:

find_package(my_robot_interfaces REQUIRED)
find_package(rclcpp_action REQUIRED)

最后,当你创建你的可执行文件时,别忘了将这两个依赖项添加到ament_target_dependencies()函数中:

add_executable(count_until_server src/count_until_server_minimal.cpp)
ament_target_dependencies(count_until_server rclcpp rclcpp_action my_robot_interfaces)

回到count_until_server_minimal.cpp文件,我们添加一些using行来简化代码(你可以在文件的顶部找到这些行,在#include行下面)。之后,你可以在你的类中添加一个动作服务器作为私有属性:

rclcpp_action::Server<CountUntil>::SharedPtr count_until_server_;

再次,我们将使用共享指针来保持对象。

然后,在构造函数中,你可以创建动作服务器:

count_until_server_ = rclcpp_action::create_server<CountUntil>(
    this,
    "count_until",
    std::bind(&CountUntilServerNode::goalCallback, this, _1, _2),
    std::bind(&CountUntilServerNode::cancelCallback, this, _1),
    std::bind(&CountUntilServerNode::executeCallback, this, _1)
);

对于动作,C++的语法比 Python 更严格。除了动作接口、要链接的对象和动作名称之外,你必须提供三个回调(即使你不想使用它们):

  • 目标回调:用于接受或拒绝传入的目标。

  • 取消回调:接收取消请求。

  • 执行回调:在 C++中,这被称为处理已接受回调,但我将其命名为执行回调,以便使代码与 Python 相似。在这个回调中,我们执行已接受的目标。

注意

我设计这一章是为了我们先编写最少的代码,然后添加额外的可选功能。然而,如果你不提供取消回调,C++的create_server()方法将无法工作。因此,我们现在要添加这个回调,但不会完全实现取消机制;我们将在稍后完成。

到目前为止,我们需要实现三个回调方法。

实现回调

回调函数内部的参数可能相当长,难以书写。这就是为什么我建议在代码开头使用using行来简化代码,以及仔细检查一切,因为很容易出错。

这是目标回调方法的开头:

rclcpp_action::GoalResponse goalCallback(const rclcpp_action::GoalUUID &uuid, std::shared_ptr<const CountUntil::Goal> goal)

在这里,你得到一个目标唯一标识符以及目标本身(更准确地说,这是一个指向目标的const共享指针)。在回调中,我们验证目标,然后接受或拒绝它。例如,要接受目标,你会返回以下内容:

return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;

下一个回调方法是取消回调,你可以决定是否接受或拒绝传入的取消请求。由于我将在本章后面解释取消机制,我现在将跳过这部分内容——你只需要编写回调,以便代码可以编译。

这里最重要的回调是执行回调。在这个方法中,我们接收一个目标句柄(const std::shared_ptr goal_handle)。我们必须做的第一件事是从目标中提取数据并初始化一些事情:

int target_number = goal_handle->get_goal()->target_number;
double delay = goal_handle->get_goal()->delay;
auto result = std::make_shared<CountUntil::Result>();
int counter = 0;
rclcpp::Rate loop_rate(1.0/delay);

你可能已经习惯了到处看到共享指针,这里也不例外。我们不是创建一个结果对象,而是一个指向结果对象的共享指针。

然后,为了处理每次计数迭代之间的等待时间,我们使用一个 rclcpp::Rate 对象。这与我们用 Python 做的事情有点不同。在这个速率对象中,我们必须传递速率——即我们想要的循环频率。例如,如果延迟是 0.5 秒,频率将是 2.0 Hz。我们现在可以执行动作:

RCLCPP_INFO(this->get_logger(), "Executing the goal");
for (int i = 0; i < target_number; i++) {
    counter++;
    RCLCPP_INFO(this->get_logger(), "%d", counter);
    loop_rate.sleep();
}

在这里,我们使用速率对象的 sleep() 函数来暂停执行。

最后,一旦 for 循环结束,我们可以完成执行:

result->reached_number = counter;
goal_handle->succeed(result);

在 Python 中,我们首先设置目标的最终状态,然后返回结果。在 C++ 中,我们不返回任何内容(注意 void 返回类型)。我们在设置目标状态的同时发送结果。

注意

使用动作编写 C++ 代码开始变得相当复杂,尤其是如果你没有太多 C++ 经验。如果你感到完全迷茫,也许可以只继续使用 Python,或者,如前所述,现在暂时跳过这一章,稍后再回来。

C++ 动作服务器的内容到此结束。我们现在可以编写客户端节点并尝试通信。

编写动作客户端

现在我们已经有了服务器接收目标、接受它、执行它并返回结果的必要最小代码。在这个时候,我们可以编写通信的客户端部分。

动作客户端将向服务器发送一个目标。然后,它将注册一个回调以确定目标是否被接受或拒绝。如果目标被接受,客户端将注册另一个回调以获取最终结果。这正是我们现在要实现的——首先用 Python,然后用 C++。

你应该在何处编写动作客户端?在你的 ROS 2 应用程序中,你可以在任何节点中添加动作客户端。例如,假设你有一个监控移动机器人电池电平的节点。这个节点可能已经有一些发布者、订阅者、服务等等。在所有这些之上,你可以添加一个动作客户端,当电池电量低时,它会向另一个节点(例如控制机器人轮子的服务器节点)发送目标。

对于本章,为了保持简单,我们将创建一个新的节点,专门用于动作客户端。然后你可以将此代码用作模板,添加到任何你想添加动作客户端的地方。你可以在此部分的 count_until_client_minimal 中找到代码(.py.cpp)。

让我们从 Python 动作客户端开始。

编写 Python 动作客户端

my_py_pkg 包中创建一个名为 count_until_client_minimal.py 的新 Python 文件。使此文件可执行。

创建动作客户端

让我们先设置动作客户端。首先,添加我们将需要的依赖项:

import rclpy
from rclpy.node import Node
from rclpy.action import ActionClient
from rclpy.action.client import ClientGoalHandle, GoalStatus
from my_robot_interfaces.action import CountUntil

至于动作服务器,我们不是直接从 Node 类获取动作客户端。相反,我们必须从 rclpy.action 导入 ActionClient

我们还必须导入动作接口,它应该与服务器相同。如果我们导入这个接口,我们还需要在package.xml文件中添加一个依赖项。然而,我们已经做了,所以不需要添加任何其他内容。

然后,在节点的构造函数中,我们创建一个动作客户端:

self.count_until_client_ = ActionClient(
self, CountUntil, "count_until")

我们直接使用ActionClient类,并传递三个参数:要绑定的对象(self)、动作接口和动作名称。请确保名称与服务器端相同。

然后,为了向服务器发送目标,我们添加一个新的方法:

def send_goal(self, target_number, delay):
    self.count_until_client_.wait_for_server()
    goal = CountUntil.Goal()
    goal.target_number = target_number
    goal.delay = delay
    self.count_until_client_.send_goal_async(
        goal).add_done_callback(self.goal_response_callback)

从客户端向服务器发送目标的过程如下:

  1. 您可以使用wait_for_server()等待服务器。如果您在服务器未启动和运行时发送目标,您将得到一个错误,所以确保它在您做任何事情之前已经准备好了。我没有提供超时,所以它将无限期地等待。您可以为它添加一个超时,并做类似我们在第六章中在编写服务 客户端部分所做的事情。

  2. 从接口创建一个目标对象:Interface.Goal()

  3. 填写目标字段。您省略的任何字段都将获得默认值(数字为0,字符串为"")。

  4. 使用send_goal_async()发送目标。这将返回一个 Python Future对象。

  5. 注册一个目标响应的回调,以便您知道它已被接受或拒绝。

注意,就像对于服务一样,我们使用send_goal_async()进行异步调用。这样,方法将返回,我们不会阻塞执行。如果我们阻塞执行,我们也会阻塞旋转,因此我们永远不会得到任何响应。

实现回调

到目前为止,我们已经使用动作客户端发送了一个目标并注册了一个回调,goal_response_callback()。让我们实现这个方法:

def goal_response_callback(self, future):
    self.goal_handle_: ClientGoalHandle = future.result()
    if self.goal_handle_.accepted:
        self.get_logger().info("Goal got accepted")
        self.goal_handle_.get_result_async().add_done_callback(
            self.goal_result_callback)
    else:
        self.get_logger().info("Goal got rejected")

在这个回调中,我们从 Python Future对象的输出结果中获取一个ClientGoalHandle对象。从这个目标处理程序中,我们可以找出目标是否被接受。

请注意,您不会在这个目标响应回调中获得最终结果。在这里,我们只知道服务器是否接受了目标。如果目标被接受,我们知道服务器将开始执行它,并在某个时候返回一个结果。

然后,在客户端,我们可以为目标结果注册另一个回调:

def goal_result_callback(self, future):
    status = future.result().status
    result = future.result().result
    if status == GoalStatus.STATUS_SUCCEEDED:
        self.get_logger().info("Success")
    elif status == GoalStatus.STATUS_ABORTED:
        self.get_logger().error("Aborted")
    elif status == GoalStatus.STATUS_CANCELED:
        self.get_logger().warn("Canceled")
    self.get_logger().info("Result: " + str(result.reached_number))

在这个回调中,我们在服务器完成目标执行后,获取目标的最终状态和结果。

您可以用这个结果做任何您想做的事情——在这里,我们只是简单地打印它。如您所见,我们将收到目标的三种最终状态之一:STATUS_SUCCEEDEDSTATUS_ABORTEDSTATUS_CANCELED

最后,别忘了调用send_goal()方法。我们将在main()函数中这样做,就在初始化节点之后,在我们使节点旋转之前:

node = CountUntilClientNode()
node.send_goal(5, 0.5)
rclpy.spin(node)

这将要求服务器计数到5,并在每次计数之间等待0.5秒。

尝试通信

现在,我们可以尝试客户端和服务器之间的通信。

为客户端和服务器节点创建一个可执行文件(在setup.py中)。构建包并源环境。

然后,在两个不同的终端中启动服务器节点和客户端节点。随着通信的进行,您应该在两个终端中看到一些日志。最后,对于服务器,您将得到如下内容:

$ ros2 run my_py_pkg count_until_server
[count_until_server]: Action server has been started.
[count_until_server]: Received a goal
[count_until_server]: Accepting the goal
[count_until_server]: Executing the goal
[count_until_server]: 1
...
[count_until_server]: 5

对于客户端:

$ ros2 run my_py_pkg count_until_client
[count_until_client]: Goal got accepted
[count_until_client]: Success
[count_until_client]: Result: 5

您可以通过每个日志中的时间戳看到执行流程。在这里,我们测试了目标数字为正的情况——因此,目标被接受。如果您愿意,您也可以测试目标数字为负的情况;您应该看到目标被拒绝而没有被执行。

现在,让我们学习如何用 C++编写动作客户端。

编写 C++动作客户端

对于 C++代码,我将关注count_until_client_minimal.cpp文件中需要注意的几个重要点。

首先,我们有所有的包含和using行。这些几乎与 C++动作服务器相同。然而,对于目标处理,我们得到ClientGoalHandle(在服务器代码中是ServerGoalHandle):

using CountUntilGoalHandle = rclcpp_action::ClientGoalHandle<CountUntil>;

要创建一个动作客户端,我们声明客户端为类的私有属性:

rclcpp_action::Client<CountUntil>::SharedPtr count_until_client_;

然后,我们在构造函数中初始化客户端:

count_until_client_ = rclcpp_action::create_client<CountUntil>(this, "count_until");

如您所见(但这不应该再是惊喜了),我们存储了一个指向动作客户端的共享指针。初始化时,我们提供了动作接口、要绑定的对象(this)和动作名称,这个名称应该与服务器代码中定义的相同。

到这一点,我们可以创建一个sendGoal()方法来向服务器发送目标。此方法遵循与 Python 客户端相同的步骤。我们等待服务器,然后创建一个目标,填写目标字段,发送目标,并注册回调。然而,我们在处理回调的方式上有一个很大的不同:

auto options = rclcpp_action::Client<CountUntil>::SendGoalOptions();
options.goal_response_callback = std::bind(
    &CountUntilClientNode::goalResponseCallback, this, _1);
options.result_callback = std::bind(
    &CountUntilClientNode::goalResultCallback, this, _1);
count_until_client_->async_send_goal(goal, options);

在 Python 中,我们在发送目标后链式调用回调。在 C++中,您首先需要创建一个SendGoalOptions对象。在这个对象中,您可以注册客户端的不同回调方法。在这里,我们注册了响应和结果回调。然后,您必须将此对象传递给async_send_goal()方法。这将注册节点旋转时的所有回调。

现在我们已经注册了两个回调,我们需要实现它们。

在目标响应回调中,为了检查目标是否被接受或拒绝,我们可以简单地写下以下内容:

if (!goal_handle) {

如果这个返回false,我们知道目标被拒绝了。如果它返回true,在这个回调中就没有必要做任何事情了,因为结果回调已经通过SendGoalOptions对象注册了。

在结果回调中,我们通过result.code获取目标的最终状态。然后我们可以将其与rclcpp_action::ResultCode中的不同代码进行比较,这些代码是SUCCEEDEDABORTEDCANCELED。要获取实际结果,我们写入result.result。这将是一个指向结果对象的共享指针。

最后,别忘了在 main() 函数中调用 sendGoal() 方法:

auto node = std::make_shared<CountUntilClientNode>();
node->sendGoal(5, 0.5);
rclcpp::spin(node);

对于 C++ 动作客户端来说,这就结束了。在编写了客户端和服务器之后,为两者创建可执行文件(在 CMakeLists.txt 中);然后,构建、源和运行这两个节点。你甚至可以尝试用 C++ 服务器运行 Python 客户端,或者任何其他组合。

现在客户端和服务器都运行正确后,我们可以添加通过动作获得的额外功能:反馈和取消。

利用所有的动作机制

我现在谈论反馈和取消机制,而不是之前,是为了尽量不让你一次处理太多代码。我知道动作比之前在 ROS 2 中看到的任何东西都要复杂。仅最小代码就已经相当长,并且包含许多你必须注意的小细节。

此外,正如本章第一部分所解释的,反馈和取消机制是可选的。你可以创建一个无需它们的完整工作的客户端/服务器通信。

我们现在将改进最小代码并添加更多功能,以便充分利用 ROS 2 动作。以下是你可以为此部分准备文件的步骤:

  1. 复制包含 _minimal 的文件。

  2. 通过删除 _minimal 重命名这些新文件。

例如,你可以复制 count_until_client_minimal.py(我们不会再修改此文件)并将其重命名为 count_until_client.py(我们将在此处添加更多代码)。你可以在本书的 GitHub 仓库中找到相同的组织结构。

因此,让我们探索反馈和取消机制,从最简单的反馈开始。

添加反馈机制

当我们编写动作接口时,我们必须定义三件事:目标、结果和反馈。到目前为止,我们只使用了目标和结果。反馈是可选的,你可以在动作定义中将其留空。在这种情况下,就没有其他事情要做了。

由于我们在 CountUntil.action 中定义了反馈(int64 current_number),让我们在我们的代码中使用它,以便服务器每次增加计数器时都发送反馈。动作客户端将能够在回调中接收此反馈。

使用 Python 的反馈

让我们从动作服务器开始。只需添加几行代码,我们就可以发布反馈。

打开 count_until_server.py。在 execute_callback() 方法中,在创建结果对象的同时,创建一个反馈对象:

feedback = CountUntil.Feedback()

现在,当你执行目标时,你必须做以下事情:

feedback.current_number = counter
goal_handle.publish_feedback(feedback)

我们必须填写反馈对象的各个字段,然后使用目标句柄的 publish_feedback() 方法将反馈发送给客户端。

对于服务器端来说,这就全部完成了。现在,让我们编写接收反馈的代码。

打开 count_until_client.py 文件,并修改使用 send_goal_async() 发送目标的行:

self.count_until_client_.send_goal_async(
    goal, feedback_callback=self.goal_feedback_callback). \
    add_done_callback(self.goal_response_callback)

要使用 Python 动作客户端获取反馈,你必须在你发送目标时注册一个回调函数。以下是这个回调的实现:

def goal_feedback_callback(self, feedback_msg):
   number = feedback_msg.feedback.current_number
   self.get_logger().info("Got feedback: " + str(number))

通过这种方式,我们得到一个反馈消息,可以访问该消息的每个字段。你可以用这个反馈做任何你想做的事情。例如,如果你的动作客户端要求机器人移动到特定的(x,y)坐标,你可能会收到关于机器人当前进度的反馈。从这个反馈中,你可以采取任何适当的措施:取消目标(参见下一节)、发送新的目标等等。

关于反馈就到这里。你可以再次构建你的包,源码化它,并运行两个节点。以下是客户端将看到的内容:

$ ros2 run my_py_pkg count_until_client
[count_until_client]: Goal got accepted
[count_until_client]: Got feedback: 1

它将继续如下:

[count_until_client]: Got feedback: 5
[count_until_client]: Success
[count_until_client]: Result: 5

通过这个反馈,客户端不再处于黑暗中。它可以了解在发送目标与接收结果之间的发生的事情。

C++中的反馈

count_until_server.cpp中添加动作服务器反馈的行为与 Python 相同。

首先,你必须在执行回调中创建一个反馈对象:

auto result = std::make_shared<CountUntil::Result>();

唯一的不同之处在于我们在这里使用共享指针。

然后,你必须发布反馈:

feedback->current_number = counter;
goal_handle->publish_feedback(feedback);

在客户端,注册回调的方式略有不同。打开count_until_client.cpp,并将以下行添加到sendGoal()方法中:

options.feedback_callback = std::bind(
    &CountUntilClientNode::goalFeedbackCallback, this, _1, _2);

对于 C++动作,我们在传递给async_send_goal()方法的SendGoalOptions对象中注册所有回调。

然后,你可以实现回调:

void goalFeedbackCallback(const CountUntilGoalHandle::SharedPtr &goal_handle, const std::shared_ptr<const CountUntil::Feedback> feedback)
{
   (void)goal_handle;
   int number = feedback->current_number;
   RCLCPP_INFO(this->get_logger(), "Got feedback: %d", number);
}

在这里,我们接收目标句柄和反馈(作为const共享指针)。

注意

正如你所见,每当在函数中不使用一个参数时,我都会写(void),然后是参数。这是在用colcon build编译时防止得到未使用参数警告的一种方法。作为一个最佳实践,在开发 ROS 2 应用程序时,你应该解决代码中的所有错误和警告。如果你不这样做,你最终会有很多被忽略的警告,你可能会错过重要的警告,导致未来难以调试的问题。

现在代码已经完成,你可以编译包,并在两个不同的终端中运行客户端和服务器节点。你应该看到与 Python 类似的输出。

实现反馈机制相对简单。现在,让我们学习如何取消一个目标。这将更加复杂,需要使用更多高级的 ROS 2 概念。

添加取消机制

在发送一个目标后,客户端可以决定要求服务器取消它。服务器将接收这个请求并接受(或拒绝)取消目标。如果取消请求被接受,服务器将采取任何适当的行动来取消目标的执行。最后,服务器仍然会向客户端发送一个结果。

我们需要在代码中做什么?在服务器节点中,我们将添加另一个回调,以便我们可以接收取消请求并决定接受或拒绝它们。然后,在执行回调中,我们将能够检查目标是否应该被取消;如果是这样,我们将提前终止执行。

然而,如果我们只是这样做,这是不会起作用的,取消请求也永远不会被接收。为什么是这样?让我们现在来探讨这个问题。

注意

本节介绍了一些超出本书(入门级)范围的概念。我将简要地谈论它们,而不会深入细节。如果你想更深入地了解这些概念,请自由探索高级概念(你将在第十四章中找到额外的资源)。你可以将这一节视为关于动作的进一步探讨部分

理解取消和旋转的问题

我们将只关注服务器端,因为问题将在这里发生。我将解释问题所在,以便我们可以在稍后实施解决方案。

因此,当你启动动作服务器时,将注册三个回调:一个目标回调、一个取消回调和一个执行回调。

使用我们当前的代码,当服务器接收到一个目标时,以下是会发生的事情:

  1. 目标由目标回调接收并被接受或拒绝。

  2. 如果被接受,我们在执行回调中执行目标。需要注意的是,当我们使用for循环执行目标时,线程被阻塞。

  3. 一旦目标执行完成,我们就返回结果并从执行回调中退出。

问题出在第二步。由于我们阻塞了执行,我们阻塞了旋转机制。

当你使一个节点旋转时,会发生什么?如前所述,节点将被保持活跃,所有回调都可以被处理。然而,旋转是在单个线程中工作的。这意味着如果你有一个回调需要 5 秒钟来执行,它将阻塞接下来的回调 5 秒钟。

我们之前从未遇到过任何问题,因为我们编写的所有回调都非常快速地执行。然而,对于动作的执行回调,我们处于执行可能需要相当长时间的情况,因此会阻塞所有其他回调。

这确实是个问题。如果取消请求仅在目标执行完成后才被接收,你如何请求取消目标?

为了解决这个问题,我们有两个可能的解决方案:

  • 经典编程方式:我们可以在执行回调中创建一个新的线程。然后,回调可以退出,而目标在后台处理。这样,旋转继续,因此可以调用其他回调。

  • ROS 2 的方式:我们可以使用多线程执行器,这意味着我们的旋转机制将不在单个线程中工作,而是在多个线程中工作。因此,如果一个回调被阻塞,你仍然可以执行其他回调——包括取消回调。

由于我们想要遵循 ROS 2 原则以与其他开发者保持一致,我们将遵循 ROS 2 的方式,并使用多线程执行器来解决该问题。

注意

我不会在这里详细介绍单线程和多线程执行器,我现在使用它们是为了正确实现取消机制。阅读完这本书后,执行器可以成为一个很好的探索主题。

服务器代码中取消机制的过程对于 Python 和 C++将是相同的:

  1. 注册一个回调来处理取消请求。

  2. 在执行回调中取消目标。

  3. 使用多线程执行器使节点旋转。

使用 Python 取消

我们将从服务器代码开始,该代码可以在count_until_server.py中找到。

首先,让我们注册一个回调来接收取消请求:

ActionServer(
    …
    cancel_callback=self.cancel_callback,
    …)

这是回调的实现:

def cancel_callback(self, goal_handle: ServerGoalHandle):
    self.get_logger().info("Received a cancel request")
    return CancelResponse.ACCEPT

在这个回调中,你将接收到一个与客户端想要取消的目标相对应的目标处理程序。然后你可以创建任何类型的条件来决定目标是否应该被取消。为了接受,你必须返回CancelResponse.ACCEPT;为了拒绝,你必须返回CancelResponse.REJECT。在这个例子中,我保持了简单,我们只是接受了取消请求而没有实现任何其他检查。

现在,如果取消请求已被接受,我们需要对此做出回应。在执行回调中,当我们正在执行目标(在for循环内部)时,添加以下代码:

if goal_handle.is_cancel_requested:
    self.get_logger().info("Canceling goal")
    goal_handle.canceled()
    result.reached_number = counter
    return result

当我们接受取消请求时,目标处理程序中的is_cancel_requested标志将被设置为True。现在,在执行回调中,我们只需要检查这个标志。

我们在代码中执行的操作是停止当前执行。例如,如果你的动作服务器控制机器人的轮子,你可以将取消解释为“减速并停止移动”,“走到一边以免阻塞主道”,或者甚至“返回基地”。你处理取消行为的方式取决于每个应用程序。在这里,我们只是停止计数。

在执行回调中,你需要设置目标的最终状态并返回一个结果,即使你取消了目标。因此,我们使用canceled()方法来设置状态,并返回包含最后达到的数字的结果。如果客户端要求服务器数到 10 然后当计数器在 7 时取消目标,结果将包含 7。

取消机制就到这里。然而,为了使事情正常工作,正如我们之前看到的,我们需要使用多线程执行器。

首先,你需要导入以下内容:

from rclpy.executors import MultiThreadedExecutor
from rclpy.callback_groups import ReentrantCallbackGroup

当使用多线程执行器时,我们还需要使用回调组。在这里,ReentrantCallbackGroup将允许所有回调并行执行。这意味着你可以为同一个动作服务器同时运行多个目标、取消和执行回调。

当你创建动作服务器时,添加一个callback_group参数:

ActionServer(
    …
    callback_group=ReentrantCallbackGroup())

最后,修改main()函数中使节点旋转的行:

rclpy.spin(node, MultiThreadedExecutor())

就这些了。这只是几行代码,但添加这些需要很好地理解 ROS 2 及其底层机制。

让我们编写客户端的代码,以便我们可以发送取消正在执行的目标的请求。在 count_until_client.py 中添加一个取消目标的方法:

def cancel_goal(self):
   self.get_logger().info("Send a cancel goal request")
   self.goal_handle_.cancel_goal_async()

这里,我们使用在目标响应回调中保存的目标处理程序(self.goal_handle_: ClientGoalHandle = future.result())。从这个目标处理程序对象,我们可以访问一个 cancel_goal_async() 方法。

那么,我们在哪里取消目标?这可以在任何地方完成:从反馈回调、独立的订阅者回调等等。这取决于你的应用。

为了进行快速测试,我们随意决定如果反馈中的 current_number 字段大于或等于 2,则取消目标。这没有任何意义(为什么我们要数到 5,然后当数字达到 2 时取消?),但这是一种快速测试取消机制的方法。

在目标反馈回调中添加以下代码:

if number >= 2:
    self.cancel_goal()

然后,构建包,源代码,并运行服务器和客户端。以下是客户端的日志:

[count_until_client]: Goal got accepted
[count_until_client]: Got feedback: 1
[count_until_client]: Got feedback: 2
[count_until_client]: Send a cancel goal request
[count_until_client]: Canceled
[count_until_client]: Result: 2

对于服务器,你会看到以下内容:

[count_until_server]: Executing the goal
[count_until_server]: 1
[count_until_server]: 2
[count_until_server]: Received a cancel request
[count_until_server]: Canceling goal

使用这些机制,我们现在可以取消从反馈回调中取消目标的行——这只是为了测试目的。

使用 C++ 取消

在服务器代码(count_until_server.cpp)中,我们在创建动作服务器时添加了一个取消回调。这是强制性的,以便代码可以编译。

在这个回调中,我们只是接受取消请求:

return rclcpp_action::CancelResponse::ACCEPT;

然后,为了在执行回调中处理目标的取消,将以下代码添加到 for 循环中:

if (goal_handle->is_canceling()) {
    RCLCPP_INFO(this->get_logger(), "Canceling goal");
    result->reached_number = counter;
    goal_handle->canceled(result);
    return;
}

在 C++ 中,我们在目标处理程序内部检查 is_canceling() 方法。如果它返回 true,这意味着已经接受了对该目标的取消请求,我们需要对此采取行动。

我们使用 canceled() 设置目标的最终状态和结果,并从执行回调中退出。

取消机制就到这里了,但现在我们需要使用多线程执行器使节点旋转。

main() 函数中,我们必须将 rclcpp::spin(node); 行替换为以下代码:

rclcpp::executors::MultiThreadedExecutor executor;
executor.add_node(node);
executor.spin();

这里,我们创建一个执行器,添加节点,并使执行器旋转。然后,就像我们对 Python 所做的那样,在节点内部,我们需要添加一个回调组。我们可以将其声明为一个私有属性:

rclcpp::CallbackGroup::SharedPtr cb_group_;

最后,我们修改节点构造函数中的代码,给动作服务器提供一个可重入的回调组,这样所有回调都可以并行执行:

cb_group_ = this->create_callback_group(
    rclcpp::CallbackGroupType::Reentrant);
count_until_server_ = rclcpp_action::create_server<CountUntil>(
    …
    rcl_action_server_get_default_options(),
    cb_group_
);

我们还需要在回调之后和回调组之前添加 rcl_action_server_get_default_options();否则,编译器会抱怨找不到 create_server() 函数的重载。

现在我们已经完成了服务器代码的编写,让我们从客户端发送取消请求。在 count_until_client.cpp 中添加一个 cancelGoal() 方法:

void cancelGoal()
{
    RCLCPP_INFO(this->get_logger(), "Send a cancel goal request");
    count_until_client_->async_cancel_all_goals();
}

在 C++ 中,我们从动作客户端而不是从目标处理程序取消目标。为了使事情更简单,我们在这里取消所有可能由该客户端发送的目标。

要测试取消机制,我们将这些行添加到反馈回调中:

if (number >= 2) {
    cancelGoal();
}

代码编写完成后,运行您的 C++ 行动客户端和服务器节点。您也可以尝试任何 Python 和 C++ 节点的组合;它们应该表现相同。测试完代码后,注释掉这些行以取消反馈回调中的目标。

让我们以一些将帮助您在开发使用动作的应用程序时使用的更多命令行工具来结束这一章。

处理动作的附加工具

由于动作是 ROS 2 核心功能的一部分,它们也有自己的命令行工具:ros2 action

在本节中,我们将学习如何检查动作、从终端发送目标以及在实际运行时更改动作名称。

要查看所有可能的命令,键入 ros2 action -h

列出和检查动作

动作基于主题和服务。由于 rqt_graph 目前不支持服务,我们可以看到动作服务器和客户端的主题,但仅此而已。因此,rqt_graph 在检查动作时不会非常有用。因此,我们将在这里使用 ros2 命令行工具。

让我们学习如何查找现有动作以及如何获取一个动作的接口。

停止所有节点并启动 count_until_server 节点(Python 或 C++)。然后,运行以下命令列出所有可用的动作:

$ ros2 action list
/count_until

在这里,我们找到了 /count_until 动作。正如我们通过主题和服务所看到的,如果您没有为名称提供任何命名空间(我们在服务器代码中写了 count_until),将自动添加一个前导斜杠。

从这个动作名称,我们可以获取更多信息,包括动作接口。

运行 ros2 action info <****action_name> -t

$ ros2 action info /count_until -t
Action: /count_until
Action clients: 0
Action servers: 1
/count_until_server [my_robot_interfaces/action/CountUntil]

从这个结果中,我们可以看到动作服务器托管在 count_until_server 节点中,我们还找到了动作接口。为了使 ros2 action info 显示接口,别忘了添加 -t;否则,您只会看到节点的名称。

最后,我们可以获取接口:

$ ros2 interface show my_robot_interfaces/action/CountUntil
# Here you should see the action definition

这个过程与我们对服务的处理过程相同。现在我们知道动作名称和接口,我们可以直接从终端尝试服务。

从终端发送目标

如果您编写了一个服务服务器并想在编写动作客户端之前尝试它,您可以使用 ros2 action send_goal 命令行。

完整的命令是 ros2 action send_goal <action_name> <action_interface> "<goal_in_json>"。您也可以在命令后添加 --feedback 以接收来自服务器的(可选)反馈。让我们试试看:

$ ros2 action send_goal /count_until my_robot_interfaces/action/CountUntil "{target_number: 3, delay: 0.4}" --feedback

您将得到以下结果:

Waiting for an action server to become available...
Sending goal:
 target_number: 3
delay: 0.4
Goal accepted with ID: cad1aa41829d42c5bb1bf73dd4d66600
Feedback:
current_number: 1
Feedback:
current_number: 2
Feedback:
current_number: 3
Result:
reached_number: 3
Goal finished with status: SUCCEEDED

这个命令对于开发动作服务器非常有用。然而,它只适用于目标简单的动作。在这里,我们只有一个整数和一个双精度数。如果目标包含一个由 20 个 3D 点组成的数组,你将花费更多的时间来正确编写命令,而不是实现动作客户端。在这种情况下,为了更快地完成,可以使用本章中编写的动作客户端作为模板。

动作内部的话题和服务

默认情况下,使用 ros2 topic listros2 service list,你不会看到动作内部的两个话题和三个服务。然而,它们确实存在——你只需要分别添加 --include-hidden-topics--include-hidden-services

$ ros2 topic list --include-hidden-topics
/count_until/_action/feedback
/count_until/_action/status
...
$ ros2 service list --include-hidden-services
/count_until/_action/cancel_goal
/count_until/_action/get_result
/count_until/_action/send_goal
...

通过这样,我们已经找到了正在使用的话题和服务。你可以通过使用其他 ros2 topicros2 service 命令行来更深入地探索这些内容。

现在,我们已经为节点、话题和服务做了一件事:我们在运行时更改了名称。由于某种原因,这个特性目前还不能用于动作。作为替代方案,你仍然可以在启动动作服务器时重命名两个话题和三个服务:

$ ros2 run my_cpp_pkg count_until_server --ros-args \
    -r /count_until/_action/feedback:=/count_until1/_action/feedback \
    -r /count_until/_action/status:=/count_until1/_action/status \
    -r /count_until/_action/cancel_goal:=/count_until1/_action/cancel_goal \
    -r /count_until/_action/get_result:=/count_until1/_action/get_result \
    -r /count_until/_action/send_goal:=/count_until1/_action/send_goal

这样,动作将被重命名为 /count_until1。命令有点丑陋且容易出错,但当我们使用第九章 配置启动文件内的节点 中的启动文件启动节点时,这不会成为问题。

通过这样,我们来到了本章的结尾。我没有在这里添加任何挑战,因为我认为本章本身就是一个很大的挑战。我更愿意你花时间继续学习这本书中的其他概念,而不是长时间地停留在动作上,尤其是如果你是刚开始使用 ROS。

概述

在本章中,你学习了 ROS 2 动作。你创建了各种动作来解决服务处理不好的问题:当服务器可能需要一些时间来执行请求时。

使用动作,你可以正确地处理这种情况。在目标执行过程中,你可以从服务器获取一些反馈,甚至可以决定取消目标。此外,你还可以同时处理多个目标,排队,用一个替换另一个,等等(我们在这章中没有看到这一点,但如果你想要进一步提高你的技能,你可以深入研究)。

你可以使用 Python 的 rclpy.action 库和 C++ 的 rclcpp_action 库在你的代码中实现动作服务器和客户端。

这里是编写动作服务器的主要步骤:

  1. 由于我们在服务器端,我们必须选择动作名称和接口。通常,对于动作,你将不得不创建一个自定义接口(在一个专门的包中)。

  2. 然后,你必须将接口导入到你的代码中,并在构造函数中创建一个动作服务器。在这里,你将注册三个回调方法:

    • 目标回调:当服务器收到一个目标时,选择是否接受或拒绝它。

    • 执行回调:在目标被接受后,您可以执行它。在执行目标的过程中,您还可以发布可选的反馈。

    • 取消回调(可选机制):如果您收到取消请求,您可以接受或拒绝它。如果接受,您将不得不取消当前目标执行。

要编写动作客户端,您必须遵循以下步骤:

  1. 找出您需要使用的名称和接口,以便您可以与服务器通信。

  2. 将接口导入到您的代码中,并在构造函数中创建一个动作客户端。

  3. 添加一个发送目标的方法。在您发送目标后,您将不得不编写几个回调:

    • 目标响应回调:您将知道服务器是否接受或拒绝了目标。

    • 目标结果回调:在服务器执行目标后,您将在这里获得结果和目标最终状态。

    • 反馈回调(可选):如果服务器发布任何反馈,您可以在这里接收它。

  4. 最后,您可以从代码的任何位置决定取消当前活动目标的执行。

在所有这些之上,使用 ros2 action 命令行,您可以检查您的动作并直接从终端发送目标。此外,由于动作基于主题和服务,您可以使用 ros2 topicros2 service 分别检查每个底层通信。

现在,如果您在第一次阅读这本书时就已经成功到达这里,恭喜您——这一章可能是最难理解的章节之一。如果您还在想我一直在说什么,请不要担心——您可以在完成这本书并更熟悉 ROS 后,稍后再回来学习动作。

现在我们已经完成了 ROS 2 中的三种通信类型。在下一章中,我们将回到更基础的级别,并继续处理节点。这次,我们将学习如何在启动节点时自定义它们,以便使我们的应用程序更加动态。

第八章:参数 – 使节点更加动态

我们现在已经完成了 ROS 2 通信的基础。在本章中,我们将继续在节点上工作,但这次是通过使用参数使它们更加动态。

为了理解参数,我将从为什么一开始就需要它们开始。然后,你将学习如何将参数添加到你的节点中,以便你可以在运行时自定义它们。你还将看到如何使用YAML文件一次性加载多个参数,以及如何使用参数回调允许在代码中修改参数。

作为起点,我们将使用书中 GitHub 仓库的ch7文件夹中的代码(github.com/PacktPublishing/ROS-2-from-Scratch)。如果你跳过了动作(第七章),你也可以从ch6文件夹开始,它将起到相同的作用。本章的最终代码将在ch8文件夹中。

到本章结束时,你将能够将参数添加到你的任何节点中,并处理你启动的其他节点的参数。

参数的概念并不太难,代码中也不会有太多要做。然而,这是一个重要的概念,是使你的应用程序更加动态和可扩展的第一步。

本章我们将涵盖以下主题:

  • 什么是 ROS 2 参数?

  • 在你的节点中使用参数

  • 将参数存储在 YAML 文件中

  • 处理参数的附加工具

  • 使用参数回调更新参数

  • 参数挑战

什么是 ROS 2 参数?

你已经在第三章中尝试过一些参数实验,其中你运行了一个具有不同设置的节点。

我现在将从头开始再次解释参数,并使用一个现实生活中的例子。

为什么需要参数?

让我们从一个问题开始,以理解参数的需求。我将使用一个相机驱动程序作为例子——我们不会编写节点;这只是用于解释。

这个相机驱动程序连接到 USB 相机,读取图像,并在 ROS 2 主题上发布。这是任何 ROS 2 硬件驱动程序的典型行为。

在这个节点内部,你将有一些用于不同设置的变量。以下是一些示例:

  • USB 设备名称

  • 每秒帧数(FPS)

  • 模拟模式

假设你正在工作的相机连接到/dev/ttyUSB0端口(Linux 上典型的 USB 端口名称)。你想要设置60 FPS 并且不使用模拟模式(false)。这些就是你要在节点内部写入的变量值。

之后,如果 USB 设备名称不同(例如,/dev/ttyUSB1),你将不得不在代码中更改该设置,也许需要重新构建——如果你想要以30 FPS 而不是60 FPS 启动相机,或者想要在模拟模式下运行它,你将做同样的事情。

那么,如果你有两个摄像头,并且你想同时使用它们呢?你会为每个摄像头复制代码吗?你如何处理两个摄像头的不同设置?

如您所见,在代码中硬编码这些设置并不是一个很好的重用选项。这就是为什么我们有 ROS 2 参数。

带有参数的节点示例

ROS 2 参数基本上是一个节点设置,你可以在启动节点时修改它。

因此,如果我们保持摄像头驱动程序的示例,我们可以添加三个参数——USB 设备名称(字符串)、帧率值(整数)和模拟模式(布尔值):

图 8.1 – 具有三个参数的节点类

图 8.1 – 具有三个参数的节点类

当你使用ros2 run(我们将在本章后面看到如何操作)启动这个摄像头驱动程序时,你将能够为这三个参数提供你想要的值。

假设你想为两个不同的摄像头启动两个节点,以下是一些设置:

  1. 端口:/dev/ttyUSB0;帧率:30;模拟模式:关闭

  2. 端口:/dev/ttyUSB1;帧率:60;模拟模式:关闭

在代码中添加的参数使我们能够以不同的值多次启动相同的节点:

图 8.2 – 使用不同设置启动两个节点

图 8.2 – 使用不同设置启动两个节点

从相同的代码中,我们启动了两个不同的节点。在运行时,我们重命名了节点(因为我们不能有两个同名节点),并提供了参数的值。

我们的两个摄像头节点现在正在运行,每个节点都有不同的配置。你可以停止一个摄像头节点,然后使用不同的值重新启动它。

ROS 2 参数 – 总结

使用参数,你可以重用相同的代码并使用不同的设置启动多个节点。无需再次编译或构建任何东西;你只需在运行时提供参数的值即可。

使你的节点可定制可以提供更大的灵活性和重用性。你的应用程序将变得更加动态。

参数也非常方便与其他 ROS 开发者协作。如果你开发了一个可以被其他人重用的节点,那么通过参数,你可以允许其他开发者完全定制节点,甚至无需查看代码。这也适用于使用现有节点。许多节点可以在运行时进行配置。

关于参数的几个重要点:

  • 就像变量一样,参数有一个名称和数据类型。在最常见的类型中,你可以使用布尔值、整数、浮点数、字符串以及这些类型的列表。

  • 参数的值是针对特定节点的。如果你杀死节点,该值也会随之消失。

  • 当你使用ros2 run(我们将在下一章中看到如何操作)启动一个节点时,你可以设置每个参数的值。

现在,如何将参数添加到您的代码中?对于节点、主题和服务,您将从rclpyrclcpp库中获得您所需的一切。您将能够在您的代码中声明参数并获取每个参数的值。

在您的节点中使用参数

我们将继续使用前几章中编写的代码。在这里,我们将改进number_publisher节点。作为一个快速回顾,这个节点会在一个主题上以给定的速率发布一个数字。数字和发布速率直接在代码中写出。

现在,我们不再将数字和发布速率值硬编码,而是将使用参数。这样,我们就可以在启动节点时指定要发布的数字和发布频率或周期。

您需要遵循两个步骤才能在您的代码中使用参数:

  1. 在节点中声明参数。这将使参数存在于节点中,这样您就可以在启动节点时使用ros2 run来设置其值。

  2. 获取参数的值,以便您可以在代码中使用它。

让我们从 Python 开始,稍后我们还将看到 C++代码。

使用 Python 声明、获取和使用参数

在使用参数之前,我们需要声明它。我们应该在哪里声明参数?我们将在节点的构造函数中这样做,在所有其他操作之前。要声明参数,请使用Node类的declare_parameter()方法。

您将提供两个参数:

  • 参数名称:这是您将在运行时设置参数值的名称

  • 默认值:如果运行时没有提供参数值,将使用此值

实际上,声明参数有不同的方式。如果您提供了参数类型,您不一定需要提供默认值。然而,我们将保持这种方式,因为它可能会使您的生活更加轻松。为每个参数添加默认值是一个最佳实践。

打开number_publisher.py文件,让我们在构造函数中声明两个参数:

self.declare_parameter("number", 2)
self.declare_parameter("publish_period", 1.0)

参数由一个名称和一个数据类型定义。在这里,您选择名称,数据类型将根据您提供的默认值自动设置。在这个例子中,number的默认值是2,这意味着参数的数据类型是整数。对于publish_period参数,默认值是1.0,这是一个浮点数。

这里有一些不同数据类型的示例:

  • self.declare_parameter("simulation_mode", False)

  • self.declare_parameter("device_name", "/dev/ttyUSB0")

  • self.declare_parameter("numbers", [4, 5, 6])

现在,声明一个参数意味着它在节点内部存在,并且您可以从外部设置一个值。然而,在您的代码中,为了能够使用该参数,仅仅声明它是远远不够的。在这样做之后,您还需要获取该值。

对于这一点,你需要使用 get_parameter() 方法,并将参数的名称作为参数提供。然后,你可以使用 value 属性来访问其值:

self.number_ = self.get_parameter("number").value
self.timer_period_ = self.get_parameter(
    "publish_period"
).value

在代码的这个位置,number_ 变量(它是一个类属性)包含在运行时使用 ros2 run 设置的 number 参数的值。

注意

在获取参数值之前,你总是需要声明一个参数。如果你没有这样做,当启动节点时,一旦尝试获取值,你将立即收到一个异常(ParameterNotDeclaredException)。

在获取所有参数的值并将它们存储在变量或类属性中之后,你可以在你的代码中使用它们。在这里,我们修改了计时器回调:

self.number_timer_ = self.create_timer(
    self.timer_period_, self.publish_number
)

通过这种方式,我们根据参数的值设置了发布周期。

代码部分就到这里。如你所见,并没有什么太复杂的。对于一个参数,你只需要添加两条指令:一条用于声明参数(给它一个名字和一个默认值),另一条用于获取其值。

现在,我一直在谈论如何使用 ros2 run 在运行时设置参数的值。我们该如何做呢?

在运行时提供参数

在继续之前,请确保保存 number_publisher.py 文件并构建 my_py_pkg 包(如果你之前没有使用过 --****symlink-install)。

要使用 ros2 run 命令提供参数的值,请按照以下步骤操作:

  1. 你首先使用 ros2 run <``package_name> <exec_name> 启动你的节点。

  2. 然后,要在该命令之后添加任何参数,你必须写 --ros-args(只写一次)。

  3. 要指定参数的值,请写 -p <param_name>:=<param_value>。你可以添加任意多的参数。

假设我们想要每 0.5 秒发布数字 3,在这种情况下,我们将运行以下命令:

$ ros2 run my_py_pkg number_publisher --ros-args -p number:=3 -p publish_period:=0.5

为了验证它是否工作,我们可以订阅 /****number 主题:

$ ros2 topic echo /number
data: 3
---
data: 3
---

我们还可以验证发布速率:

$ ros2 topic hz /number
average rate: 2.000
    min: 0.500s max: 0.500s std dev: 0.00004s window: 3

那么,发生了什么?你在运行时为不同的参数提供了一些值。节点将启动并识别这些参数,因为它们的名称与代码中声明的名称相匹配。然后,节点可以获取每个参数的值。

如果你为参数提供了错误的数据类型,你将收到一个错误。如前所述,数据类型是在代码中从默认值设置的。在这个例子中,number 参数应该是一个整数。看看如果我们尝试设置一个双精度值会发生什么:

$ ros2 run my_py_pkg number_publisher --ros-args -p number:=3.14
…
    raise InvalidParameterTypeException(
rclpy.exceptions.InvalidParameterTypeException: Trying to set parameter 'number' to '3.14' of type 'DOUBLE', expecting type 'INTEGER': number
[ros2run]: Process exited with failure 1

如你所见,一旦在代码中设置了参数类型,你必须在提供运行时值时始终使用完全相同的类型。

由于每个参数都有一个默认值,你也可以省略一个或多个参数:

$ ros2 run my_py_pkg number_publisher --ros-args -p number:=3

在这种情况下,publish_period 参数将被设置为代码中定义的默认值(1.0)。

为了结束这里,让我们看看一个例子,其中重命名节点和设置参数值可以让你从相同的代码中运行多个不同的节点,而无需修改代码中的任何内容。

在终端 1 中运行以下命令:

$ ros2 run my_py_pkg number_publisher --ros-args -r __node:=num_pub1 -p number:=3 -p publish_period:=0.5

在终端 2 中运行以下命令:

$ ros2 run my_py_pkg number_publisher --ros-args -r __node:=num_pub2 -p number:=4 -p publish_period:=1.0

这样,你就有两个节点(num_pub1num_pub2),它们都发布到 /number 主题,但数据不同且发布速率不同。通过这个示例,你可以看到参数是使你的节点更加动态的绝佳方式。

让我们现在用参数的 C++代码来完成这个部分。

C++中的参数

参数对于 Python 和 C++的工作方式相同;只是语法不同。在这里,我们将修改 number_publisher.cpp 文件。

在构造函数中,你可以声明一些参数:

this->declare_parameter("number", 2);
this->declare_parameter("publish_period", 1.0);

我们使用 rclcpp::Node 类的 declare_parameter() 方法。参数与 Python 中的相同:名称和默认值。从这个值中,参数类型将被设置。

然后,要在代码中获取一个参数的值,请编写以下内容:

number_ = this->get_parameter("number").as_int();
double timer_period = this->get_parameter("publish_period")
                            .as_double();

我们使用 get_parameter() 方法并提供参数的名称。然后,我们使用对应于数据类型的方法获取值:as_int()as_double()as_string()as_string_array() 等。如果你有一个具有自动完成的 IDE,你应该能够看到所有可能的数据类型。

其余部分与 Python 相同。请参考 GitHub 文件以了解任何其他细微的更改和添加。

要带参数启动节点,请运行以下命令:

$ ros2 run my_cpp_pkg number_publisher --ros-args -p number:=4 -p publish_period:=1.2

与参数一起工作并不那么困难。对于你想要创建的每个参数,你必须在代码中声明它并获取其值。当从终端启动节点时,你可以为每个参数指定一个值。

现在,这只有在参数数量较少的情况下才有效。在实际应用程序中,一个节点有几十个甚至几百个参数并不罕见。你如何管理这么多参数?

将参数存储在 YAML 文件中

随着你的 ROS 2 应用程序的增长,参数的数量也会增加。从命令行添加 10 个或更多的参数已经不再是可行的选项。

幸运的是,你可以使用 YAML 文件来存储你的参数,并且你可以在运行时加载这些文件。如果你不了解 YAML,它基本上是一种标记语言,类似于 XML 和 JSON,但据说比人类更容易阅读。

在本节中,你将学习如何将你的参数添加到 YAML 文件中,以及如何在运行时加载此文件。

从 YAML 文件加载参数

让我们先保存参数到一个文件中,这样我们就可以在启动节点时使用它们。

首先,创建一个具有 .yaml 扩展名的 YAML 文件。文件名并不那么重要,但最好给它一个有意义的名字。由于我们的应用程序处理数字,我们可以将其命名为 number_params.yaml

现在,让我们在我们的主目录中创建一个新文件(在下一章中,我们将看到如何在 ROS 2 应用程序中正确安装 YAML 文件):

$ cd ~
$ touch number_params.yaml

编辑此文件并添加 /****number_publisher 节点的参数:

/number_publisher:
  ros__parameters:
    number: 7
    publish_period: 0.8

首先,你写下节点的名称。在下一行,并使用缩进(通常建议使用两个空格),我们添加 ros__parameters(确保你使用两个下划线)。这将是 YAML 文件中每个节点的相同设置。在接下来的几行中,并使用更多的缩进,你可以添加节点的所有参数值。

注意

确保节点名称匹配;否则,参数不会被加载到节点中。如果你省略了前面的斜杠,使用 ros2 run 加载参数仍然可以工作,但可能会与其他命令出现问题。

一旦你编写了这个文件,你可以使用 --params-file 参数来加载参数:

$ ros2 run my_py_pkg number_publisher --ros-args --params-file ~/number_params.yaml

这将启动节点并指定 numberpublish_period 参数的值。

如果你有两个或五十个参数,ros2 run 命令保持不变。你所要做的就是向 YAML 文件中添加更多参数。如果你想修改一个参数,你可以修改文件中的对应行,甚至为不同的配置集创建几个文件。

多个节点的参数

如果你想为多个节点保存参数,你应该怎么做?

好消息:在一个 param YAML 文件中,你可以为任意多个节点添加配置。以下是一个示例:

/num_pub1:
  ros__parameters:
    number: 3
    publish_period: 0.5
/num_pub2:
  ros__parameters:
    number: 4
    publish_period: 1.0

这对应于我们之前运行过的示例,有两个节点和不同的参数。

现在,为了启动相同的节点和参数,我们只需要运行下面的命令。

在终端 1 中,运行以下命令:

$ ros2 run my_py_pkg number_publisher --ros-args -r __node:=num_pub1 --params-file ~/number_params.yaml

在终端 2 中,运行以下命令:

$ ros2 run my_py_pkg number_publisher --ros-args -r __node:=num_pub2 --params-file ~/number_params.yaml

我们将相同的 YAML 文件给两个节点。每个节点将只加载在节点名称下定义的参数值。

回顾所有参数的数据类型

假设你已经在你的代码中声明了所有这些参数(仅以 Python 为例,但你可以轻松地将其转换为 C++):

self.declare_parameter("bool_value", False)
self.declare_parameter("int_number", 1)
self.declare_parameter("float_number", 0.0)
self.declare_parameter("str_text", "Hola")
self.declare_parameter("int_array", [1, 2, 3])
self.declare_parameter("float_array", [3.14, 1.2])
self.declare_parameter("str_array", ["default", "values"])
self.declare_parameter("bytes_array", [0x03, 0xA1])

这些基本上是参数的所有可用数据类型。

要指定每个参数的值,你可以创建一个 YAML 文件或在现有的 YAML 文件中添加一些配置。以下是为你这个节点(命名为 your_node)编写的代码:

/your_node:
  ros__parameters:
     bool_value: True
     int_number: 5
     float_number: 3.14
     str_text: "Hello"
     bool_array: [True, False, True]
     int_array: [10, 11, 12, 13]
     float_array: [7.5, 400.4]
     str_array: ['Nice', 'more', 'params']
     bytes_array: [0x01, 0xF1, 0xA2]

使用 YAML 文件,你可以快速高效地自定义节点。我建议在参数超过几个时立即使用它们。

此外,随着你继续使用 ROS 2,你将开始使用其他开发者开发的节点和完整的堆栈。这些节点通常附带一些 YAML 文件,允许你配置堆栈而无需直接更改节点中的任何内容。

现在我们继续介绍命令行工具。你已经使用 ros2 run 设置了参数的值,但实际上还有更多工具可以处理参数。

处理参数的附加工具

你开始习惯了:对于每个 ROS 2 核心概念,我们都有一个专门的 ros2 命令行工具。对于参数,我们有 ros2 param

您可以使用 ros2 param -h 查看所有命令。让我们关注最重要的命令,以便我们可以从终端获取参数值,并在节点启动后设置一些值。在本节的最后,我们还将探索所有节点可用的不同参数服务。

从终端获取参数值

在您启动了一个或多个节点后,您可以使用 ros2 param list 列出所有可用的参数。

停止所有节点并启动两个节点,num_pub1num_pub2,可以通过使用 YAML 文件或手动提供参数值来实现。

在终端 1 中运行以下命令:

$ ros2 run my_py_pkg number_publisher --ros-args -r __node:=num_pub1 -p number:=3 -p publish_period:=0.5

在终端 2 中运行以下命令:

$ ros2 run my_py_pkg number_publisher --ros-args -r __node:=num_pub2 -p number:=4 -p publish_period:=1.0

现在,列出所有可用的参数:

$ ros2 param list
/num_pub1:
  number
  publish_period
  start_type_description_service
  use_sim_time
/num_pub2:
  number
  publish_period
  start_type_description_service
  use_sim_time

在这里,我启动了两个节点以向您展示每个节点都有自己的参数集。/num_pub1 中的 number 参数与 /num_pub2 中的 number 参数不同。

注意

对于每个参数,我们也会始终获取一个 use_sim_time 参数,默认值为 false。这意味着我们使用系统时钟。如果我们正在模拟机器人,我们会将其设置为 true,以便我们可以使用模拟引擎时钟。这对现在来说并不重要,您可以忽略此参数。您也可以忽略 start_type_description_service 参数。

从此,您可以使用 ros2 param get <节点名> <参数名> 获取一个特定参数的值:

$ ros2 param get /num_pub1 number
Integer value is: 3
$ ros2 param get /num_pub2 number
Integer value is: 4

这对应于我们在启动节点时设置的值。使用 ros2 param get 允许您检查任何运行节点内部的参数。

将参数导出到 YAML

如果您想获取一个节点的完整参数集,可以使用 ros2 param dump <节点名> 来实现。

让我们列出我们正在运行的节点上的所有参数。

对于第一个节点,运行以下命令:

$ ros2 param dump /num_pub1
/num_pub1:
  ros__parameters:
    number: 3
    publish_period: 0.5
    start_type_description_service: true
    use_sim_time: false

对于第二个节点,运行以下命令:

$ ros2 param dump /num_pub2
/num_pub2:
  ros__parameters:
    number: 4
    publish_period: 1.0
    start_type_description_service: true
    use_sim_time: false

如您所见,输出结果正是您需要写入 YAML 文件中的内容。您可以将终端中获取的内容复制粘贴,创建自己的 YAML 文件以供稍后加载(无需设置 use_sim_timestart_type_description_service)。

这个 ros2 param dump 命令可以用于一次性获取所有参数值,并快速构建一个参数 YAML 文件。

从终端设置参数值

参数实际上并不是在节点的整个生命周期中固定不变的。在您使用 ros2 run 初始化参数值后,您可以从终端修改它。

以我们的相机驱动程序为例,假设您断开并重新连接相机。在 Linux 上,设备名称可能会改变。如果它是 /dev/ttyUSB0,现在它可能是 /dev/ttyUSB1。您可以通过为设备名称参数设置不同的值来停止并重新启动节点,但使用 ros2 param set 命令,您也可以在节点仍在运行时直接更改值。

为了向您展示它是如何工作的,让我们回到我们的数字应用。

停止所有节点并启动一个number_publisher节点(在这里,我不提供任何参数;我们将使用默认值):

$ ros2 run my_py_pkg number_publisher

让我们验证number参数的值:

$ ros2 param get /number_publisher number
Integer value is: 2

要从终端修改参数,您必须运行ros2 param set <node_name> <param_name> <new_value>,如下面的示例所示:

$ ros2 param set /number_publisher number 3
Set parameter successful

当然,请确保为参数提供正确的数据类型;否则,您将得到一个错误。您还可以使用ros2 param load <node_name> <yaml_file>直接加载 YAML 文件,这样您可以同时设置多个参数:

$ ros2 param load /number_publisher ~/number_params.yaml
Set parameter number successful
Set parameter publish_period successful

修改参数后,我们再次检查参数的值:

$ ros2 param get /number_publisher number
Integer value is: 7

如您所见,值已成功更改。然而,这真的有效吗?代码中使用了新参数的值吗?

让我们验证我们是否正在发布正确的数字:

$ ros2 topic echo /number
data: 2
---

即使我们已经更改了参数的值,新的值也没有在代码内部更新。为了做到这一点,我们需要添加一个参数回调。这就是我们将在下一分钟看到的内容,但现在,让我们通过额外存在的允许您管理参数的服务来完成这个部分。

参数服务

如果您还记得,当我们处理服务时,您看到对于每个节点,我们得到了一组额外的七个服务,其中大多数与参数相关。

列出number_publisher节点的所有服务:

$ ros2 service list
/number_publisher/describe_parameters
/number_publisher/get_parameter_types
/number_publisher/get_parameters
/number_publisher/get_type_description
/number_publisher/list_parameters
/number_publisher/set_parameters
/number_publisher/set_parameters_atomically

使用这些服务,您可以列出参数,获取它们的值,甚至设置新值。这些服务基本上提供了与ros2 param命令行工具相同的函数。

这是一个好消息,因为从终端获取和设置参数在真实应用中并不实用且可扩展。通过使用这些服务,您可以在节点 A 中创建一个服务客户端,该客户端将获取或修改节点 B 中的参数。

我不会深入探讨这个问题;您可以自己尝试使用您在第六章中看到的内容。这里我们通过修改number参数来做一个小例子。首先,让我们检查您需要使用哪个接口:

$ ros2 service type /number_publisher/set_parameters
rcl_interfaces/srv/SetParameters

然后,您可以使用ros2 interface show获取更多详细信息。最后,您可以在节点内部创建一个服务客户端来修改一个参数。让我们从终端这样做:

$ ros2 service call /number_publisher/set_parameters rcl_interfaces/srv/SetParameters "{parameters: [{name: 'number', value: {type: 2, integer_value: 3}}]}"

这与运行ros2 param set /number_publisher number 3相同。服务的好处是您可以在任何其他节点中使用它,使用来自rclpyrclcpp的服务客户端。

如果您想知道服务请求中的type: 2是什么意思,这里列出了您可以使用参数服务获取或设置的 所有类型:

$ ros2 interface show rcl_interfaces/msg/ParameterType
uint8 PARAMETER_NOT_SET=0
uint8 PARAMETER_BOOL=1
uint8 PARAMETER_INTEGER=2
uint8 PARAMETER_DOUBLE=3
uint8 PARAMETER_STRING=4
uint8 PARAMETER_BYTE_ARRAY=5
uint8 PARAMETER_BOOL_ARRAY=6
uint8 PARAMETER_INTEGER_ARRAY=7
uint8 PARAMETER_DOUBLE_ARRAY=8
uint8 PARAMETER_STRING_ARRAY=9

因此,数字2对应于PARAMETER_INTEGER类型。

现在您已经看到了如何在节点运行时设置参数的值,让我们继续讨论参数回调。到目前为止的问题是我们修改参数时,值没有到达代码。

使用参数回调更新参数

当节点启动时参数的值被设置后,你可以从终端或使用服务客户端进行修改。然而,为了在代码中接收新的值,你需要添加一个称为参数回调的东西。

在本节中,你将学习如何为 Python 和 C++ 实现参数回调。这个回调将在参数的值被更改时触发,我们将在代码中获取新的值。

注意

你不一定需要在你的节点中添加参数回调。对于某些参数,你希望在启动节点时有一个固定值,并且不再修改这个值。只有当在节点执行期间修改某些参数有意义时,才使用参数回调。

参数回调是改变节点设置的好方法,而不必再创建另一个服务。让我用一个相机驱动程序的例子来解释这一点。如果你想能够在节点运行时更改设备名称,默认的方法将是服务。你将在你的节点中创建一个服务服务器,接受更改设备名称的请求。然而,为节点中的每个小设置这样做可能会很麻烦。使用参数,你不仅可以在运行时提供不同的设备名称,还可以通过使用每个 ROS 2 节点已经具有的参数服务来稍后修改它。没有必要让它比这更复杂。

现在,让我们看看如何解决我们设置number参数新值时遇到的问题,让我们从 Python 开始。

实际上可以实施几种参数回调,但为了保持简单,我将只使用其中之一。参数回调是一个很好且有用的功能,但当你刚开始使用 ROS 2 时,它可能不是最重要的。因此,在这里你将获得功能概述,并在完成本书后自行进行更多研究(你将在第十四章中找到额外资源)。

Python 参数回调

让我们编写我们的第一个 Python 参数回调。

打开number_publisher.py文件,并在节点构造函数中注册一个参数回调:

self.add_post_set_parameters_callback(self.parameters_callback)

我们还添加了一条新的导入语句:

from rclpy.parameter import Parameter

然后,我们实现回调方法:

def parameters_callback(self, params: list[Parameter]):
    for param in params:
        if param.name == "number":
            self.number_ = param.value

在这个回调中,你将收到一个Parameter对象的列表。对于每个参数,你可以访问其名称、值和类型。使用for循环,我们遍历我们得到的每个参数,并在代码中设置相应的值。你也可以决定验证值(例如,只接受正数),但在这里我将不会这样做,以保持代码最小化。

为了进行快速测试,再次运行number_publisher节点(未指定参数;将使用默认值)。在另一个终端中,订阅/****number主题:

$ ros2 topic echo /number
data: 2
---

现在,更改参数的值:

$ ros2 param set /number_publisher number 3
Set parameter successful

让我们现在回到另一个终端去观察变化:

$ ros2 topic echo /number
data: 3
---

参数的值已经更改,我们通过参数回调在代码中收到了这个值。

C++参数回调

C++中参数回调的行为与 Python 中完全相同。让我们看看语法。打开number_publisher.cpp文件,并在构造函数中注册参数回调:

param_callback_handle_ = this->add_post_set_parameters_callback(
    std::bind(&NumberPublisherNode::parametersCallback, this, _1));

这里是回调的实现:

void parametersCallback(
    const std::vector<rclcpp::Parameter> & parameters)
{
    for (const auto &param: parameters) {
        if (param.get_name() == "number") {
            number_ = param.as_int();
        }
    }
}

我们得到了一个rclcpp::Parameter对象的列表。从这个列表中,我们可以使用get_name()方法检查每个参数的名称。如果参数的名称匹配,我们就获取值。由于我们在这里接收的是整数,我们使用as_int()方法。对于字符串,你会使用as_string()方法,等等。请参考 GitHub 文件以获取完整的代码。

你现在已经看到了参数回调的基础。你并不一定需要在所有节点中添加它们。如果你需要在节点启动后修改参数的值,它们是非常有用的。

让我们以一个额外的挑战结束这一章,以便让你更多地练习使用参数。

参数挑战

通过这个挑战,你将练习这一章中看到的所有内容:在代码中声明和获取参数,在运行时提供参数的值,并将值保存在 YAML 文件中。我们将跳过参数回调,但如果你也想练习这些,请随意添加。

对于挑战,我首先会解释挑战的内容,然后提供 Python 解决方案。你可以在书的 GitHub 仓库中找到 Python 和 C++的完整代码。

挑战

我们将继续改进turtle_controller节点。对于这个挑战,我们希望在运行时能够选择不同的设置:

  • 右侧的笔颜色

  • 左侧的笔颜色

  • 要在cmd_vel主题上发布的速度

为了做到这一点,你需要添加以下参数:

  • color_1:当海龟位于右侧时,我们不再随意选择颜色,而是将颜色重命名为color_1,并从参数中获取值。这个参数将是一个包含三个值(红色绿色蓝色)的整数列表。

  • color_2:与color_1相同,这是海龟位于屏幕左侧时使用的颜色。

  • turtle_velocity:默认情况下,我们为发送到cmd_vel主题的速度使用了1.02.0。我们将其作为一个参数,以便在运行时提供速度。我们将使用turtle_velocityturtle_velocity * 2.0代替1.02.0

为了测试这个节点,你需要使用ros2 run启动turtle_controller节点,并为参数提供不同的值。你应该通过观察海龟移动的速度和笔的颜色来检查它是否工作。如果需要,可以在代码中添加一些日志来查看发生了什么。

作为这个挑战的最后一步,你可以将所有参数放入一个 YAML 文件中,并在运行时加载这个 YAML 文件。

解决方案

让我们先声明我们将需要用于这个挑战的参数。

打开 turtle_controller.py 文件。让我们在节点构造函数的开始处声明一些参数:

self.declare_parameter("color_1", [255, 0, 0])
self.declare_parameter("color_2", [0, 255, 0])
self.declare_parameter("turtle_velocity", 1.0)

我们提供了与之前硬编码的相同值的默认值。因此,如果我们不提供任何参数就启动节点,行为将与之前相同。

声明参数后,我们可以获取它们的值:

self.color_1_ = self.get_parameter("color_1").value
self.color_2_ = self.get_parameter("color_2").value
self.turtle_velocity_ = self.get_parameter("turtle_velocity").value

我们将值存储在类属性中,以便我们可以在代码的后续部分重用它们。

注意

作为提醒,在 Python 中,别忘了在 get_parameter() 后面添加 .value(不带任何括号)。这是一个常见的错误,当启动节点时会导致异常。

然后,我们在 callback_pose() 方法中修改了几行:

if pose.x < 5.5:
    cmd.linear.x = self.turtle_velocity_
    cmd.angular.z = self.turtle_velocity_
else:
    cmd.linear.x = self.turtle_velocity_ * 2.0
    cmd.angular.z = self.turtle_velocity_ * 2.0
self.cmd_vel_pub_.publish(cmd)

我们不是使用硬编码的速率值,而是使用我们从参数中获取的值。

然后,我们设置笔的颜色:

if pose.x > 5.5 and self.previous_x_ <= 5.5:
    self.previous_x_ = pose.x
    self.get_logger().info("Set color 1.")
    self.call_set_pen(
        self.color_1_[0],
        self.color_1_[1],
        self.color_1_[2]
    )
elif pose.x <= 5.5 and self.previous_x_ > 5.5:
    self.previous_x_ = pose.x
    self.get_logger().info("Set color 2.")
    self.call_set_pen(
        self.color_2_[0],
        self.color_2_[1],
        self.color_2_[2]
    )

在这里,我们修改了日志,使其更有意义,因为颜色可以是任何东西。

最后,有几种不同的方法可以将整数数组传递给 call_set_pen() 方法。你可以修改 call_set_pen(),使其接收一个包含三个整数的数组并从中提取每个数字。或者,像我这里做的那样,你不需要修改该方法,只需确保传递正确的参数即可。

代码现在已完成。要测试它,在一个终端中启动 turtlesim 节点,在另一个终端中启动 turtle_controller 节点。你可以为参数提供不同的值。例如,如果我们想速度为 1.5,颜色为黑白,我们运行以下命令:

$ ros2 run turtle_controller turtle_controller --ros-args -p color_1:=[0,0,0] -p color_2:=[255,255,255] -p turtle_velocity:=1.5

你也可以将这些参数保存在一个 YAML 文件中。创建一个新的 YAML 文件(例如,在你的家目录中),命名为 turtle_params.yaml。在这个文件中,写入以下内容:

/turtle_controller:
  ros__parameters:
    color_1: [0, 0, 0]
    color_2: [255, 255, 255]
    turtle_velocity: 1.5

然后,你可以直接使用 YAML 文件启动 turtle 控制器节点:

$ ros2 run turtle_controller turtle_controller --ros-args --params-file ~/turtle_params.yaml

这个挑战就到这里。最后,对于每个参数,我们做了三件事:我们声明了它,获取了它的值,并在代码中使用了它。这并不复杂,如果你只是知道如何做,你将能够成功地处理你未来 ROS 2 应用程序中的参数。

摘要

在这一章中,你处理了参数。参数允许你在运行时为你的节点提供设置。因此,使用相同的代码,你可以启动具有不同配置的几个不同的节点。这大大增加了代码的可重用性。

要处理节点中的参数,请遵循以下指南:

  1. 声明参数,使其在节点内存在。最佳实践是设置一个默认值。这个值也将设置参数的类型。

  2. 获取参数的值并将其存储在你的节点中——例如,在一个私有属性中。

  3. 在你的代码中使用这个值。

然后,当你使用 ros2 run 启动一个节点时,你可以指定任何你想要的参数值。

您还可以将参数组织在一个 YAML 文件中,当您开始拥有超过几个参数时,这将变得非常方便。您将在启动节点时加载该 YAML 文件。

最后,您还可以决定在启动节点后允许修改参数。为此,您需要实现参数回调。

参数使您的节点变得更加动态。在您运行的几乎每一个节点中,您都会拥有参数。使用它们可以更容易地通过加载不同的配置集来扩展您的应用程序。

说到扩展,在下一章中,我们将深入探讨启动文件。使用启动文件,您可以同时启动多个节点和参数。当您的应用程序开始增长时,这将非常有帮助。

第九章:启动文件 – 同时启动所有节点

到目前为止,你知道如何编写节点,如何使它们通过主题、服务和动作进行通信,以及如何通过参数使它们更加动态。

在本节 第二部分 的最后一章中,我们将把所有内容整合起来,并进一步使你的应用程序更具可扩展性。在这里,我们将讨论启动文件,它允许你一次性启动所有节点和参数。

要开始使用启动文件,重要的是你对前几章中看到的概念感到舒适。作为一个起点,我们将使用书中 GitHub 仓库(github.com/PacktPublishing/ROS-2-from-Scratch)中的 ch8 文件夹内的代码。你可以在 ch9 文件夹中找到启动文件的最终代码。

首先,像往常一样,我将使用一个真实世界的例子来解释为什么你需要启动文件以及它们究竟是什么。然后,你将深入代码,使用 XML 和 Python 创建你自己的启动文件(我们将讨论哪种语言更合适)。你还将通过额外的配置来完全自定义启动文件内的节点,并通过最后的挑战进行更多的练习。

到本章结束时,你将能够正确地扩展你的 ROS 2 应用程序,并知道如何使用或修改现有的启动文件。几乎每个 ROS 2 应用程序或堆栈都包含一个或多个启动文件。对这些文件感到舒适是成为一名优秀的 ROS 开发者的关键。

在本章中,我们将涵盖以下主题:

  • ROS 2 启动文件是什么?

  • 创建和安装 XML 启动文件

  • 创建一个 Python 启动文件 – 启动文件使用 XML 还是 Python?

  • 在启动文件中配置节点

  • 启动文件挑战

ROS 2 启动文件是什么?

在你已经学到的所有内容的基础上,理解启动文件的概念不会非常困难。

你已经在 第三章 中对启动文件进行了一些实验。现在,我们将像往常一样从头开始,通过一个示例来了解启动文件是什么。首先,我们将探讨为什么我们需要启动文件。

为什么需要启动文件?

随着你的 ROS 2 应用程序开始增长,节点和参数的数量也在增加。例如,我为一个机械臂开发的 ROS 栈有超过 15 个节点和 200 个参数。想象一下打开 15 个终端,并逐个启动所有节点,同时为每个参数设置正确的值。这很快就会变成一场噩梦。

为了解释这一点,让我们假设在我们的应用程序中有以下节点:

  • 具有不同设置的三个相机节点

  • 具有不同数量 LED 的两个 LED 面板节点

  • 一个电池节点

  • 另一个具有更多参数的节点

这就是你的应用程序看起来会是什么样子:

图 9.1 – 具有七个节点和十六个参数的 ROS 2 应用

图 9.1 – 具有七个节点和十六个参数的 ROS 2 应用

要启动所有这些节点,您需要打开七个终端并逐个启动节点。对于每个节点,您还需要提供所有必需参数的值(根据您在上一章中看到的,您可以使用 YAML 参数文件来简化这个过程)。这不仅不可扩展,而且会使您的开发过程变得缓慢且令人沮丧。有这么多终端,很容易出错或忘记哪个终端在做什么。

您可以考虑的一个解决方案是创建一个脚本(例如 bash 脚本)来从一个文件中启动所有ros2 run命令。这样,您就可以从一个终端运行您的应用程序。这将减少开发时间,并允许您的应用程序扩展。

嗯,这正是启动文件的作用所在。您不需要编写自己的脚本;您需要做的只是创建一个启动文件并遵循一些语法规则。启动文件可以安装到您的 ROS 2 应用程序中。让我们在下一节中看看一个例子。

七节点启动文件的示例

如果我们继续我们的例子,以下是您的节点组织方式:

图 9.2 – 包含所有节点和参数的启动文件

图 9.2 – 包含所有节点和参数的启动文件

在一个文件中,您启动所有节点并为每个参数提供您想要的值。这个文件可以用 XML、YAML 或 Python 编写——我们稍后会看到如何做到这一点。然后,一旦编写了启动文件,您将使用colcon build安装它,并使用ros2 launch命令行工具运行它。

在一个应用程序中拥有几十个节点和几百个参数并不罕见。没有启动文件,您将无法快速启动应用程序,您将花费大部分时间调试琐碎的事情。

启动文件允许您轻松地自定义和扩展您的应用程序。没有太多可说的;这个概念相当直接。大部分工作都是关于学习如何实现一个,以及了解如何自定义节点以使它们更加动态的功能。这正是我们现在要深入探讨的。

创建和安装 XML 启动文件

现在,您将创建您的第一个启动文件。我们将从 XML 开始。在本章的后面部分,我们还将编写 Python 启动文件,并比较这两种语言,但为了便于入门,让我们保持简单。

要正确创建、安装和启动一个启动文件,您需要进行一些设置。在本节中,我们将使用一个最小启动文件遵循所有必要的设置步骤。

我们在这里想做的事情是从一个终端启动数字应用程序(number_publishernumber_counter节点),只使用一条命令行。让我们开始吧。

为启动文件设置包

您应该把启动文件放在哪里?从理论上讲,您可以在任何现有包中创建启动文件。

然而,这种方法可能会迅速导致包之间的依赖混乱。如果包 A 需要包 B,而你又在包 B 中创建了一个启动文件来启动这两个包的节点,那么你就创建了一个所谓的依赖循环。包 A 依赖于包 B,而包 B 又依赖于包 A。这是启动 ROS 应用程序的一个非常糟糕的方式。

作为最佳实践,我们将创建一个专门用于启动文件的包。我们不会修改任何现有的包;相反,我们将创建一个完全独立的包。

首先,让我们为这个包选择一个名字。我们将遵循一个常见的命名约定。我们以机器人或应用程序的名字开头,后面跟着_bringup后缀。由于我们这里没有机器人,我们将把这个包命名为my_robot_bringup。如果你的机器人名字是abc,你会创建一个abc_bringup包。

导航到你的 ROS 2 工作空间中的src目录并创建这个包。它将不包含任何 Python 或 C++节点。对于构建类型,你可以选择ament_cmake(你也可以省略构建类型,因为ament_cmake已经是默认的):

$ cd ~/ros2_ws/src/
$ ros2 pkg create my_robot_bringup --build-type ament_cmake

或者,你也可以直接运行$ ros2 pkg create my_robot_bringup

一旦创建了包,我们可以删除我们不需要的目录:

$ cd my_robot_bringup/
$ rm -r include/ src/

然后,我们创建一个launch目录。这是我们将会放置所有这个应用程序的启动文件的地方:

$ mkdir launch

在我们创建启动文件之前,让我们完成包配置。打开CMakeLists.txt文件并添加以下行:

find_package(ament_cmake REQUIRED)
install(DIRECTORY
  launch
  DESTINATION share/${PROJECT_NAME}/
)
ament_package()

当你使用colcon build构建你的包时,这将会安装launch目录。

现在,包已经正确配置。你只需要为每个 ROS 2 应用程序执行这些步骤一次。然后,要添加启动文件,你只需在launch文件夹内创建一个新文件。让我们这么做。

编写 XML 启动文件

导航到你在my_robot_bringup包内创建的launch文件夹。要创建一个启动文件,你首先选择一个名字,然后使用.launch.xml扩展名。由于我们给我们的应用程序命名为number app,让我们创建一个名为number_app.launch.xml的新文件:

$ cd ~/ros2_ws/src/my_robot_bringup/launch/
$ touch number_app.launch.xml

打开文件,让我们开始为启动文件编写内容。

首先,你需要打开和关闭一个标签。你写的所有内容都将在这两条线之间。这是 XML 启动文件的最小代码:

<launch>
</launch>

然后,我们想要启动number_publishernumber_counter节点。

作为快速提醒,在终端中,你会运行这个:

$ ros2 run my_py_pkg number_publisher
$ ros2 run my_cpp_pkg number_counter

在这里,我从 Python 包和一个 C++包中分别启动了一个节点。我们需要为ros2 run提供的两个参数是包名和可执行文件名。在启动文件中也是同样的。要添加一个节点,使用带有pkgexec参数的标签:

<launch>
    <node pkg="my_py_pkg" exec="number_publisher"/>
    <node pkg="my_cpp_pkg" exec="number_counter"/>
</launch>

通过这种方式,我们从启动文件中启动了相同的两个节点。正如你所看到的,这并没有什么特别复杂的。在本章的后面部分,我们将看到如何通过重映射、参数、命名空间等来配置应用程序。现在,让我们专注于运行这个最小化的启动文件。

安装和启动启动文件

在开始使用之前,你必须安装你的新启动文件。

由于我们是从my_py_pkgmy_cpp_pkg包中启动节点,我们需要在my_robot_bringup包的package.xml文件中添加依赖项:

<exec_depend>my_py_pkg</exec_depend>
<exec_depend>my_cpp_pkg</exec_depend>

注意

之前,我们只在指定依赖项时使用过<depend>标签。在这种情况下,没有需要构建的内容;我们只需要在执行启动文件时需要依赖项。因此,我们使用一个较弱的标签,<exec_depend>

对于你在启动文件中使用的每个新包,你需要在package.xml文件中添加一个新的<exec_depend>标签。

现在,我们可以安装启动文件。要这样做,你只需要构建你的包:

$ cd ~/ros2_ws/
$ colcon build --packages-select my_robot_bringup

然后,源你的环境,并使用ros2 launch命令行工具来启动启动文件。完整的命令是ros2 launch <package_name> <launch_file_name>

$ ros2 launch my_robot_bringup number_app.launch.xml

你将看到以下日志:

[INFO] [launch]: All log files can be found below /home/user/.ros/log/...
[INFO] [launch]: Default logging verbosity is set to INFO
[INFO] [number_publisher-1]: process started with pid [21108]
[INFO] [number_counter-2]: process started with pid [21110]
[number_counter-2] [INFO] [1716293867.204728817] [number_counter]: Number Counter has been started.
[number_publisher-1] [INFO] [1716293867.424510088] [number_publisher]: Number publisher has been started.
[number_counter-2] [INFO] [1716293868.413350769] [number_counter]: Counter: 2
[number_counter-2] [INFO] [1716293869.413321220] [number_counter]: Counter: 4
[number_counter-2] [INFO] [1716293870.413321491] [number_counter]: Counter: 6

这里发生了什么?让我们仔细看看:

  1. 创建了一个日志文件,并设置了日志详细程度。

  2. 启动文件中提供的每个可执行文件都将作为一个新的进程启动。你可以看到进程名称(例如,number_publisher-1)和进程 ID(表示为pid)。

  3. 然后,由于所有节点都在同一个终端中启动,你会看到所有节点的所有日志。

这个例子相当简单,因为我们只是启动了两个没有额外配置的可执行文件。当节点数量和设置增多时,启动文件将变得非常有用。此外,ros2 launch命令行工具非常容易使用。实际上,这里没有比我们看到的更多的内容。

现在你已经完成了创建、安装和启动启动文件的过程,让我们来谈谈 Python 启动文件。

创建 Python 启动文件 – XML 还是 Python 用于启动文件?

实际上,你可以使用三种语言在 ROS 2 中创建启动文件:Python、XML 和 YAML。我不会介绍 YAML 启动文件,因为它们很少使用,而且 YAML 在启动文件方面没有比 XML 更强的优势。在这里,我们将重点关注 Python 和 XML。

我们将从这个部分开始,创建一个 Python 启动文件(与之前相同的应用程序)。然后,我会比较 XML 和 Python 启动文件,并给你一些关于如何充分利用两者的指导。

编写 Python 启动文件

由于我们已经有了一个完全配置好的my_robot_bringup包用于我们的应用程序,因此不需要做任何事情。我们只需要在launch目录内创建一个新文件。

对于 Python 启动文件,你将使用 .launch.py 扩展名。创建一个名为 number_app.launch.py 的新文件。以下是启动 number_publishernumber_counter 节点的代码:

from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
    ld = LaunchDescription()
    number_publisher = Node(
        package="my_py_pkg",
        executable="number_publisher"
    )
    number_counter = Node(
        package="my_cpp_pkg",
        executable="number_counter"
    )
    ld.add_action(number_publisher)
    ld.add_action(number_counter)
    return ld

你首先会注意到,代码比 XML 长得多。当我比较 Python 和 XML 时,我会在一会儿回来讨论这个问题。现在,让我们专注于编写 Python 启动文件所需的步骤:

  1. 启动文件必须包含一个 generate_launch_description() 函数。确保你没有打错字。

  2. 在这个函数中,你需要创建并返回一个 LaunchDescription 对象。你可以从 launch 模块中获取它。

  3. 要在启动文件中添加一个节点,你创建一个 Node 对象(来自 launch_ros.actions),并指定包和可执行文件名。然后,你可以将此对象添加到 LaunchDescription 对象中。

到此为止,但还有更多选项,我们将在本章稍后探讨。

一旦你编写了启动文件,请确保在 my_robot_bringup 包的 package.xml 文件中添加所有必需的依赖项。因为我们已经用 XML 启动文件做了这件事(并且我们有相同的依赖项),所以我们可以跳过这一步。

最后,为了安装这个启动文件,重新构建 my_robot_bringup 包。由于我们已经在 CMakeLists.txt 文件中编写了必要的指令,启动文件将被安装。之后你需要做的就是设置你的环境,并使用 ros2 launch 命令启动启动文件:

$ ros2 launch my_robot_bringup number_app.launch.py

要创建、安装并启动一个 Python 启动文件,其过程与 XML 启动文件相同。只是代码不同。现在让我们比较这两种语言在启动文件中的应用。

启动文件中的 XML 与 Python

我非常倾向于简洁,所以,从看到之前的代码示例,你就可以猜到我的立场了。

要回答 XML 与 Python 的选择问题,我们首先回顾一下过去。

Python 启动文件的问题

在 ROS 1 的第一个版本中,XML 是启动文件使用的唯一语言。Python 实际上也是可用的,但由于没有文档,没有人知道它。

在 ROS 2 的早期,开发团队对 Python 启动文件给予了更多的重视,并开始只为 Python 编写文档,因此使其成为启动文件的默认语言。XML(和 YAML)启动文件也得到支持,但由于没有文档,没有人使用它们。

我最初对编写 Python 启动文件的想法很热情,因为这意味着你可以利用 Python 的逻辑和语法使启动文件更加动态和易于编写。这是理论,但在实践中,我发现我在大多数找到的启动文件中都没有看到任何编程逻辑,它只是另一种——更复杂和困难——编写描述的方法,而这正是 XML 存在的根本原因。

你已经可以看到在前两个例子中增加的复杂性。要启动两个节点,XML 需要 4 行,Python 需要 20 行(我可以优化代码并使其少于 15 行,但这仍然很多)。对于相同数量的节点,你可以预期 Python 启动文件比 XML 版本长两到五倍。

此外,随着更多功能(参数、终端参数、条件、路径等)的增加,你将不得不使用越来越多的 Python 导入,这些导入难以找到和使用。当你看到这本书中更多的 XML 和 Python 启动文件示例时,你会意识到这一点。

幸运的是,XML 正在回归,因为官方文档开始包括它以及 Python。越来越多的开发者又开始使用 XML 启动文件,这是一个好事,因为更多的在线教程和开源代码将包括它们。

如何在你的应用程序中结合 XML 和 Python 启动文件

XML 启动文件比 Python 启动文件简单得多,也小得多。然而,对于某些高级用例,Python 将是唯一的选择,因为它包含一些 XML 中不可用的功能。这可能会成为一个问题,因为如果你只需要一个 Python 功能,这意味着你需要用 Python 编写整个启动文件。

幸运的是,有一个非常简单的方法可以解决这个问题。正如我们将在下一分钟看到的,你可以将任何类型的启动文件包含到任何其他启动文件中,无论是 Python、XML 还是 YAML。

因此,如果你绝对需要为特定的启动功能使用 Python,那么就创建一个 Python 启动文件。然后,你可以将此启动文件包含在你的 XML 启动文件中。你也可以包含任何其他现有的 Python 启动文件(来自已安装的包),它包含你需要的功能。通过这样做,你可以保持你的代码最小化和简单。

现在,当你需要为特定用例创建一个 Python 启动文件时,该怎么办?语法非常复杂,任何功能都有太多的导入。这很快就会成为一个挑战。

当我需要创建一个 Python 启动文件时,我会尝试在 GitHub 上找到一个现有的启动文件,它做我想做的事情,并调整代码使其与我的应用程序兼容。我已经放弃尝试学习或甚至记住 Python 启动文件的语法。我通常不是“从互联网复制粘贴”方法的粉丝,但我会为 Python 启动文件破例。

最后,这完全取决于你的选择。一个正确编写的 XML、YAML 或 Python 启动文件将完成完全相同的事情。至于 YAML,它只是另一种标记语言,我发现 XML 对于启动文件来说更容易使用。我的建议是在可能的情况下使用 XML。只有在必须使用 Python 并且仅用于需要 Python 的功能时才使用 Python。然后,将 Python 启动文件包含在你的 XML 启动文件中。

按照此过程操作将使您在开发 ROS 2 应用程序时更加轻松。

在另一个启动文件中包含启动文件

由于我提到了在 XML 启动文件中包含 Python 启动文件,让我们看看如何做到这一点。语法不会太复杂。

确保将标签内的所有内容都添加进去。要包含另一个启动文件,请使用标签。以下是一个示例:

<launch>
    <include file="$(find-pkg-share         my_robot_bringup)/launch/number_app.launch.py" />
</launch>

这一行,使用find-pkg-share,将找到位于my_robot_bringup包内的number_app.launch.py启动文件的路径。然后,将包含启动文件的内容。即使您在 XML 文件中包含 Python 启动文件,这也会起作用。

您可以将此行用于任何其他 XML 启动文件;只需替换包名和启动文件名即可。

现在,如果您想执行相反的操作(这意味着在 Python 启动文件中包含 XML 启动文件),以下是您需要编写的代码:

from launch import LaunchDescription
from launch.actions import IncludeLaunchDescription
from launch_xml.launch_description_sources import XMLLaunchDescriptionSource
import os
from ament_index_python import get_package_share_directory
def generate_launch_description():
    ld = LaunchDescription()
    other_launch_file = IncludeLaunchDescription(
     XMLLaunchDescriptionSource(os.path.join(
      get_package_share_directory('my_robot_bringup'),
                        'launch/number_app.launch.xml')))
    ld.add_action(other_launch_file)
    return ld

此代码示例说明了关于 Python 启动文件带来的额外复杂性的说法。这种复杂性在这里是不必要的,因为它与 XML 文件相比没有增加任何东西。

通过这两个代码示例,你现在可以结合任何 XML 和 Python 启动文件。

现在您已经看到了在 XML 和 Python 中创建启动文件的过程,让我们更进一步,为节点添加一些额外的配置。

在启动文件中配置节点

到目前为止,我们只启动了两个节点,没有额外的配置。当您使用ros2 run启动节点时,如我们在第二部分的上一章中看到的,您可以重命名它,重命名主题/服务/动作,添加参数等。

在本节中,您将学习如何在启动文件中执行此操作。我们还将介绍命名空间的概念。所有代码示例都将使用 XML 和 Python。

重命名节点和通信

在 XML 启动文件中,要重命名一个节点,只需在标签中添加一个name参数:

<node pkg="your_package" exec="your_exec" name="new_name" />

更改主题/服务/动作的名称实际上被称为重映射。要重映射通信,您必须在标签内使用标签:

<node pkg="your_package" exec="your_exec">
    <remap from="/topic1" to="/topic2" />
</node>

您可以添加任意多的标签,每个标签占一行。

注意

这是一个快速的 XML 提醒,但如果你不熟悉 XML,它可能很有用,可以防止未来出现很多错误。对于单行标签,您打开标签并以/>结束(例如,<node />)。如果您需要在标签内添加标签,那么您必须打开标签并在稍后关闭它,就像我们为<launch>...</launch><node>...</node>所做的那样。

从这个例子中,假设我们想要启动两个number_publisher节点和一个number_counter节点。在此基础上,我们还想将主题从number重命名为my_number。以下是完整的 XML 启动文件:

<launch>
    <node pkg="my_py_pkg" exec="number_publisher" name="num_pub1">
        <remap from="/number" to="/my_number" />
    </node>
    <node pkg="my_py_pkg" exec="number_publisher" name="num_pub2">
        <remap from="/number" to="/my_number" />
    </node>
    <node pkg="my_cpp_pkg" exec="number_counter">
        <remap from="/number" to="/my_number" />
    </node>
</launch>

我们将两个number_publisher节点重命名以避免名称冲突。然后,我们确保为所有使用number主题的发布者或订阅者的节点添加相同的标签。

额外提示

当你重命名节点和重新映射通信时,使用rqt_graph来验证一切是否正常工作。通过图形视图,你可以轻松地发现通信两边的主题名称是否不同。

这里是使用 Python 启动文件做同样事情的代码:

from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
    ld = LaunchDescription()
    number_publisher1 = Node(
        package="my_py_pkg",
        executable="number_publisher",
        name="num_pub1",
        remappings=[("/number", "/my_number")]
    )
    number_publisher2 = Node(
        package="my_py_pkg",
        executable="number_publisher",
        name="num_pub2",
        remappings=[("/number", "/my_number")]
    )
    number_counter = Node(
        package="my_cpp_pkg",
        executable="number_counter",
        remappings=[("/number", "/my_number")]
    )
    ld.add_action(number_publisher1)
    ld.add_action(number_publisher2)
    ld.add_action(number_counter)
    return ld

在重命名和重新映射之后,让我们看看如何在启动文件中为你的节点添加参数。

启动文件中的参数

在启动文件中为节点设置参数的值相当直接。我们将首先看看如何直接提供这些值,然后是如何加载一个 YAML 文件。

直接设置参数的值

要在 XML 启动文件中为节点添加参数的值,你首先需要打开和关闭标签。在这个标签内,你将为每个参数添加一个标签,带有两个参数:namevalue

这里有一个示例,我们为number_publisher节点设置了numberpublish_period参数:

<node pkg="my_py_pkg" exec="number_publisher">
    <param name="number" value="3" />
    <param name="publish_period" value="1.5" />
</node>

它将和在ros2 run命令后添加-p :=一样工作。

现在,你可以结合重命名、重新映射和设置参数。让我们向之前的示例添加参数:

<node pkg="my_py_pkg" exec="number_publisher" name="num_pub1">
    <remap from="/number" to="/my_number" />
    <param name="number" value="3" />
    <param name="publish_period" value="1.5" />
</node>

在 Python 启动文件中,你需要在Node对象中添加一个字典列表:

number_publisher1 = Node(
    package="my_py_pkg",
    executable="number_publisher",
    name="num_pub1",
    remappings=[("/number", "/my_number")],
    parameters=[
        {"number": 3},
        {"publish_period": 1.5}
    ]
)

如果你只有少量参数,像这样设置每个参数的值将工作得很好。对于更多的参数,使用 YAML 文件更合适。

注意

不要将 YAML 参数文件与 YAML 启动文件混淆。启动文件可以用 Python、XML 和 YAML 编写(尽管在这本书中我们没有使用 YAML)。任何这些启动文件都可以包含 YAML 参数文件,以添加启动文件中节点的参数值。

在启动文件中安装和加载 YAML 参数文件

要使用 YAML 文件提供参数值,你需要遵循以下过程:

  1. 创建一个包含值的 YAML 文件。

  2. 将此文件安装到_bringup包中。

  3. 在你的启动文件中加载 YAML 文件(我们将使用 XML 和 Python 来做这件事)。

对于这个示例,我们将重用我们在第八章中创建的number_params.yaml文件。在这个文件中,你可以找到以下代码:

/num_pub1:
  ros__parameters:
    number: 3
    publish_period: 0.5
/num_pub2:
  ros__parameters:
    number: 4
    publish_period: 1.0

这将完美匹配我们在上一个示例中启动的节点,因为名称完全相同。

现在,我们到目前为止所做的一切只是提供了在启动节点时使用ros2 run启动文件的路径。要使用启动文件内的 YAML 参数文件,我们需要在包中安装它。

要做到这一点,在my_robot_bringup包内创建一个新的目录。你可以为这个目录选择任何名字,但我们将遵循一个常见的约定,将其命名为config

$ cd ~/ros2_ws/src/my_robot_bringup/
$ mkdir config

number_params.yaml文件放入这个config目录中。这也是你将放置此应用程序的所有其他 YAML 参数文件的地方。

现在,为了编写安装此目录(以及其中所有的 YAML 文件)的指令,打开my_robot_bringup包的CMakeLists.txt文件并添加一行:

install(DIRECTORY
  launch
  config
  DESTINATION share/${PROJECT_NAME}/
)

你只需要做一次。在config目录中的任何其他文件在为该包运行colcon build时都会被安装。

在我们构建包之前,让我们修改启动文件,以便我们可以使用这个 YAML 参数文件。在 XML 中这样做很简单。你将添加一个标签,但与namevalue不同,你需要指定一个from参数:

<node pkg="my_py_pkg" exec="number_publisher" name="num_pub2">
    <remap from="/number" to="/my_number" />
    <param from="$(find-pkg-share             my_robot_bringup)/config/number_params.yaml" />
</node>

正如我们在本章前面看到的,$(find-pkg-share <package_name>)将定位该包的安装文件夹。然后,你只需要完成你想要检索的文件的相对路径。

为了测试这一点,首先构建你的包。这将安装 YAML 参数文件和启动文件。然后,设置你的环境并启动 XML 启动文件。

参数部分到此结束。现在,让我们看看 Python 版本。在你的启动文件中添加以下导入:

from ament_index_python.packages import get_package_share_directory
import os

然后,检索 YAML 文件:

param_config = os.path.join(
    get_package_share_directory("my_robot_bringup"),
    "config", "number_params.yaml")

最后,将配置加载到节点中:

number_publisher2 = Node(
    ...
    parameters=[param_config]
)

这样,你应该能够以任何数量的参数启动任何节点,而不会遇到任何缩放问题。

现在,让我们用命名空间结束这一节。我在这本书中简要提到了它们几次。由于你现在对 ROS 2 中名称的工作方式有了更好的理解,并且由于命名空间在启动文件中特别有用,现在是开始使用它们的好时机。

命名空间

命名空间在编程中相当常见,你可能已经对它们很熟悉了。使用命名空间,你可以将一些功能(变量、函数等)分组在一个具有名称的容器中。这可以帮助你更好地组织代码并避免名称冲突。

在 ROS 中,命名空间也非常实用。假设你想要启动一个包含两个相同机器人的应用程序,但你想要能够独立控制每个机器人。你不需要为每个机器人重命名节点、主题、服务和动作,你只需添加一个命名空间。

如果你有一个名为robot_controller的节点和一个名为cmd_vel的主题,那么对于第一个机器人,这些可以成为/robot1/robot_controller/robot1/cmd_vel。对于第二个机器人,这将变为/robot2/robot_controller/robot2/cmd_vel。这样,两个机器人仍然在同一个应用程序上运行,但你确保每个机器人的速度命令是独立的。

随着你对 ROS 2 的进步和学习新的堆栈和插件,你将在各个地方遇到命名空间。现在,让我们看看如何与命名空间一起工作。因为我们之前没有这样做,所以我们将首先使用ros2 run命令行使用命名空间,然后将其添加到我们的启动文件中。

在命名空间内启动节点

将命名空间添加到节点中相当简单。

首先,在ros2 run 命令之后,你只需添加一次--ros-args。然后,要指定一个命名空间,你将写-r __ns:=-r选项(或--remap)与重命名节点的选项相同,只是在这里你使用__ns而不是__node

让我们在/****abc命名空间内启动我们的number_publisher节点:

$ ros2 run my_py_pkg number_publisher --ros-args -r __ns:=/abc [INFO] [1716981935.646395625] [abc.number_publisher]: Number publisher has been started.

在此之后,你可以检查节点和主题名称:

$ ros2 node list
/abc/number_publisher
$ ros2 topic list
/abc/number
/parameter_events
/rosout

如你所见,/abc被添加到了节点名称中,同时也被添加到了主题名称中——如果你有服务和动作,命名空间将被同样应用。

重要提示

命名空间成功应用是因为代码中定义的主题名称是number,没有任何前导斜杠。如果你在代码中写了/number,那么这个主题将被认为是处于全局作用域或命名空间中。给节点添加命名空间将改变节点名称,但不会改变主题名称。因此,在定义代码中的通信(主题、服务、动作)名称时,请注意这一点。

现在,由于主题名称是/abc/number,如果我们想启动number_counter节点并接收一些数据,我们需要要么重命名主题,要么也给节点添加命名空间:

$ ros2 run my_cpp_pkg number_counter --ros-args -r __ns:=/abc
[abc.number_counter]: Number Counter has been started.
[abc.number_counter]: Counter: 2
[abc.number_counter]: Counter: 4

在添加命名空间时,名称不匹配可能成为频繁出现的问题。验证事物是否正常工作的最佳方法之一是运行rqt_graph

图 9.3 – 使用 rqt_graph 检查命名空间

图 9.3 – 使用 rqt_graph 检查命名空间

通过这个,你可以看到这两个节点都在发布或订阅/****abc/number主题。

注意

你可以组合任何类型的重命名。例如,你可以同时添加命名空间并重命名节点:$ ros2 run my_py_pkg number_publisher --ros-args -r __ns:=/abc -r __node:=num_pub

现在你已经知道了如何在运行时为节点提供命名空间,让我们看看如何在启动文件中做到这一点。

在启动文件中指定命名空间

要在 XML 启动文件中给节点添加命名空间,你只需要在标签内部添加一个namespace参数。让我们继续使用之前的例子:

<node pkg="my_py_pkg" exec="number_publisher" name="num_pub1" namespace="/abc">

对于 Python,语法也很简单;在这里,你只需要在Node对象内部添加一个namespace参数:

number_publisher1 = Node(
    package="my_py_pkg",
    executable="number_publisher",
    namespace="/abc",
    name="num_pub1",
    ...

如果你给这个节点添加了命名空间,你也将给与之直接通信的节点添加相同的命名空间:

<node pkg="my_py_pkg" exec="number_publisher" name="num_pub1" namespace="/abc">
...
<node pkg="my_cpp_pkg" exec="number_counter" namespace="/abc">

在启动文件中给节点添加命名空间相当直接。然而,有一件重要的事情你需要注意。如果你使用 YAML 参数文件,你还需要在 YAML 文件中指定命名空间。打开number_params.yaml文件,并将命名空间添加到节点名称中:

/abc/num_pub2:
 ros__parameters:
   number: 4
   publish_period: 1.0

如果你没有这样做,参数将被应用到/num_pub2节点上,但这个节点不存在,因为它被命名为/abc/num_pub2。这可能是错误的一个常见来源,所以在添加命名空间时务必仔细检查参数文件。

在所有这些修改之后,确保在启动任何启动文件之前再次构建 my_robot_bringup 包并源代码环境。

你现在已经看到了在启动文件中配置节点的一些方法。有了这些基础知识,你现在已经可以扩展你的应用程序很多了。让我们通过一个新的挑战来结束这一章,这样你可以自己练习更多。

启动文件挑战

在这个挑战中,你将更多地练习使用启动文件、YAML 参数文件、重映射和命名空间。这将是我们第二部分的结论。为了完成这个挑战,你可以决定用 XML、Python 或两者都来编写启动文件。

挑战

我们在这里想要做的是启动两个 turtlesim 窗口,每个窗口中有一个海龟。然后,对于每个海龟,我们运行一个 turtle_controller 节点(这是我们之前章节中一直在开发的节点)。

目标是让每个 turtle_controller 节点只控制一个海龟。结果应该看起来像这样:

图 9.4 – 两个不同的海龟和两个独立的控制器

图 9.4 – 两个不同的海龟和两个独立的控制器

对于每个海龟,我们将应用不同的设置(参数):

  • 第一个 turtlesim 窗口:

    • 128 用于 RGB 值)
  • 第一个控制器:

    • 1.5
  • 第二个 turtlesim 窗口:

    • 128 用于 RGB 值)
  • 第二个控制器:

    • 0.5

这里有一些你可以采取的步骤:

  1. 创建一个包含每个节点参数的 turtle_params.yaml 文件。将其安装到 my_robot_bringup 包中。

  2. 创建一个新的启动文件并启动四个节点。从 YAML 参数文件中加载参数。将不同的节点放入适当的命名空间中(为了简单起见,分别使用 t1t2 作为 turtle1turtle2 的命名空间)。

  3. 构建、源代码并启动启动文件。你会看到一些主题和服务不匹配,因此你会知道你需要添加哪些重映射。

为了简化,先从一对节点(turtlesimturtle_controller)开始,然后当它工作后再添加另一对。

这里是挑战中的一个重要点:我们不会修改任何现有的代码——即使这样做会使事情变得更容易。目标是使用节点原样(使用存储库中 ch8 文件夹中的代码)并使用启动文件和 YAML 参数文件中的适当命名空间和重映射来使事情工作。

解决方案

my_robot_bringup 包的 config 目录下创建一个名为 turtle_params.yaml 的新文件。作为一个基础,你可以参考我们在第八章参数挑战中使用的那个。

在这个文件中,我们将为所有四个节点添加参数。在我们这样做之前,我们需要确切地知道每个节点的名称,包括命名空间。

使用 t1t2 命名空间,如果我们只是添加一个命名空间而不重命名节点,那么我们将有这些名称:

  • /``t1/turtlesim

  • /``t2/turtlesim

  • /``t1/turtle_controller

  • /``t2/turtle_controller

在做出这个选择后,我们可以编写 YAML 参数文件:

/t1/turtlesim:
  ros__parameters:
    background_r: 128
    background_g: 0
    background_b: 0
/t2/turtlesim:
  ros__parameters:
    background_r: 0
    background_g: 128
    background_b: 0
/t1/turtle_controller:
  ros__parameters:
    color_1: [0, 0, 0]
    color_2: [255, 255, 255]
    turtle_velocity: 1.5
/t2/turtle_controller:
  ros__parameters:
    color_1: [255, 255, 255]
    color_2: [0, 0, 0]
    turtle_velocity: 0.5

这包含了挑战中给出的所有配置。现在,在 launch 目录中创建一个新的启动文件(例如,turtlesim_control.launch.xml)。

在这个启动文件中,让我们从简单的东西开始。我们想要尝试运行一个 turtlesim 节点和一个 turtle_controller 节点,使用 t1 命名空间:

<launch>
    <node pkg="turtlesim" exec="turtlesim_node" namespace="t1">
        <param from="$(find-pkg-share             my_robot_bringup)/config/turtle_params.yaml" />
    </node>
    <node pkg="turtle_controller" exec="turtle_controller" namespace="t1">
        <param from="$(find-pkg-share             my_robot_bringup)/config/turtle_params.yaml" />
    </node>
</launch>

由于我们是从 turtlesimturtle_controller 包中启动节点,我们在 package.xml 文件中也添加了两个新的 <exec_depend> 标签:

<exec_depend>turtlesim</exec_depend>
<exec_depend>turtle_controller</exec_depend>

现在,如果你启动这个(确保先构建和源代码),你会看到 turtlesim 节点,但海龟不会移动。这是为什么?

如果你查看主题列表,你会找到这两个主题:

$ ros2 topic list
/t1/turtle1/cmd_vel
/turtle1/cmd_vel

使用 rqt_graph,你还可以看到 turtlesim 节点正在订阅 /t1/turtle1/cmd_vel,但 turtle_controller 节点正在发布到 /turtle1/cmd_vel。为什么命名空间对节点名称有效,但对主题名称无效?

这是因为我们在代码中写的是 /turtle1/cmd_vel,而不是 turtle1/cmd_vel。我们在前面添加了一个斜杠,这使得命名空间成为 全局 命名空间。因此,如果你尝试给那个命名空间添加内容,它将不会被考虑。

在这里,我们有两种选择:要么修改代码(我们只需要删除这个前导斜杠),要么调整启动文件以使其工作。正如挑战说明中指定的,我们不会修改代码。我添加这个约束的原因是,在现实生活中,你并不一定能修改你运行的节点的代码。因此,知道如何在不接触代码的情况下解决名称不匹配是一个非常有用的技能。

因此,如果你查看主题和服务名称(我们这里不使用动作),你会看到我们有两个主题和一个服务需要修改。让我们在节点内部添加一些 标签:

<node pkg="turtle_controller" exec="turtle_controller" namespace="t1">
    <param from="$(find-pkg-share             my_robot_bringup)/config/turtle_params.yaml" />
    <remap from="/turtle1/pose" to="/t1/turtle1/pose" />
    <remap from="/turtle1/cmd_vel" to="/t1/turtle1/cmd_vel" />
    <remap from="/turtle1/set_pen" to="/t1/turtle1/set_pen" />
</node>

现在,你可以启动启动文件,你会看到海龟在移动。现在我们有了这个功能,添加第二对节点很容易。我们基本上需要复制/粘贴这两个节点,并将 t1 替换为 t2

<node pkg="turtlesim" exec="turtlesim_node" namespace="t2">
    <param from="$(find-pkg-share             my_robot_bringup)/config/turtle_params.yaml" />
</node>
<node pkg="turtle_controller" exec="turtle_controller" namespace="t2">
    <param from="$(find-pkg-share             my_robot_bringup)/config/turtle_params.yaml" />
    <remap from="/turtle1/pose" to="/t2/turtle1/pose" />
    <remap from="/turtle1/cmd_vel" to="/t2/turtle1/cmd_vel" />
    <remap from="/turtle1/set_pen" to="/t2/turtle1/set_pen" />
</node>

挑战现在完成了。如果你启动这个启动文件,你会看到两个 turtlesim 窗口,每个窗口中都有一个海龟以不同的速度移动,并使用不同的笔色。

你可以在书的 GitHub 仓库中找到完整的代码和包组织结构(包括 Python 启动文件)。

摘要

在这一章中,你学习了 ROS 2 启动文件。启动文件允许你通过多个节点、参数和配置集合来正确地扩展你的应用程序。

你可以用 Python、XML 或 YAML 编写启动文件。在这里,你发现了 Python 和 XML 语法,并看到 XML 可能是默认的最佳选择。语法更简单,代码更短。如果你需要将 XML 和 Python 启动文件结合起来,你可以通过在一个启动文件中包含另一个启动文件来实现。

最佳实践是为启动文件和 YAML 文件设置一个专门的软件包。您可以使用_bringup后缀来命名软件包。启动文件将安装在launch文件夹中,YAML 参数文件将安装在config文件夹中。

如果您正确理解了如何使用ros2 run 命令启动节点,那么在启动文件中这样做就相当简单:您只需为每个节点提供软件包和可执行文件名称。您需要学习的是 XML 或 Python 语法。

在启动文件中,您也可以以多种方式配置您的节点:

  • 重命名节点和/或添加命名空间

  • 映射主题、服务和动作

  • 添加参数,单独或从 YAML 参数文件中添加

这就是我们迄今为止所看到的,但在您的 ROS 2 学习之旅中,您会发现还有许多其他配置节点的方法。

这本书的第二部分现在已经完成。您已经发现了所有核心概念,这将使您能够编写完整的 ROS 2 应用程序并加入现有的 ROS 2 项目。现在您应该能够与任何 ROS 2 节点进行交互,编写与它通信的代码,并通过参数和启动文件扩展您的应用程序。

现在,这一部分主要关注编程(Python 和 C++),这非常重要,但 ROS 2 不仅仅是这些。在第三部分中,我们将深入研究一些额外的概念和工具(TransFormsTFs),统一机器人描述格式URDF),Gazebo),这样您就可以为机器人设计一个自定义应用程序,包括 3D 模拟。这与我们在第二部分中进行的编程相结合,将成为您所工作的任何 ROS 2 应用程序的骨干。

第三部分:使用 ROS 2 创建和模拟自定义机器人

在这个第三部分和最后一部分,你将不仅仅接触到 Python 或 C++代码。将引入新的概念和工具,例如 TF、URDF、RViz 和 Gazebo。有了这些,以及你在第一部分第二部分中获得的知识,你将构建一个新的项目来使用 ROS 2 模拟一个机器人。最后,最后一章总结了本书,并提供了关于下一步做什么和学习的建议。

本部分包含以下章节:

  • 第十章, 使用 RViz 发现 TFs

  • 第十一章, 为机器人创建 URDF

  • 第十二章, 发布 TFs 和打包 URDF

  • 第十三章, 在 Gazebo 中模拟机器人

  • 第十四章, 更进一步 – 下一步做什么

第十章:使用 RViz 发现 TFs

在本书的第三部分中,你将使用 ROS 2 创建一个机器人仿真。然而,在你开始之前,你首先需要了解变换TFs)是什么。

在 ROS 中,TF 是 3D 空间中两个坐标系之间的变换。TFs 将用于跟踪 ROS 机器人(或具有多个机器人的系统)随时间变化的不同坐标系。它们无处不在,并将成为任何你创建的机器人的骨架。

为了理解 TFs,我们首先将查看一个现有的机器人模型。正如我们在第三章中所做的那样,在这里,我们将通过实验来发现这些概念,并且你将建立起对事物如何工作的直觉。在这个阶段,你将发现一些新的 ROS 工具,包括RViz,一个 3D 可视化包。

你将亲自看到 TFs 是如何工作的,它们是如何相互关联的,以及如何为任何机器人可视化 TF 树。到本章结束时,你将理解 TFs 是什么,它们解决了什么问题,以及它们如何在 ROS 2 应用程序中使用。

好消息:一旦你理解了 TFs,嗯,对于任何 ROS 机器人来说,原理都是相同的,所以你可以直接将在这里学到的知识应用到你的未来项目中。

本章将非常简短,并且很快就能完成。我们在这里不会编写任何代码,也没有 GitHub 仓库。你所要做的就是跟随实验。现在不是所有东西都必须有意义;目标是获得足够的背景知识,以便理解我们稍后将要做什么。完成第三部分后,不要犹豫,随时回来查看本章。

在本章中,我们将涵盖以下主题:

  • 在 RViz 中可视化机器人模型

  • TFs 是什么?

  • TFs 之间的关系

  • 我们试图用 TFs 解决什么问题?

技术要求

在本书的开头,我给了你两个选项:要么安装带有双启动的 Ubuntu,要么在虚拟机中安装。

如果你选择了虚拟机路径,那么对于第一部分第二部分的所有章节你应该都没有问题。对于本章和接下来的两章,我们将使用一个 3D 可视化工具(RViz),如果你的电脑不够强大,它可能无法正常工作。

我建议首先尝试运行本章中的命令。如果它运行得不好(例如,太慢),那么我强烈建议你设置一个双启动系统,使用 Ubuntu 和 ROS 2(参见第二章中的说明)。如果 RViz 运行良好,那么现在可以继续这样做。双启动系统将在第十三章中需要。

在 RViz 中可视化机器人模型

在本节中,你将发现 RViz。RViz 允许你在 3D 中可视化机器人模型,并包含许多插件和功能,这些将帮助你开发你的机器人应用程序。使用 RViz,你将能够可视化机器人的 TFs,因此我们可以开始理解它们是什么。

由于我们还没有创建一个机器人模型,我们将使用一个名为 urdf_tutorial 的现有 ROS 2 包中的一个。我们将在 RViz 中加载一个机器人模型,并学习如何导航该软件。

让我们先为这一章设置我们需要的所有东西。

安装和设置

首先,没有必要安装 RViz。在本书开头安装 ROS 2 时(使用 sudo apt install 命令的 ros-<distro>-desktop),它已经被包含在内了。

要在 RViz 中可视化机器人模型的 TF,我们将安装一个新的 ROS 包,名为 urdf_tutorial。此包包含一些现有的启动文件和机器人模型文件(下一章将重点介绍如何创建机器人模型)。

如果您还记得,使用 apt 安装 ROS 2 包时,您必须从 ros 开始,然后写下您正在使用的发行版名称,最后添加包名称。所有单词都由连字符(不是下划线)分隔。

打开一个终端并安装此包:

$ sudo apt install ros-<distro>-urdf-tutorial

然后,为了使用该包,请确保您已源代码或简单地打开一个新的终端。

现在我们来可视化一个机器人模型。

使用机器人模型启动 RViz

urdf_tutorial 包包含一个名为 display.launch.py 的启动文件,它将启动 RViz 并将一个机器人模型加载到其中。现在,我们将使用它,在接下来的章节中,我们将了解这个过程是如何工作的,这样我们就可以复制它。

因此,我们需要启动这个启动文件,并加载一个机器人模型。我们将在哪里找到它?在 urdf_tutorial 包中有些现有的模型。要找到它们,导航到包安装的 share 目录,你将在包名称下找到一个 urdf 文件夹:

$ cd /opt/ros/<distro>/share/urdf_tutorial/urdf/

一个 统一机器人描述格式URDF)文件基本上是机器人模型的描述。我们将在下一章中回到这一点。现在,我们只想可视化一个。在 urdf 文件夹中,你可以找到几个机器人模型文件:

$ ls
01-myfirst.urdf            04-materials.urdf    07-physics.urdf
02-multipleshapes.urdf     05-visual.urdf        08-macroed.urdf.xacro
03-origins.urdf            06-flexible.urdf

现在,你可以通过启动 display.launch.py 文件并在启动文件后添加一个额外的 model 参数来启动一个机器人模型:

$ ros2 launch urdf_tutorial display.launch.py model:=/opt/ros/<distro>/share/urdf_tutorial/urdf/07-physics.urdf

注意

为了避免错误,最好提供 .urdf 文件的绝对路径,即使你从同一文件夹运行命令也是如此。

运行命令后,你应该会看到类似这样的内容:

图 10.1 – RViz 上的机器人模型

图 10.1 – RViz 上的机器人模型

你将得到两个窗口:一个主窗口(RViz)带有机器人模型,以及一个带有一些光标的 Joint State Publisher 窗口。我们这里的机器人模型是一个著名科幻电影机器人的复制品。它有一些轮子、一个躯干、一个头部和一个夹爪。

让我们首先关注主窗口(RViz)。花些时间学习如何在 3D 空间中导航并在机器人周围移动。你可以使用左键点击、右键点击和鼠标滚轮。为此,最好有一个鼠标,但即使使用笔记本电脑的触摸板,你也能管理导航,尽管这不太方便。

你也可以调整 RViz 窗口的大小以及内部各个部分的大小。基本上,你看到的一切都可以自定义。现在你可以在 RViz 中加载机器人模型,我们将开始实验 TF。

TF 是什么?

机器人模型中有两个主要部分:链接和 TF。在本节中,我们将可视化它们,并了解它们是如何协同工作的。

让我们从链接开始。

链接

看看RViz窗口左侧的菜单。在那里,你会看到,用蓝色粗体字母,RobotModelTF。这是我们本章将关注的内容。如你所见,你可以启用或禁用这两个菜单。

禁用TF,保持RobotModel,并展开菜单。在那里,你可以找到一个名为Links的子菜单。

图 10.2 – RViz 上的 RobotModel 和 Links 菜单

图 10.2 – RViz 上的 RobotModel 和 Links 菜单

选择或取消选择一些复选框。如你所见,从这个菜单中,一个链接是机器人一个刚性部件(意味着一个没有关节的固体部件)。基本上,在 ROS 中,一个机器人模型将是由一系列刚性部件组成的集合。

在这个例子中,链接由基本形状表示:盒子、圆柱体和球体。这些刚性部件本身没有任何作用,那么它们是如何连接的,又是如何相互移动的呢?

这就是引入 TF 的地方。

TF

现在我们检查一下TF复选框。你可以保持RobotModel复选框选中或未选中。在TF菜单中,有一个名为Frames的子菜单,你也可以为机器人启用或禁用每个框架。

图 10.3 – RViz 上的框架和 TF

图 10.3 – RViz 上的框架和 TF

你在这里看到的轴(红色、绿色和蓝色坐标系)代表框架,或者说机器人每个链接的起点。

在 ROS 中,坐标系遵循右手定则。根据图 10.4,你有以下内容:

  • X 轴(红色)向前指向

  • Y 轴(绿色)向左 90 度指向

  • Z 轴(蓝色)向上指向

图 10.4 – ROS 中坐标系的习惯用法

图 10.4 – ROS 中坐标系的习惯用法

你在图 10.3中看到的每个框架之间的箭头是机器人每个刚性部件(链接)之间的关系。TF 由一个箭头表示。

注意

linksframesTFs的名称之间可能会有一些混淆。让我们澄清一下:

  • 链接:机器人一个刚性部件

  • 框架:链接的起点(RViz 中的轴)

  • TF:两个框架之间的关系(RViz 中的箭头)

因此,每个刚体部分都会通过一个 TF 与另一个刚体部分相连。这种变换定义了这两个部分相对于彼此的位置。此外,TF 还定义了这两个部分是否在移动,如果是的话,如何移动——平移、旋转等。

要使机器人的某些部分移动,你可以在关节状态发布者窗口中移动一些光标。你将看到在 RViz 中帧和 TFs 在移动。如果你再次检查机器人模型框,你也会看到刚体部分在移动。

为了更好地理解,这里有一个与人类手臂的类比:我们可以将手臂的部分定义为手臂(从肩膀到肘部)和前臂(肘部之后)。这两个是刚体部分(在这里,是链接),并且它们不会自行移动。每个链接都有一个原点坐标系,有一个 TF 定义了手臂和前臂的连接位置(想象一下肘部的轴线),以及它们的运动方式(在这种情况下,是一个具有最小和最大角度的旋转)。

正如我们将在本章后面看到的那样,TFs 非常重要。如果机器人的 TFs 没有正确定义,那么什么都不会工作。

现在你已经知道了 TFs 是什么,但它们之间是如何相互关联的呢?正如你在 RViz 中看到的那样,TFs 似乎是有组织地排列的。让我们再进一步,了解 TFs 之间的关系。

TFs 之间的关系

在 RViz 中,我们看到了链接(刚体部分)和 TFs(链接之间的连接)。链接主要用于模拟中的视觉方面,并将有助于定义惯性和碰撞属性(当我们使用 Gazebo 时)。TFs 定义了链接是如何连接的,以及它们是如何相互移动的。

除了这些,一个机器人的所有 TFs 都按照特定的方式组织,在一个树结构中。让我们来探索 TFs 之间的关系,并在 RViz 中可视化我们开始时的机器人 TF 树。

父亲和子代

每个 TF 都会连接到另一个 TF,形成一个父/子关系。例如,要查看一个,你可以禁用 RViz 上的所有 TFs,只检查base_linkgripper_pole坐标系。

图 10.5 – 两个坐标系之间的关系

图 10.5 – 两个坐标系之间的关系

正如你在这个例子中看到的那样,一个箭头从gripper_pole坐标系指向base_link坐标系。这意味着gripper_polebase_link的子代(或者,base_linkgripper_pole的父代)。

如果你回顾一下图 10.3,你可以看到机器人的所有坐标系,以及它们之间的关系(TFs)。

这些关系的顺序非常重要。如果你将gripper_pole相对于base_link移动(在关节状态发布者窗口中的gripper_extension光标),那么连接到gripper_pole的任何东西(即gripper_pole的子代)也会随之移动。

这是有意义的:当你旋转你的肘部时,你的前臂在移动,但你的手腕、手和手指也在移动。它们相对于前臂不移动,但作为它们附着在前臂上,它们相对于手臂移动。

现在,你可以在 RViz 上可视化所有的链接和 TFs,看到 TFs 之间的关系,以及它们是如何相互关联的。让我们进一步探讨/****tf主题。

/tf主题

在这一点上,你可能会认为我们在本书的第二部分中所做的一切与我们现在所做的一切没有任何关系。好吧,我们在这里看到的一切仍然基于节点、主题等等。

让我们列出所有节点:

$ ros2 node list
/joint_state_publisher
/robot_state_publisher
/rviz
/transform_listener_impl_5a530d0a8740

你可以看到,RViz 实际上是以一个节点(rviz)启动的。我们还有joint_state_publisherrobot_state_publisher节点,我们将在本书的后续章节中回到这些节点。现在,让我们列出所有主题:

$ ros2 topic list
/joint_states
/parameter_events
/robot_description
/rosout
/tf
/tf_static

你可以看到,那些启动的节点正在使用主题相互通信。在这个主题列表中,我们找到了/tf主题。这个主题将适用于你创建的任何机器人。你在 RViz 上看到的 TF 实际上是这个主题的 3D 可视化——这意味着rviz节点是/****tf主题的订阅者。

你可以使用以下命令从终端订阅主题:

$ ros2 topic echo /tf

如果你这样做,你会收到很多消息。这里是一个摘录:

transforms:
- header:
	stamp:
  	sec: 1719581158
  	nanosec: 318170246
	frame_id: base_link
  child_frame_id: gripper_pole
  transform:
	translation:
  	x: 0.19
  	y: 0.0
  	z: 0.2
	rotation:
  	x: 0.0
  	y: 0.0
  	z: 0.0
  	w: 1.0

这个摘录与我们之前在 RViz 上看到的内容相匹配。它代表了base_linkgripper_pole之间的转换。以下是我们可以从这个消息中获得的重要信息:

  • TF 的时间戳

  • 父亲和子帧 ID

  • 实际的转换,包括平移和旋转

注意

旋转不是用欧拉角(xyz)表示,而是用四元数(xyzw)表示。四元数通常更适合计算机,但对于人类来说,可视化它们是困难的。不要担心这个问题——我们实际上并不需要处理四元数。如果你将来必须这样做,你将能够访问可以将角度转换为可理解内容的库。

我们可以在这里获得的一个重要信息是,转换是针对特定时间的。这意味着,通过主题数据,你可以跟随所有 TFs 随时间的变化。你可以知道gripper_pole相对于base_link现在或过去的位置。

这个/tf主题包含了我们所需要的一切信息,但它并不是真正的人类可读的。这就是为什么我们开始使用 RViz,这样我们就可以看到包含所有 TFs 的 3D 视图。

让我们现在通过打印 TF 树来完成这个部分,这样我们就可以在一张单独的图像中看到所有关系。

可视化 TF 树

对于每个机器人,你可以以简化的方式可视化完整的 TF 树,这样你就可以看到所有 TFs 之间的关系。

要做到这一点,你需要使用tf2_tools包。确保它已安装:

$ sudo apt install ros-<distro>-tf2-tools

不要忘记在安装包后 source 环境。现在,保持机器人在 RViz 上运行,并在第二个终端中执行此命令:

$ ros2 run tf2_tools view_frames

如你将通过日志看到的那样,它将监听/tf主题五秒钟。在这之后,命令将退出并显示一个大的日志,你可以忽略它。

你将在这个命令运行的同一目录下获得两个新文件。

打开 PDF 文件。你会看到类似这样的内容(我只是添加了图像的左侧,否则书中的文字太小,难以阅读):

图 10.6 – 机器人的 TF 树

图 10.6 – 机器人的 TF 树

在这个文件中,你可以一次性获得所有的链接和 TFs,并且可以清楚地看到哪个链接是哪个其他链接的子链接。PDF 上的每个箭头都代表链接之间的一个 TF(变换)。

如你所见,那个机器人的根链接被命名为base_link(对于大多数机器人,base_link被用作第一个链接的名称)。这个链接有四个子链接:gripper_poleheadleft_legright_leg。然后,这些链接也会有更多的子链接。在这里,我们可以清楚地看到gripper_pole链接的所有子链接。

我们现在可以理解,当我们之前将gripper_pole相对于base_link移动时,gripper_pole的所有子链接也相对于base_link移动了。

注意

在 ROS 中,一个链接可以有多个子链接,但只有一个父链接。我们将在下一章中定义链接和 TFs 时回到这个问题。

在这个例子中,我们只有一个机器人。如果你在你的应用中有几个机器人,那么你会有一个world框架作为根链接。然后,这个框架会有几个子链接:base_link1base_link2等等。每个机器人基链接都会连接到world框架。因此,你可以得到一个完整的 TF 树,不仅是一个机器人,而且是一个包含多个机器人的完整机器人应用。

现在,你已经看到了关于 TFs(变换)的几乎所有内容:它们是什么,它们是如何相互关联的,以及它们是如何组织的。让我们通过理解我们试图用 TFs 解决的问题来结束这一章。

我们试图用 TFs 解决什么问题?

你现在已经看到了 TFs 是什么以及你如何为任何 ROS 机器人可视化它们。这是很好的,但现在我们来到了这一章的最终问题:为什么我们需要关心这个?我们试图解决什么问题?

我们想要实现的目标

为了使机器人应用工作,我们希望随着时间的推移跟踪每个 3D 坐标框架。我们需要一个结构化的树来表示机器人的所有框架(或机器人)。

这里有两个组成部分:我们需要知道事物在哪里以及变换发生的时间。如果你还记得,当我们检查/tf主题时,你可以看到对于每个父框架和子框架,我们都有一个变换(在三维空间中的平移和旋转),我们还有一个时间戳。

这里有一些具体的例子,说明在机器人应用中你可能需要回答的问题:

  • 对于一个移动机器人,右轮相对于左轮和机器人底部的位置在哪里?车轮的运动是如何随时间演变的?

  • 如果你有一个带有机械臂和摄像头的应用,那么摄像头相对于机器人底部和机械臂手的位置在哪里?这样,机械臂就可以正确地捡起和放置由摄像头检测到的物体?

  • 在另一个有多个移动机器人的应用中,每个机器人相对于其他机器人的位置在哪里?

  • 如果你结合前两个例子,机械臂的手相对于移动机器人之一的底部在哪里?

因此,通过 TFs,我们想要知道以下内容:

  • 框架相对于彼此是如何放置的

  • 它们相对于彼此以及随时间如何移动

这对于机器人正确使用 ROS 是必需的。

现在,让我们看看你将如何自己计算 TFs,以及 ROS 是如何自动为你完成这些工作,这样你就不必担心它。

如何计算 TFs

变换究竟是什么?变换是空间中平移和旋转的组合。

由于我们在 3D 空间中工作,我们有三个平移组件(xyz),以及三个旋转组件(xyz)。要找到两个框架之间的变换,你需要计算这六个元素,使用 3x3 矩阵。

我不会在这里深入数学细节,但你可以猜测这不会是一个容易的任务。此外,你需要为每个框架相对于其他框架计算这个变换。这增加了复杂性。

例如,假设你需要知道left_front_wheel相对于base_link的位置。按照之前的 TF 树(再次打开 PDF),你可以看到你需要遵循以下顺序:base_linkleft_legleft_baseleft_front_wheel

让我们在 RViz 上可视化这一点:

图 10.7 – 四个框架之间的三个变换

图 10.7 – 四个框架之间的三个变换

你需要连续计算三个变换,以便得到从base_linkleft_front_wheel的变换。你将不得不对每个相对于所有其他框架的框架重复此操作(随着你添加更多框架,复杂性会大大增加),并且跟踪这些变换随时间的变化。

这听起来像是一项大量的工作。幸运的是,我们不需要做任何这些,多亏了 ROS TF 功能。有一个名为tf2的库,它已经为我们做了这些。

最后,TFs(变换函数)最大的挑战在于理解它们是如何工作的。你通常不会直接在你的应用程序中使用 TFs。有几个包会为你处理这个问题。我们唯一需要做的是提供一个机器人描述,该描述指定了机器人的所有链接和 TFs。然后,使用名为robot_state_publisher的包,ROS 将自动为我们发布 TFs。这就是我们在下一章将要关注的内容。

概述

在本章中,我们开始了一些不同的事情。ROS 不仅仅是编程;还有很多其他东西让它成为机器人领域的优秀工具。

你首先发现了一个用于 ROS 的 3D 可视化工具,名为 RViz。你将在大多数 ROS 应用程序中使用这个工具。使用 RViz,你可以可视化一个机器人模型,这在你自己开发模型时将非常有帮助。

然后,你发现了 TFs 是什么,以及为什么它们在 ROS 应用程序中如此重要。这里是一个简要的回顾:

  • 我们需要跟踪整个机器人应用(一个或多个机器人)中的每个 3D 坐标系随时间的变化。

  • 我们不自己计算变换,而是使用 ROS TF 功能,通过tf2库。TFs 在/tf主题上发布。

  • TFs 被组织成一个你可以可视化的结构化树。

  • TF 定义了两个坐标系如何连接,以及它们随时间如何相对移动。

为了指定机器人的 TFs,我们必须创建一个名为URDF的机器人模型。然后,这个机器人模型将由robot_state_publisher节点(我们稍后会看到)用来发布 TFs。发布的 TFs 将被你的应用程序中的其他包使用。

最后,我们实际上不会直接与 TFs 交互。本章最重要的内容是理解 TFs 是什么,以及为什么我们需要它们。这将帮助你理解你在下一章创建机器人模型时所做的事情。

如果现在事情仍然有点令人困惑,不要过于担心。继续阅读接下来的几章,然后再次回到这个 TF 章节,一切都会更加清晰。

现在,让我们跳到下一章,创建我们的第一个机器人模型。

第十一章:为机器人创建 URDF

在上一章中,我们从一个直观的介绍开始,介绍了 TFs,或称为变换。您已经看到 TFs 非常重要;它们将是几乎所有 ROS 应用程序的骨干。我们总结说,为了为机器人生成 TFs,您需要创建一个统一机器人描述格式URDF)文件。

基本上,一个 URDF 文件将包含机器人所有元素的描述。您将定义机器人的每个链接(刚性部分)。然后,为了在链接之间建立关系,您将添加一些关节,这些关节将用于生成 TFs。

要编写 URDF 的内容,我们将使用 XML。随着您开发 URDF,您将能够使用 RViz 可视化它。这将非常有帮助,以查看链接和关节/TFs 是否正确。我们还将使用一个名为Xacro的额外工具改进 URDF 文件,使其更加动态。

因此,在本章中,我们将从第三部分的项目开始,创建机器人的 URDF。我们将创建一个带有两个轮子的移动底盘。这将是下一章的基础。您可以在本书 GitHub 仓库的ch11文件夹中找到最终的 URDF 文件(github.com/PacktPublishing/ROS-2-from-Scratch)。

URDF 最难的部分是理解如何使用关节组装机器人的两个链接。在没有指导的情况下完成这项工作相当困难,因为您可以修改许多参数和原点。我将逐步解释整个过程,以确保您构建的东西能够正常工作。

到本章结束时,您将能够为几乎任何由 ROS 驱动的机器人创建自己的 URDF。

在本章中,我们将涵盖以下主题:

  • 使用链接创建 URDF

  • 链接和关节的组装过程

  • 为移动机器人创建 URDF

  • 使用 Xacro 改进 URDF

使用链接创建 URDF

在本节中,您将直接创建您的第一个 URDF。我们首先创建一个用于 URDF 的 XML 文件。在这个文件中,我们将添加一个链接,它将代表机器人的一个刚性部分,并在 RViz 中可视化它。我们还将探索您可以使用的不同类型的形状——盒子、圆柱体等。

这将是一个很好的第一步,这样您可以熟悉 URDF 并准备好深入到添加多个链接和关节的过程(在下一节中)。

让我们从设置我们的 URDF 文件开始。

设置 URDF 文件

URDF 文件只是一个具有.urdf扩展名的 XML 文件。

现在,为了使本章的内容简单,我们将在我们的主目录中创建一个 URDF 文件。在下一章中,您将学习如何正确地将 URDF 打包到 ROS 2 应用程序中。

应该如何命名 URDF 文件?您可以选择任何名称;实际上并不重要。通常,您会将其命名为您的机器人名称。如果您的机器人名称是abc,那么您将创建一个abc.urdf文件。让我们使用之前在这本书中使用的名称my_robot

打开终端并在您的家目录中创建一个新文件:

$ cd
$ touch my_robot.urdf

您可以使用任何文本编辑器或 IDE 打开此文件,例如,使用 VS Code:

$ code my_robot.urdf

这里是您必须在 URDF 文件中编写的最小代码:

<?xml version="1.0"?>
<robot name="my_robot">
</robot>

我们首先使用行打开文件,以指定此文件是一个 XML 文件——我们也给出了 XML 版本。

然后,您需要打开和关闭**<robot>**标签。您在 URDF 中编写的所有内容都必须在这个标签内。您还必须通过name参数提供机器人的名称。

现在,这是 URDF 的最小代码,但如果您不至少定义一个元素,它将毫无用处。让我们在这个 URDF 中添加一个链接。

创建链接

现在,您将编写您的第一个链接,这对应于机器人的一个刚性部分,并在 RViz 中可视化它。通过这样做,您将能够看到链接是否正确定义,并在必要时进行修改。

让我们从添加链接的 XML 代码开始。

链接的基本代码

要为链接创建一个视觉元素,您可以使用现有的形状:盒子、圆柱体和球体(我们稍后还将看到如何包含由计算机辅助设计CAD)软件创建的自定义形状)。

要开始,让我们想象机器人的主要基座,表示为一个盒子。这个盒子是 60 厘米 x 40 厘米 x 20 厘米,或0.6米 x 0.4米 x 0.2米。

注意

在 ROS 中,只使用公制系统。以下是本章中我们将使用的一些单位:

  • 距离将使用米。如果您需要指定 70 毫米,例如,您将写0.07

  • 弧度将用于角度。180 度对应于π(约3.14)弧度。

  • 每秒米数将用于速度实例。

这是第一个链接的代码:

<robot name="my_robot">
    <link name="base_link">
        <visual>
            <geometry>
                <box size="0.6 0.4 0.2" />
            </geometry>
            <origin xyz="0 0 0" rpy="0 0 0" />
        </visual>
    </link>
</robot>

确保在**<robot>**标签内定义**<link>**。尽管在 XML 中缩进不是必需的,但最佳实践是添加一些缩进以获得更易读的文件。在这里,我使用了每个缩进四个空格。

让我们分析这个链接的元素。**<link>**标签定义了链接。此链接的所有属性都必须在标签内。您还必须为链接提供一个name属性。按照惯例,对于第一个链接,我们使用base_link

然后,在这个标签内部,我们有**<visual>**标签。如果您想为链接(刚性部分)定义视觉外观,可以使用此标签。在内部,您将有以下内容:

  • <geometry>: 这将定义链接的形状。在这里,我们使用<box>标签,并通过size属性提供尺寸。

  • <origin>:这个标签非常重要,因为它定义了可视化相对于链接原点的原点。我们将在本章稍后回到这一点,并了解如何避免混淆。原点包含六个元素用于平移和旋转。

注意

旋转的原点以 rpy 表示。这意味着 翻滚俯仰偏航。它与 xyz 相同,但使用了不同的名称。翻滚,俯仰和偏航在航空中非常常用。你只需要习惯 URDF 中的这种用法。

如你所见,我们首先将所有原点设置为 0。现在,我们唯一指定的是盒子的尺寸。

在 RViz 中可视化 URDF

我们有足够的代码在 RViz 中可视化 URDF。这里的目的是在 3D 中看到盒子,并验证一切是否正确。

在开发 URDF 时这样做非常重要。我建议始终执行以下操作:

  1. 进行最小的修改(添加或修改某些内容)

  2. 在 RViz 中可视化 URDF

  3. 如果正确,继续下一个功能;如果不正确,返回,修复它,并再次检查

现在,我们如何在 RViz 中可视化 URDF?

好消息:我们可以重用 urdf_tutorial 包(在 第十章 中安装)并提供我们自己的 URDF 而不是示例 URDF。这很好,因为我们可以在 ROS 2 工作空间之外轻松测试 URDF 文件,而且我们目前不需要创建任何包。

打开一个终端并启动 display.launch.py 启动文件,为 model 参数提供 URDF 文件的绝对路径:

$ ros2 launch urdf_tutorial display.launch.py \ model:=/home/<user>/my_robot.urdf

你将看到 RViz 内部的一个盒子(默认为红色)。你还将有一个空的 Joint State Publisher 窗口,没有光标。

图 11.1 – 在 RViz 中的 URDF 可视化

图 11.1 – 在 RViz 中的 URDF 可视化

如果你访问 RobotModel | 链接,你会看到 基础链接。这是你创建的链接;你可以为该链接启用或禁用可视化。

RViz 中绕着盒子导航。你会看到可视化(盒子)围绕链接原点居中。你可以保持这样,或者决定相对于框架偏移可视化。让我们这样做。

修改可视化的原点

我们创建的链接是完美的。然而,我们将稍微偏移可视化,以便原点不在盒子的中间,而是在盒子的底部。

你不一定需要这样做。有时,将可视化中心对准链接原点就是你所需要的。当我们稍后在本章中创建移动基座的 URDF 时,我们将看到一些示例来说明这一点。现在,让我们假设我们想要偏移可视化。

为了调整视觉效果,我们需要修改<visual>标签内的<origin>标签。在这个<origin>标签中,我们有六个元素用于平移和旋转。我们只想将视觉效果向上移动,因此我们只需要修改Z轴上的平移(如果您还记得,使用右手定则,Z 轴向上)。

我们应该应用多大的偏移量?由于盒子目前位于链接原点中心,我们需要将其向上抬起一半的高度。

我们已将高度定义为0.2米,因此我们需要将视觉效果偏移0.1米。

修改此行,使 z 偏移为0.1

<origin xyz="0 0 0.1" rpy="0 0 0" />

保存文件,为了可视化更改,停止 RViz(在启动urdf_tutorial的终端上按Ctrl + C),然后再次启动。每次修改 URDF 时,您都可以这样做。

现在,您应该看到盒子坐在地面上,这意味着视觉效果的正确偏移已经应用。

备注

链接的原点仍然是相同的;您只是改变了相对于链接的视觉效果。这是一个重要的区别。如果您感到困惑,请继续阅读,在您看到完整的链接和关节过程后,一切都会变得清晰。

您已经创建了您的第一个链接。现在让我们看看您可以使用哪些形状,以及您可以在 URDF 中的链接上添加哪些自定义设置。

自定义链接视觉效果

链接是您机器人的一个刚性部分。您可以使其看起来像任何您想要的样子。

让我们探索您可以赋予链接的不同形状,以及如何更改它们的颜色。

链接的不同形状

正如您在创建的第一个链接中看到的那样,您将在<visual>标签内的<geometry>标签中定义链接的形状。

您可以使用三种基本形状。对于每一种形状,您都需要提供不同的属性来指定尺寸:

  • <box>: 您需要添加一个包含三个组件的size参数:xyz

  • <cylinder>: 您需要添加两个参数,radius(半径)和length(长度)

  • <sphere>: 您只需要一个参数,radius(半径)

我们刚刚在之前的代码示例中看到了如何创建一个盒子。以下是一个半径为0.2米、长度为0.5米的圆柱体示例:

<geometry>
    <cylinder radius="0.2" length="0.5"/>
</geometry>

以下是一个半径为0.35米的球体示例:

<geometry>
    <sphere radius="0.35"/>
</geometry>

在那些基本形状之上,您还可以使用从 CAD 软件(如 SolidWorks、Blender 等)导出的自定义网格。您可以使用 STL 和 Collada 文件,分别具有.stl.dae扩展名。设置这些文件并不复杂,但需要您正确地将应用程序打包在 URDF 周围,这是我们将在第十二章中看到的。

备注

甚至还有一些工具允许您直接从 CAD 软件中为机器人生成完整的 URDF(链接、关节、网格等)。太棒了,不是吗?然而,这些工具并不总是最新或稳定的,如果您遇到错误,您可能需要花费大量时间查找和修复它。我建议您自己编写 URDF 并逐个添加网格。这样,您将对自己的操作有更多的控制,修复错误也将花费更少的时间。

使用三个基本形状(盒子、圆柱体、球体),您已经可以完成很多事情并设计一个完整的机器人。我们不需要更多东西来开始,我们将使用它们来创建本章中我们将创建的移动机器人。链接的可视化对 TF 生成没有影响,所以这不会成为问题。即使您开始设计自己的定制机器人,您也可以从基本形状开始,一切都会顺利。

下面是包含您可以在链接中添加的每个标签和属性的完整参考:

wiki.ros.org/urdf/XML/link

现在我们来完成这个部分,看看如何更改链接可视化的颜色。

链接颜色

如果您查看 RViz 上的第一个链接,您可以看到其可视化颜色为红色。这将是您创建的任何基本形状的默认颜色。

随着我们添加更多形状并将它们组合起来,修改它们的颜色可能是个不错的选择,这样我们可以在不同的链接之间获得一些对比度。否则,在屏幕上区分它们将很困难。

要为链接添加颜色,您首先需要创建一个名为 <material> 的标签。然后,您可以在链接的可视化中使用该颜色。

下面是使链接变绿的完整代码:

<?xml version="1.0"?>
<robot name="my_robot">
    <material name="green">
        <color rgba="0 0.6 0 1" />
    </material>
    <link name="base_link">
        <visual>
            <geometry>
                <box size="0.6 0.4 0.2" />
            </geometry>
            <origin xyz="0 0 0.1" rpy="0 0 0" />
            <material name="green" />
        </visual>
    </link>
</robot>

确保定义 <material> 标签的内容位于 <robot> 标签内,但不在任何 <link> 标签内。在这个新标签中,您需要执行以下操作:

  • 使用 name 属性定义一个名称。

  • 使用 <color> 标签和 rgba 属性(红色、绿色、蓝色、透明度)定义颜色。这四个值应在 01 之间。要创建一个基本、不太明亮的绿色,我们将红色和蓝色设置为 0,绿色设置为 0.6。您可以保持透明度(不透明度)设置为 1

您只需要定义此标签一次,然后您可以在任何 <visual> 标签中使用它,无论在哪个链接中。颜色将应用于基本形状。如果已导入自定义网格(Collada 文件已包含颜色,因此不需要 <material> 标签),它也应应用于 STL 文件。

注意

当在链接中使用 <material> 标签时,请确保将其放置在 <visual> 标签内,但不要放在 <geometry> 标签内。《geometryoriginmaterial标签应该是` 标签的直接子标签。

太好了,您现在可以创建具有不同形状和颜色的链接。这是一个很好的开始,这样您就可以在 3D 中表示机器人中的任何刚性部件。

现在我们来看看如何组装不同的链接,从而创建一个完整的机器人模型。

组装连杆和关节的过程

现在你已经有一个包含一个连杆的 URDF 文件,让我们添加另一个连杆,并通过关节将它们连接起来。这个关节将用于生成一个 TF。

正确组装两个连杆并使用关节是任何人在学习 URDF 时面临的主要问题。你可以修改几个原点和轴,使两个部分正确地放置在彼此之间,并具有正确的运动,这可能具有挑战性。

在本节中,我们将专注于这个过程,以便它对你来说更容易。我已经将其简化为五个步骤,每次添加新连杆时你都可以按照这个顺序进行。

在你对这个过程有信心之后,你将能够为任何类型的机器人创建 URDF。一个完整的机器人模型只是连接到彼此的一系列连杆。如果你正确理解了如何为两个连杆操作,添加二十个更多的连杆就不会那么困难了。

我们还将探索在 URDF 中可以使用的不同类型的关节。为了验证一切正常,我们将使用 RViz 以及由tf2_tools包生成的 TF 树。

这个部分非常重要,我建议你不要跳过它。同时,如果你对如何连接两个连杆有疑问,随时可以回来查看。

让我们开始这个过程的第 1 步:向 URDF 中添加第二个连杆。

第 1 步 – 添加第二个连杆

对于这个例子,我们想在盒子上方添加一个圆柱体(半径:0.1 米,长度:0.3 米)。我们将把这个圆柱体设置为灰色(以与绿色盒子形成对比),因此,让我们首先创建另一个带有灰色颜色的标签。

你可以在之前的标签之后添加这个新标签:

<material name="gray">
    <color rgba="0.7 0.7 0.7 1" />
</material>

然后,让我们添加连杆。在标签内创建另一个标签,并指定第二个连杆的规格。你可以把这个连杆放在base_link之后:

<link name="shoulder_link">
    <visual>
        <geometry>
            <cylinder radius="0.1" length="0.3" />
        </geometry>
        <origin xyz="0 0 0" rpy="0 0 0" />
        <material name="gray" />
    </visual>
</link>

你可以为连杆命名任何名称。在这里,我指定为shoulder_link,因为我们将在本例中创建机器人手臂的起始部分。你还可以有多个部分:基础、肩部、手臂、前臂、手等等。最佳实践是为你的机器人连杆命名具有意义的名称。

如你所见,我们将所有原点元素设置为0。这非常重要,将是这个过程的第一步:你添加一个连杆,但不要修改原点。

现在,如果你尝试在 RViz 中可视化 URDF(从终端停止并重新启动),你会得到一个错误。在日志中,你会看到这个:

Error: Failed to find root link: Two root links found: [base_link] and [shoulder_link]

你会得到这个结果,因为 URDF 中的所有连杆都需要通过父/子关系相互关联,正如我们在上一章中看到的。在这里,没有明确的关系,所以 ROS 无法知道哪个是父节点,哪个是子节点。

我们将通过关节来定义这种关系。这也会使我们能够为机器人生成第一个 TF。

第 2 步 – 添加关节

为了定义两个链接如何连接,你需要添加一个关节。以下是你在两个<link>标签之后(并且仍然在<robot>标签内)可以写入的代码:

<joint name="base_shoulder_joint" type="fixed">
    <parent link="base_link" />
    <child link="shoulder_link" />
    <origin xyz="0 0 0" rpy="0 0 0" />
</joint>

要创建一个关节,你添加一个<joint>标签,它包含两个属性:

  • name:你可以选择任何你想要的,只要它有意义。我通常将我想连接的两个链接的名称组合起来:base_linkshoulder_link变成base_shoulder_joint

  • type:我们将在第 4 步中回到你可以使用的不同关节类型。目前,我们将它设置为fixed,这意味着两个链接之间不会移动。

<joint>标签内,你还有三个更多的标签:

  • <parent>:这是父链接。你必须使用link属性写出链接的确切名称。

  • <child>:你将使用link属性写出子链接的确切名称。

  • <origin>:这将定义子链接相对于父链接原点的原点。再次强调,我们使用xyz进行平移,使用rpy进行旋转。

这是过程的第二步:你在两个链接之间添加一个关节,并定义哪个是父链接,哪个是子链接。目前,你将所有原点元素都设置为0

使用这段代码,你可以再次启动 RViz,这次,由于链接之间存在关系,URDF 将会显示。以下是你会得到的结果:

图 11.2 – 两个链接和一个关节,所有原点都设置为 0

图 11.2 – 两个链接和一个关节,所有原点都设置为 0

如你所见,我们现在有一个盒子(base_link)和一个圆柱体(shoulder_link)。由于所有原点元素都设置为0,两个链接的原点都在同一个位置。

此外,你还可以验证你用关节创建的 TF 是否正确地放置在 TF 树中。使用ros2 run tf2_tools view_frames命令,你可以生成 TF 树。在新创建的 PDF 文件中,你会看到这个:

图 11.3 – 验证两个链接之间关系的 TF 树

图 11.3 – 验证两个链接之间关系的 TF 树

我们可以验证我们定义的关系是否正确。现在我们需要正确地将shoulder_link相对于base_link放置。

第 3 步 – 调整关节原点

这是大多数人感到困惑的步骤。如果你查看当前的代码,我们有三个<origin>标签:每个链接中一个,一个在关节中。那么,我们需要修改哪个原点?

经典的错误是同时修改几个随机的起点,并通过调整值来尝试找到可以工作的情况。即使最终看起来似乎可行,但在添加其他关节时可能会产生更多问题。

因此,对于这一步,我强调你必须每次都遵循我将要描述的精确过程。

首件事是修改关节的标签,以便正确放置子连接的框架。这是最重要的。你将首先固定关节原点,然后,并且然后,固定视觉原点。

为了做到这一点,展开RobotModelLinks,并取消选择shoulder_link的视觉(在这个阶段,看到视觉可能会造成混淆,所以我们禁用它)。然后,问问自己这个问题:shoulder_link的框架相对于base_link的框架应该在哪个位置?

我们希望shoulder_link位于盒子的顶部,因此我们需要将框架移动到盒子的高度;这里,那是0.2米。这个关节不需要旋转,只需要平移。

因此,你现在可以修改标签内的标签:

<origin xyz="0 0 0.2" rpy="0 0 0" />

再次启动 RViz,禁用shoulder_link的视觉,并检查框架是否放置正确。

图 11.4 – 不使用视觉设置关节原点

图 11.4 – 不使用视觉设置关节原点

太好了,看起来shoulder_link的框架在正确的位置:在盒子的顶部。

这个原点是至关重要的;这将定义 TF。最终,TFs 将使用你的 URDF 中的所有关节原点生成。视觉将不被考虑(在 Gazebo 模拟器中,视觉将用于惯性和碰撞属性)。

我们已经正确设置了关节原点。现在,让我们看看如何指定关节类型,这样我们就可以定义两个连接之间的运动。

第 4 步 – 设置关节类型

为了使前面的解释简单,我们将关节类型设置为固定,这意味着两个连接相对于彼此不移动。

你将创建的许多关节将类似于这种情况。例如,如果你在你的机器人上放置一个传感器(摄像头、激光雷达),该传感器不会移动。你可以为传感器创建一个连接,然后创建一个固定关节将这个传感器连接到你的机器人上。

然而,对于一些刚性部件(机器人臂中的手臂、轮子、躯干等),你需要指定子连接相对于父连接是移动的。我不会描述所有可能的关节类型,但在任何机器人中你都会找到以下最常见的运动:

  • 固定:如前所述,这是如果你有两个不移动的部分

  • 旋转:具有最小和最大角度的旋转,例如在机器人臂中

  • 连续:无限旋转,通常用于轮子

  • 滑动:如果你需要使机器人的某个部分滑动(只有平移,没有旋转)

你可以在wiki.ros.org/urdf/XML/joint找到所有关节类型的完整参考。在那里,你可以获取所有可以在标签中添加的元素。其中大部分是可选的。

回到我们的例子,假设肩部链接在盒子上方的Z-轴上旋转(有一个最小值和最大值)。你可以使用revolute关节类型来指定这一点。通过这样做,你还需要添加一个标签来指定旋转轴,以及一个标签来指定最小和最大角度。让我们修改<****base_shoulder_joint>标签:

<joint name="base_shoulder_joint" type="revolute">
    <parent link="base_link" />
    <child link="shoulder_link" />
    <origin xyz="0 0 0.2" rpy="0 0 0" />
    <axis xyz="0 0 1" />
    <limit lower="-3.14" upper="3.14" velocity="100" effort="100"/>
</joint>

当选择revolute时,我们首先需要定义哪个轴将旋转。因为我们选择了z(如果你看图 11**.4,我们想要围绕蓝色轴旋转),我们写"0 0 1",这意味着:xy没有旋转,z上有旋转。

我们将旋转角度设置为-180 到+180 度(大约-3.143.14弧度)。我们还需要指定速度和努力限制的值。这两个值通常会被其他 ROS 节点覆盖。默认设置为100;在这里它不会很重要。

通过这个关节,你创建了一个 TF,它定义了shoulder_link相对于base_link的位置,以及这两个链接之间的运动。

现在,你可以再次启动 RViz(禁用视觉),你将在Joint State Publisher窗口中找到一个光标。将光标移动到使shoulder_linkbase_link上方旋转。再一次,这完全是关于正确移动框架。如果视觉仍然看起来不正确,不要担心;这是我们将在本过程的最后一步修复的。

第 5 步 – 修复视觉原点

我们现在可以固定shoulder_link视觉的原点。不要修改base_link的原点,因为它已经正确了。在这里,我们只修改子链接的视觉。

要做到这一点,你可以在 RViz 中再次启用shoulder_link视觉,并看到框架位于圆柱体的中心。因此,我们需要将视觉偏移到圆柱体长度的一半,这意味着0.15米(0.3米的一半)。

修改标签,位于标签内的shoulder_link

<origin xyz="0 0 0.15" rpy="0 0 0" />

如果你再次启动 RViz,你会看到一切都被正确放置:

图 11.5 – 修复原点的过程结束

图 11.5 – 修复原点的过程结束

注意

在这个例子中,我们想要将圆柱体放置在框架上方。如果你想要框架位于圆柱体的中心,你会在Z-轴上更高处放置关节原点,然后保持视觉原点不变(我将在本章后面,当我们处理移动机器人的轮子时,更多地讨论这一点)。

现在过程已经完成。由于它非常重要,让我们现在快速回顾一下。

回顾 – 每次都要遵循的过程

当你创建一个 URDF 时,你首先会从一个链接开始,通常命名为base_link。然后,对于你添加的每个链接,你也会添加一个关节,以将这个新链接连接到现有的一个链接上。

这是将新链接添加到你的 URDF 中的过程:

  1. 添加一个新的标签,并将所有原点元素设置为0

  2. 添加一个新的 <joint> 标签。你必须指定一个父链接和一个子链接。父链接将是你已经创建的现有链接,而子链接是你刚刚添加的新链接。将所有原点元素设置为 0

  3. 确定关节的原点。在 RViz 中可视化 URDF,禁用新链接的视觉,并确定新链接的框架相对于其父链接的位置。

  4. 如果关节与运动相关联,设置关节类型。根据类型,你可能需要设置旋转/平移轴、一些限制等。

  5. 一旦框架原点正确,在 RViz 中启用视觉并固定链接的视觉原点(仅针对子链接,不是父链接)。

在完成这个步骤后,恭喜你,你已经成功连接了两个链接,关节将用于生成 TF。你可以为 URDF 中添加的每个新链接重复此过程。

需要记住的一些重要事项如下:

  • 每次只添加一个链接和一个关节。完成这个过程后,再添加另一个链接。

  • 不要修改父链接或你之前创建的任何链接的原点。这是开始弄乱 URDF 并花费数小时进行调试的可靠方法。如果你需要回到之前的链接,那么禁用所有子链接,修复链接,然后继续。

  • 一个链接可以有多个子链接,但只有一个父链接。

  • 在每次修改后,无论修改得多小,都要在 RViz 中进行验证。不要尝试同时修改多个原点元素。改变一个,验证它,然后进行下一个。

  • 你可以通过打印 TF 树来验证所有链接之间的关系。

最后,这个过程并不复杂。严格按照这些步骤操作将确保你第一次就能正确构建 URDF。这不仅会让你对自己的操作有信心,而且从长远来看,这将节省你大量时间,并且你的 URDF 将更加整洁。

我们现在拥有了创建一个完整的 URDF 所需的所有信息。

为移动机器人编写 URDF

你已经看到了在 URDF 文件中添加链接和关节的完整过程。通过重复这个过程几次,你可以为任何机器人创建一个完整的模型。

我们现在将使用这个过程来创建一个移动机器人。然后我们将把这个 URDF 作为下一章的基础。

我首先会向你展示最终的机器人模型,这样你就能有一个概念,然后我们将逐步构建 URDF。随着我们的构建,你将获得机器人的规格。我鼓励你在阅读本节的同时跟随操作,甚至可以边读边写代码。这对于你熟悉 URDF 来说是一个很好的实践。作为提醒,你可以在 GitHub 上找到完整的代码。

我们想要实现的目标

在编写任何代码之前,定义我们想要实现的目标是非常重要的。最终的结果在 RViz 中将看起来像这样:

图 11.6 – 移动机人的最终结果

图 11.6 – 移动机人的最终结果

我们将从代表机器人主要结构(底盘)的盒子开始,这个盒子与base_link我们创建的相同。

然后,我们将在底盘的两侧各添加两个轮子。这两个轮子将具有连续旋转。最后,为了稳定性,我们添加一个转向轮(球体),这将帮助机器人在 Gazebo 中模拟时不会向前倾倒。这个转向轮将是一个固定关节,我们不会给它添加任何运动。

在此基础上,最后,我们还将添加另一个名为base_footprint的链接(没有视觉,我们可以将其视为虚拟链接),它是base_link在地面上的投影。我将在我们做的时候进一步解释。

要开始,回到我们之前编写的my_robot.urdf文件,并保留base_link。同时移除shoulder_link以及base_shoulder_joint

现在,让我们将两个轮子添加到机器人的侧面。

添加轮子

我们将逐个添加轮子,从右轮开始。除非你已经是一位专家,否则每次只添加一个链接和一个关节是很重要的。

右轮

要添加右轮,我们将遵循我们刚刚描述的五步流程。让我们把这个链接命名为right_wheel_link。对于视觉,我们将使用一个半径为0.1米,长度为0.05米的圆柱体。以下是链接的代码(步骤 1):

<link name="right_wheel_link">
    <visual>
        <geometry>
            <cylinder radius="0.1" length="0.05" />
        </geometry>
        <origin xyz="0 0 0" rpy="0 0 0" />
        <material name="gray" />
    </visual>
</link>

由于轮子将连接到base_link(绿色),我们选择灰色以产生对比。正如你所见,我们将所有原点元素设置为0。我们将在过程结束时再回到这些元素。

现在,让我们在底盘和右轮之间添加一个关节(步骤 2)。

<joint name="base_right_wheel_joint" type="fixed">
    <parent link="base_link" />
    <child link="right_wheel_link" />
    <origin xyz="0 0 0" rpy="0 0 0" />
</joint>

当你更有经验时,你可以在创建关节时设置运动类型,但让我们一步一步来。现在,我们将类型设置为fixed,这样我们就可以首先设置关节原点,然后指定运动类型。这将使事情更简单,更不容易出错。

使用这段代码,你现在已经可以在 RViz 中可视化 URDF 了。禁用right_wheel_link的视觉(RobotModel | Links),因为我们现在不需要它,这可能会在设置关节原点时让我们感到困惑。

那么,问题是:我们将right_wheel_link框架相对于base_link框架放置在哪里(步骤 3)?让我们看看每个轴:

  • -0.15米偏移量。

  • -0.2米(箱子宽度的一半)。然后,稍后,你需要在轮子的视觉中添加一个偏移量,就像我们在之前的例子中对shoulder_link所做的那样。

  • 或者,你也可以在关节上添加一个小的额外偏移量,这样轮子就会在箱子外面,视觉将围绕框架中心。对于轮子和一些传感器来说,这是一个好主意——例如,当使用激光雷达时,这是使扫描正常工作的必要条件。然后我们需要添加-0.2米,以及额外的-0.025米(轮子长度的一半)。总偏移量是-0.225米。

  • Z 轴平移(蓝色轴):这里不需要任何偏移,因为我们希望轮子的中心位于盒子的底部。

我们已经有了三个平移值。这就足够了。你可能认为我们必须旋转关节轴,因为视觉方向不正确。然而,这是最常见的错误之一,而且你可能会开始修改错误的值来修复错误的问题。正如我们在过程中看到的那样,我们首先禁用视觉来固定关节,然后,而且只有然后,我们才固定视觉。

让我们再次修改 标签内的 标签(再次强调:不是在链接中,而是在关节中):

<origin xyz="-0.15 -0.225 0" rpy="0 0 0" />

然后,再次启动 RViz,禁用轮子的视觉(这个视觉仍然不正确,但不是问题),你会发现关节放置在盒子外面一点。

我们现在可以轻松地添加运动(步骤 4)。由于轮子将不断旋转(没有最小或最大位置),我们选择连续类型。我们还需要指定旋转轴。通过在 RViz 中查看机器人模型,我们可以看到我们必须选择 Y 轴(轮子应该围绕绿色轴旋转)。

因此,我们相应地修改了 标签:

<joint name="base_right_wheel_joint" type="continuous">
    <parent link="base_link" />
    <child link="right_wheel_link" />
    <origin xyz="-0.15 -0.225 0" rpy="0 0 0" />
    <axis xyz="0 1 0" />
</joint>

对于 连续 类型,不需要指定 标签,就像我们之前对 revolute 类型所做的那样。

现在,你可以再次启动 RViz,禁用轮子视觉,并将名为 base_right_wheel_link 的新光标移动到 Joint State Publisher 窗口中。你应该看到关节正确地围绕基础旋转。

注意

使用光标,你会看到一个最小值约为 -3.14 和一个最大值约为 3.14(总共 360 度)。不用担心这个,这只是一个图形元素。由于关节类型是连续的,当我们后来控制它时,将没有最小或最大位置。

对于关节来说,这就结束了。有了这个,TF 将会正确生成。我们现在可以完成这个过程,并修复链接的视觉(步骤 5)。如果你重新启用轮子视觉,你会看到它没有正确定位。你需要添加一个在 X 轴上的 90 度旋转(围绕红色轴)。这对应于 π/2,或者大约 1.57 弧度(我们将在本章后面看到如何使用 π 的精确值)。

让我们修改 right_wheel_link 标签:

<origin xyz="0 0 0" rpy="1.57 0 0" />

再次启动 RViz,现在一切应该都正常:轮子视觉将正确放置(正好在盒子外面)并且方向正确。当你移动关节的光标时,轮子将正确转动。

右轮可能是这个机器人最复杂的一部分,但正如你所看到的,如果你按照顺序遵循这个过程,应该不会有问题。你可以确信所有值都是正确的,而且没有错误会传播到你添加的下一个链接。

现在让我们为左轮编写代码。

左轮

由于左轮与右轮相同,但位于箱子的对面,编写代码将相当直接。这里我不会重复整个过程,只会展示最终的标签。

让我们从链接开始:

<link name="left_wheel_link">
    <visual>
        <geometry>
            <cylinder radius="0.1" length="0.05" />
        </geometry>
        <origin xyz="0 0 0" rpy="1.57 0 0" />
        <material name="gray" />
    </visual>
</link>

这与right_wheel_link的代码相同。

然后,左轮将连接到底盘,因此left_wheel_link的父级将是base_link。以下是关节的代码:

<joint name="base_left_wheel_joint" type="continuous">
    <parent link="base_link" />
    <child link="left_wheel_link" />
    <origin xyz="-0.15 0.225 0" rpy="0 0 0" />
    <axis xyz="0 1 0" />
</joint>

所有的东西都一样,只是在Y-轴上的偏移不同。对于右轮,我们必须在负方向上移动,但对于左轮,我们在正方向上移动。

需要检查的重要事项是,当你将两个光标在Joint State Publisher窗口的正方向移动时,两个轮子都朝同一方向旋转。如果你有这种情况,那么你的差速驱动系统设计是正确的。

我们现在可以添加万向轮以提高机器人的稳定性。

添加万向轮

当我们在 Gazebo 模拟器中稍后添加物理和重力时,你可以猜到机器人会落在前面,因为它不平衡。为了解决这个问题,我们将添加一个称为万向轮的东西。这与桌子椅子下的轮子原理相同。

为了简化问题,万向轮将用一个球体(半径为0.05 m)来表示。对于运动,即使轮子在旋转,这也不是我们用 ROS 控制的旋转。这是一个被动旋转;因此,我们将考虑关节为固定

让我们首先创建链接(步骤 1):

<link name="caster_wheel_link">
    <visual>
        <geometry>
            <sphere radius="0.05" />
        </geometry>
        <origin xyz="0 0 0" rpy="0 0 0" />
        <material name="gray" />
    </visual>
</link>

这里没有太多复杂的事情。现在,万向轮将连接到机器人的底盘。让我们添加关节(步骤 2)。为了遵循这个过程,我们首先将所有原点元素设置为0

<joint name="base_caster_wheel_joint" type="fixed">
    <parent link="base_link" />
    <child link="caster_wheel_link" />
    <origin xyz="0 0 0" rpy="0 0 0" />
</joint>

启动 RViz 并禁用视觉。从这一点来看,让我们看看万向轮相对于底盘原点的位置在哪里(步骤 3):

  • 0.2 m。

  • 0

  • 半径设置为0.05,这样滚轮的直径(0.1 m)就对应于轮子的半径。因此,为了使轮子和万向轮在地面上对齐,我们需要将Z-轴偏移-0.05 m。如果你对此不太确定,很简单:尝试一些值,并在 RViz 中查看结果。

对于关节,我们不需要设置任何旋转,因为我们不会移动万向轮,它是一个球体。让我们在标签的标签中应用偏移:

<origin xyz="0.2 0 -0.05" rpy="0 0 0" />

现在,万向轮将被正确地放置在底盘下方,你可以验证两个轮子和万向轮的底部似乎是对齐的。在这里,没有必要设置移动类型(步骤 4)或固定视觉(步骤 5)。

机器人模型现在完成了。我们还将做一件事,以更好地为后续步骤准备机器人。

额外链接 - 底部足迹

机器人设计正确,当我们添加控制时,它将正常工作。然而,我们可以进行一项改进。

如果你查看 RViz 中的机器人,base_link 框架的 z 偏移量与三个车轮底部不在同一高度。这是可以的,但最好将机器人的原点与机器人将放置的地面对齐。

这不仅会使机器人在 RViz 中的外观更好(尽管这不是很重要),而且还会使未来的事情更容易处理。一个例子是当你想从一个对接站创建一个到移动机器人的变换,或者从一个机器人到另一个机器人。另一个例子是如果你想使用 Navigation 2 堆栈。如果所有机器人的原点都在地面上,那么你就可以在 2D 中工作,这更容易处理。

因此,添加一个名为 base_footprint 的虚拟链接是很常见的,它将是 base_link 在地面上的投影。我们说这个链接是 虚拟的,因为它不包含任何视觉元素;它只是在空间中定义的一个额外的框架。以下是链接的代码:

<link name="base_footprint" />

如你所见,这个链接非常简单,因为我们没有包含任何 标签。对于链接名称,我们通常从刚体部分名称开始,并添加 _link 后缀。在这里,我们做一个例外。你将在许多现有移动机器人的 URDF 文件中找到这个 base_footprint 名称。

现在,我们可以添加一个新的 固定 关节,以 base_footprint 作为父节点,以 base_link 作为子节点:

<joint name="base_joint" type="fixed">
    <parent link="base_footprint" />
    <child link="base_link" />
    <origin xyz="0 0 0.1" rpy="0 0 0" />
</joint>

我们在 Z 轴上应用了一个 0.1 米的偏移量,这对应于左右轮的半径。

注意

为了在 URDF 中组织所有链接和关节,我通常先写所有链接,然后写所有关节。你也可以决定交替使用链接和关节。没有正确或错误的方法;这是一个关于偏好的问题。

你现在可以在 RViz 中可视化最终结果。要获得正确的视图,点击 全局选项,然后在 固定框架 菜单中选择 base_footprint。你会看到车轮底部和 base_footprint 都与地面齐平。

当 RViz 仍在运行时,你可以打印和可视化 TF 树:

图 11.7 – 移动机器人的最终 TF 树

图 11.7 – 移动机器人的最终 TF 树

我们现在已经完成了 URDF。在我们完成这一章之前,让我们探索 Xacro,这将允许你改进你的 URDF 文件并使其更具可扩展性。

使用 Xacro 改进 URDF

你的机器人越复杂,URDF 就越大。随着你添加更多的链接和关节,你最终会遇到在机器人模型上缩放的问题。此外,我们迄今为止所写的内容并不那么动态:所有值都是硬编码的。

Xacro 是一个额外的 ROS 功能,你可以用它来解决所有这些问题。现在我们将看到如何使 URDF 文件与 Xacro 兼容,如何创建变量和函数,以及如何将你的 URDF 分割成几个文件。

使用 Xacro,你的 URDF 文件将变得更加动态和可扩展。所有严肃的 ROS 2 项目都使用 Xacro,因此学习如何与之协同工作是很重要的。

让我们从设置开始。

使 URDF 文件与 Xacro 兼容

我们首先确保我们的 URDF 文件可以使用 Xacro 功能。在开始任何操作之前,让我们确认 Xacro 是否已安装(它应该已经随着我们之前安装的所有包一起安装了):

$ sudo apt install ros-<distro>-xacro

现在,为了在你的 URDF 文件中使用 Xacro,你需要进行两个更改。

首先,更改文件扩展名。文件当前命名为my_robot.urdf。对于 Xacro,你将使用.xacro扩展名。一个常见的做法是为你的机器人主 URDF 文件使用.urdf.xacro扩展名,因此文件将被命名为my_robot.urdf.xacro(重要的是要在末尾有.xacro)。

一旦你更改了扩展名,打开文件并修改标签:

<robot name="my_robot" >

每次你想在 URDF 文件中使用 Xacro,你都必须添加这个 xmlns:xacro 参数

设置到此结束。现在,为了在 RViz 中可视化 URDF,你将运行与之前相同的命令,但使用新的文件名:

$ ros2 launch urdf_tutorial display.launch.py model:=/home/<user>/my_robot.urdf.xacro

现在,让我们了解如何使用 Xacro 中的变量。

Xacro 属性

在编程中,你最先和最重要的学习之一是如何使用变量。URDF 中没有变量,但你可以使用 Xacro 来使用它们。在这里,变量被称为属性

使用 Xacro 属性,我们将在文件开始处指定诸如尺寸之类的值,并在标签中使用这些属性。这样,值和计算就不会是硬编码的。这将使事情更少混淆,更易读。此外,如果我们需要修改机器人的尺寸,我们只需修改文件开始处的值。

此外,值得注意的是,Xacro 属性被视为常量变量。在你设置它们的值之后,你将不再修改它们。

要声明和定义一个 Xacro 属性,你将使用xacro:property标签并提供两个参数:namevalue

让我们在文件的开始处(在标签内)声明一些属性:

<xacro:property name="base_length" value="0.6" />
<xacro:property name="base_width" value="0.4" />
<xacro:property name="base_height" value="0.2" />
<xacro:property name="wheel_radius" value="0.1" />
<xacro:property name="wheel_length" value="0.05" />

这些是我们计算 URDF 中其他所有值所需的所有值。

然后,要使用 Xacro 属性,你只需写出\({property_name}**。你也可以进行计算。例如,要乘以 2.5,你将写出**\){property_name * 2.5}。有了这些信息,让我们修改base_link内的内容,以删除任何硬编码的值:

<geometry>
    <box size="${base_length} ${base_width} ${base_height}" />
</geometry>
<origin xyz="0 0 ${base_height / 2.0}" rpy="0 0 0" />

如你所见,我们仅使用属性来指定盒子大小。最有趣的部分是我们如何计算 Z 轴上的视觉偏移。写作${base_height / 2.0}比仅仅写作0.1要明确得多。这不仅更动态,而且我们还能更好地了解这个计算的目的。想象一下六个月后回到这个 URDF,试图弄清楚偏移值为什么是0.1,而没有任何上下文。有了这个属性,就不会有任何可能的疑问。

现在,让我们修改右轮和左轮的视觉:

<geometry>
    <cylinder
        radius="${wheel_radius}" length="${wheel_length}"
    />
</geometry>
<origin xyz="0 0 0" rpy="${pi / 2.0} 0 0" />

注意

Xacro 属性或常数 pi 已经定义。你不必硬编码 pi 的近似值,可以直接使用 ${pi}

最后,这是 caster 轮的代码:

<geometry>
    <sphere radius="${wheel_radius / 2.0}" />
</geometry>
<origin xyz="0 0 0" rpy="0 0 0" />

如你所见,我们没有定义 caster_wheel_radius 属性。这是因为 caster 轮的半径需要与左右轮的半径成比例。它需要是值的一半,这样两个轮子和 caster 轮都可以接触地面,同时使机器人保持稳定。通过在这里使用 Xacro 属性,如果我们修改轮子半径,那么 caster 轮将自动调整大小以使机器人保持稳定。

我们现在已经修改了所有链接;让我们更改关节原点中的值。对于 base_joint,我们有以下内容:

<origin xyz="0 0 ${wheel_radius}" rpy="0 0 0" />

base_right_wheel_joint 要复杂一些。然而,再次强调,通过编写这段代码,我们将使计算更加可读,并减少未来出错的可能性:

<origin xyz="${-base_length / 4.0} ${-(base_width + wheel_length) / 2.0} 0" rpy="0 0 0" />

base_left_wheel_joint 将与之前相同,只是在 Y 轴上的符号是正的。我们以 base_caster_wheel_joint 结束:

<origin xyz="${base_length / 3.0} 0 ${-wheel_radius / 2.0}" rpy="0 0 0" />

所有这些代码更改都没有修改机器人模型。在 Rviz 中可视化时,它应该看起来相同。

为了测试一切是否正常工作,尝试修改文件开头的一些 Xacro 属性值。在 RViz 中的机器人模型将具有不同的尺寸,但它仍然应该是有意义的。

Xacro 属性就到这里。这个概念并不难理解和应用,尤其是如果你已经熟悉变量和常量。现在让我们转向函数或宏。

Xacro 宏

Xacro 宏在编程中相当于函数。使用 Xacro,宏就像一个模板:它是一段可以重复使用不同值(参数)的 XML 代码。宏不返回任何内容。

当你需要多次复制一个链接或关节时,宏非常有用。想象一个有四个摄像头的机器人。你可以为摄像头链接创建一个宏,然后调用宏而不是重写相同的代码四次。

使用我们的机器人模型,right_wheel_linkleft_wheel_link 几乎有相同的代码。唯一的区别是链接的名称。让我们为这些链接创建一个宏。

要创建一个宏,你将使用 xacro:macro 标签,并给出一个名称以及一个参数列表。你可以指定零个或任意多个参数——只需用空格分隔即可。以下是一个示例:

<xacro:macro name="wheel_link" params="prefix">
    <link name="${prefix}_wheel_link">
        <visual>
            <geometry>
                <cylinder radius="${wheel_radius}"
                          length="${wheel_length}" />
            </geometry>
            <origin xyz="0 0 0" rpy="${pi / 2.0} 0 0" />
            <material name="gray" />
        </visual>
    </link>
</xacro:macro>

这段代码本身不会做任何事情。我们需要调用它,就像调用一个函数一样。要调用一个宏,你将编写以下内容:

<xacro:name param1="value" param2="value" />.

移除 right_wheel_linkleft_wheel_link,改为以下内容:

<xacro:wheel_link prefix="right" />
<xacro:wheel_link prefix="left" />

前缀参数中的 "right" 值将被应用于宏内部的链接名称,使其成为 right_wheel_link。对于左轮也是如此。

如你所见,宏可以帮助你减少代码重复。在这个例子中,好处并不大,但如果你需要重复某些链接或关节超过三次,那么宏可以非常有用。此外,如果你正在创建将被其他人使用的 URDF 的一部分,编写一个宏可以帮助他们轻松地将你的代码集成到他们的代码中,并使用他们可以提供的不同参数对其进行自定义。

Xacro 属性和宏将允许你使一个 URDF 文件更加动态。让我们通过看看如何使一个 URDF 更加可扩展来结束本节。

在另一个文件中包含 Xacro 文件

多亏了 Xacro,你可以将你的 URDF 分成几个文件。

这有助于分离你机器人的不同部分,例如,主核心基座和添加在顶部的额外传感器。如果你组合两个机器人,例如,一个在移动机器人顶部的机械臂,你可以为每个机器人创建一个 URDF,并将它们组合成第三个。另一个好处是协作。在文件中创建一个宏,其他开发者可以包含它,将使他们更容易与你合作。

回到我们的 URDF,让我们将文件分成三个部分:

  • common_properties.xacro:这个文件将包含适用于我们机器人应用任何部分的材料标签和其他属性

  • mobile_base.xacro:这个文件将包含特定于移动基座的属性、宏、链接和关节

  • my_robot.urdf.xacro:在这个主文件中,我们包含前两个文件

通过这样做,我们将使 URDF 更加动态,并且在未来更容易修改。如果你想组合几个移动基座或添加其他机器人或传感器,你可以创建更多的 Xacro 文件,并将它们包含到主文件中(my_robot.urdf.xacro)。

现在,创建两个额外的文件,让我们看看如何组织代码。确保将所有三个文件放在同一个目录中。在下一章中,我们将将它们正确地安装在一个 ROS 2 包中,但现在,将它们都放在同一个地方(这将使将前两个文件包含到第三个文件中更容易,使用相对路径)。

让我们从 common_properties.xacro 开始。以下是这个文件中要写的第一件事:

<?xml version="1.0"?>
<robot >
</robot>

所有 Xacro 文件都必须包含此代码,其中包含一个带有 xmlns:xacro 属性的 标签。

注意

不要在 <robot> 标签中添加 name 属性。这个属性只会在主 Xacro 文件中添加一次。

然后,在 标签内部,你可以复制并粘贴我们之前写过的两个 标签。然后你可以从 my_robot.urdf.xacro 中移除这些标签。

mobile_base.xacro 文件中,你也会像在 common_properties.xacro 中做的那样,以 <?xml> 标签开始。

然后,你可以复制并粘贴所有与移动基座相关的 xacro:propertyxacro:macro 标签,这基本上是剩下的所有标签。

my_robot.urdf.xacro 中,我们剩下的是 <?xml> 标签和包含 namexmlns:xacro 属性的 标签。

要在另一个文件中包含一个 Xacro 文件,你需要编写一个 xacro:include 标签,并通过 filename 属性提供文件的路径。以下是 my_robot.urdf.xacro 的最终内容:

<?xml version="1.0"?>
<robot name="my_robot" >
    <xacro:include filename="common_properties.xacro" />
    <xacro:include filename="mobile_base.xacro" />
</robot>

我们的 URDF 现在已经分成几个文件,并使用了 Xacro 属性和宏。通过这些修改,我们没有改变机器人模型本身,但使 URDF 更具动态性和可扩展性,同时也更容易阅读。

作为提醒,你可以在书籍的 GitHub 仓库的 ch11 文件夹中找到本章的完整代码——所有 URDF 和 Xacro 文件。

摘要

在本章中,你发现了为机器人编写 URDF 的完整过程。

URDF 定义了一个机器人模型,并包含两个主要部分:链接和关节。链接是机器人中的一个刚性部分,它本身不执行任何操作。链接可以有一个视觉(如盒子、圆柱体、球体或从 CAD 软件导出的网格等简单形状)。你可以将机器人看作是一系列链接的组合。关节定义了两个链接之间的连接。它指定了哪个链接是父链接,哪个是子链接,以及两个链接连接的位置以及它们相对于彼此的运动方式。

你了解到,你在一个关节中写入的内容将定义机器人的 TF。最终,在 URDF 中的所有关节都创建了一个 TF 树。

你也看到了在之前的基础上添加新链接和关节的完整过程。确保每次都遵循这个流程。为了帮助你开发和验证流程的每一步,你了解到使用 RViz 这样的工具来可视化机器人模型,以及 tf2_tools 来查看 TF 树是一个好主意。

然后,你了解到你也可以通过 Xacro 来改进你的 URDF。你可以定义一些属性和宏,甚至将 URDF 分成几个文件。当你的应用程序扩展时,这将非常有用,并使协作更容易。

如本章所述,创建一个机器人的 URDF 是第一步。这将允许你生成 TFs,它们是任何使用 ROS 的机器人的骨架。

现在你已经可以创建 URDF 了,让我们看看如何开始打包我们的应用程序,并了解我们应该从哪里开始,以便正确生成 TFs(不使用 urdf_tutorial 包)。这将是下一章的重点。**

第十二章:发布 TFs 和打包 URDF

到目前为止,在本书的第三部分中,你已经对 TFs 进行了介绍,并学习了如何编写 URDF,这将用于生成你的机器人应用程序的 TFs。现在我们需要做两件事来进一步学习。

首先,为了更快,我们使用了urdf_tutorial包来发布 TFs。这对于开始和可视化机器人模型来说很棒,但我们在实际应用中不会使用这个包。问题是:使用我们创建的 URDF,我们如何为我们的应用程序生成 TFs?我们需要启动哪些节点?我们将首先通过实验了解我们需要启动哪些节点和参数,以便正确地为我们的应用程序生成 TFs。从这一点出发,我们将能够创建我们自己的启动文件。

其次,URDF 现在是一系列三个 Xacro 文件,放置在主目录中。为了启动一个合适的 ROS 2 应用程序,我们将创建一个包来组织和安装 URDF、启动文件等。

到本章结束时,你将能够正确地打包你的 URDF 并发布 ROS 2 应用程序的 TFs。这个过程适用于任何机器人,你在这里创建的包将成为任何进一步开发的基础,包括本书后面将要介绍的 Gazebo 仿真。

作为本章的起点,我们将使用书籍 GitHub 仓库中ch11文件夹内的代码(github.com/PacktPublishing/ROS-2-from-Scratch)。你可以在ch12文件夹中找到最终代码。

在本章中,我们将涵盖以下主题:

  • 理解如何使用我们的 URDF 发布 TFs

  • 从终端启动所有节点

  • 创建一个包来安装 URDF

  • 编写一个启动文件来发布 TFs 并可视化机器人

理解如何使用我们的 URDF 发布 TFs

我们将首先了解我们需要启动哪些节点和参数,以便为我们的应用程序发布 TFs。然后,有了这些知识,我们将能够启动所需的节点,打包应用程序,并编写一个启动文件。

正如我们在这本书中经常做的那样,我们将从一个发现阶段开始,通过实验。我建议你在阅读本节时运行所有命令。

robot_state_publisher 节点

基本上,在本章中,我们想要复制urdf_tutorial包中所做的工作,以便我们可以自己发布 TFs。那么,让我们再次启动display.launch.py启动文件,使用上一章中的 URDF,并进行一些自我检查:

$ ros2 launch urdf_tutorial display.launch.py model:=/home/<user>/my_robot.urdf.xacro

在第二个终端中,启动rqt_graph来可视化当前正在运行的节点。如果屏幕为空,请刷新视图。你会看到类似这样的内容:

图 12.1 – 使用 urdf_tutorial 运行的节点

图 12.1 – 使用 urdf_tutorial 运行的节点

我们看到 /tf 主题,这是这里最重要的东西。这是任何 ROS 应用程序正常工作所必需的。

现在,/tf 主题上发布了什么内容?正如你所见,有一个名为 /robot_state_publisher(在文本中,我们将写作 robot_state_publisher,不带前面的斜杠)。这个节点从哪里来?robot_state_publisher 是一个已经为你准备好的核心节点,你可以使用它。它是你用 ROS 2 安装的软件包集合的一部分。这个节点将发布你机器人的 TFs。你将在任何需要 TFs 的 ROS 2 应用程序中启动它。大多数时候,你不需要自己发布任何 TF,因为这将由 robot_state_publisher 节点处理。

现在我们知道了我们必须启动这个节点,需要哪些输入?

robot_state_publisher 的输入

为了使 robot_state_publisher 节点正确工作,你需要提供两样东西:URDF 和关节状态。让我们从第一样开始。

URDF 作为参数

到目前为止,你可能想知道:URDF 去哪里了?我们在 rqt_graph 中看到了一些节点和主题,但我们没有看到我们创建的 URDF 的使用。

保持 urdf_tutorial 中的 display.launch.py 运行,并在另一个终端中列出 robot_state_publisher 节点的所有参数:

$ ros2 param list /robot_state_publisher

你会看到很多主题,但我们这里关心的是名为 robot_description 的主题。然后,你可以从这个参数中读取值:

$ ros2 param get /robot_state_publisher robot_description

这样,你将在终端中看到整个 URDF(或者更准确地说,从你提供的 Xacro 文件生成的 URDF)。

因此,当你启动 robot_state_publisher 节点时,你需要提供一个名为 robot_description 的参数内的 URDF。

注意

rqt_graph 中,你可以看到 robot_state_publisher 正在发布到 /robot_description 主题。从这个主题获得的消息也包含了 URDF 内容。这可以用来从任何其他节点检索 URDF,使用订阅者。

这就是第一个输入的全部内容;让我们看看第二个。

关节状态主题

为了发布 TFs,robot_state_publisher 节点需要 URDF,但也需要每个关节的当前状态。

你可以在 rqt_graph 中看到 /joint_states 主题,如图 12.1 所示。这个主题包含了你在真实机器人中从编码器或控制反馈中读取的内容。例如,如果你有一些轮子,你会知道这些轮子的速度和/或位置。你将把这些信息喂入 /joint_states 主题。如果你有一个机械臂,你通常在每个轴上都有编码器来读取轴的当前位置。

当我们使用 Gazebo 模拟机器人时,我们将使用一个插件来自动发布关节状态。实际上,无论是在模拟模式还是对于真实机器人,你通常都会有节点为你做这件事(要进一步了解这一点,阅读完这本书后请查看 ros2_control——你可以在本书的最后一章找到关于它的额外资源)。所以,你需要知道的是,这个 /joint_states 主题非常重要,因为它被 robot_state_publisher 所需要。在这个主题上的发布是由现有的 ROS 2 插件完成的。

目前,因为我们没有真实的机器人或 Gazebo 模拟,我们将使用 joint_state_publisher 节点(带有 Joint State Publisher 窗口),它将发布我们在光标上选择的任何值。例如,如果你为 base_right_wheel_joint 选择 1.0 弧度,那么 1.0 将被发布在该关节的 /joint_states 主题上,并被 robot_state_publisher 接收和使用。

在这里,重要的是要明确关节状态和 TF 之间的区别。关节状态仅仅是关节的 当前状态。例如,对于一个轮子:当前的速率是多少?对于一个机械臂的轴:电机的当前角位置是多少?状态是一个数据点,在特定时间,对于你机器人中的一个关节。它不指定关节之间的关系,也不指定它们相对于彼此的位置——这就是 TF 的作用。

回顾 – 如何发布 TF

让我们快速回顾一下发布 TF 所需的内容。这是 urdf_tutorial 为我们做的事情,也是从现在起我们将自己做的事情。

图 12.2 – 发布 TF 所需的节点和输入

图 12.2 – 发布 TF 所需的节点和输入

这里是我们需要做的事情:

  1. 启动 robot_state_publisher 节点。这个节点已经安装好了。我们提供 URDF 作为 robot_description 参数。

  2. /joint_states 主题上发布所有关节的当前状态。这通常由编码器、模拟器(如 Gazebo)或来自 joint_state_publisher 节点的 虚假 数据自动为你完成。

这就是你正确发布应用程序 TF 所需要做的所有事情。这些 TF 将随后被其他节点、插件和堆栈使用,例如,Navigation 2 或 MoveIt 2 堆栈(我们不会在本书中涵盖这些,但它们是之后一个好的学习主题——本书最后一章将提供更多资源)。

从终端启动所有节点

在我们打包应用程序并编写启动文件之前,让我们在终端中启动所有需要的节点。这样做是一个最佳实践,这样你可以确保你的应用程序运行正常。然后,创建包和启动文件会更容易,因为你已经知道你需要包含的所有元素。

我们将使用上一节的结果并启动 robot_state_publisher 节点以及 joint_state_publisher 节点。除此之外,我们还将启动 RViz(这是可选的,仅用于可视化机器人模型)。

从终端发布 TFs

让我们发布 TFs。为此,我们将打开两个终端。

在第一个步骤中,启动 robot_state_publisher 节点。此节点的包和可执行文件名相同。要提供 robot_description 参数,你必须使用以下语法:"$(****xacro <path_to_urdf>)"

在终端 1 中,运行以下命令:

$ ros2 run robot_state_publisher robot_state_publisher --ros-args -p robot_description:="$(xacro /home/<user>/my_robot.urdf.xacro)"
[robot_state_publisher]: got segment base_footprint
[robot_state_publisher]: got segment base_link
[robot_state_publisher]: got segment caster_wheel_link
[robot_state_publisher]: got segment left_wheel_link
[robot_state_publisher]: got segment right_wheel_link

如果你看到这个,那么一切正常。robot_state_publisher 已经启动了 URDF,并且它已准备好在 /tf 主题上发布。现在,我们需要在 /joint_states 主题上添加一个发布者。我们将使用来自同一包的 joint_state_publisher_gui 可执行文件(注意额外的 _gui 后缀,表示 图形 用户界面)。

注意

提示:可执行文件名和节点名是两回事。可执行文件名是在 setup.py(对于 Python)或 CMakeLists.txt(对于 C++)中定义的。节点名在代码中定义,可能不同。在这里,我们启动了 joint_state_publisher_gui 可执行文件,但节点名是 joint_state_publisher

在终端 2 中,运行以下命令:

$ ros2 run joint_state_publisher_gui joint_state_publisher_gui

这将打开我们之前在实验 TFs 和 URDF 时使用的 Joint State Publisher 窗口。你将在光标上看到的值将被发布到 /joint_states 主题,并由 robot_state_publisher 接收。

这基本上就是我们需要做的。这将成为你的 ROS 2 应用程序的核心——当然,我们还需要将其打包并从启动文件中启动。

如果你运行 rqt_graph,你将看到与 图 12.1 中相同的节点和主题。你还可以打印 TF 树(ros2 run tf2_tools view_frames)并监听终端中的 /tf 主题(ros2 topic echo /tf)。

在 RViz 中可视化机器人模型

在我们之前所做的基础上,我们可以在 RViz 中可视化机器人。这是可选的,你将在开发应用程序时主要做这件事。当一切正常并且你切换到生产模式后,你将不需要启动 RViz。

启动和配置 RViz

让我们启动 RViz 并看看如何可视化机器人模型以及 TFs。

保持 robot_state_publisherjoint_state_publisher 节点运行。然后,在终端 3 中,运行以下命令:

$ ros2 run rviz2 rviz2

这将打开 RViz,但正如你所看到的,没有机器人模型,左侧菜单中也有一些错误:

图 12.3 – Rviz 没有机器人模型和一些错误

图 12.3 – RViz 没有机器人模型和一些错误

我们需要做一些配置才能正确地可视化机器人模型和 TFs。然后,我们将能够保存此配置并在下次启动 RViz 时重用它。

按照以下步骤配置 RViz:

  1. 在左侧菜单中,将 map 滚动到 base_footprint。之后,全局状态:错误 应该更改为 全局 状态:OK

  2. 在左侧单击 添加 按钮,向下滚动,然后双击 RobotModel。你将在 RViz 的左侧出现一个新菜单。

  3. 打开这个新的 /robot_description。之后,机器人模型应该出现在屏幕上。

  4. 再次单击 添加 按钮,向下滚动,然后双击 TF。这将打开一个新菜单,你将在屏幕上看到 TFs。

  5. 如果你想要像之前一样透过模型查看,可以将 1 更改为 0.8,例如。

  6. 你可以移除右侧的额外菜单(视图)和底部的菜单(时间),以便为机器人腾出更多空间。

在所有这些设置下,你应该会看到机器人模型和 TFs,就像我们之前使用 urdf_tutorial 包可视化 URDF 时的样子:

图 12.4 – 带有机器人模型和 TFs 的 RViz

图 12.4 – 带有机器人模型和 TFs 的 RViz

保存 RViz 配置

每次你启动 RViz 时,你都需要重复这些步骤。为了避免这样做,我们将保存配置。

单击 文件 | 另存为配置。让我们将文件命名为 urdf_config.rviz(对于这些文件,使用 .rviz 扩展名),并将其暂时放置在你的家目录中。

确保你可以看到文件,使用文件管理器或终端。如果你没有正确保存文件,你需要手动重新进行完整配置。一旦文件保存,你就可以停止 RViz(在终端中按 Ctrl + C)。

然后,当你再次启动 RViz 时,你可以添加一个额外的 -d 参数,并指定配置文件的路径:

$ ros2 run rviz2 rviz2 -d /home/<user>/urdf_config.rviz

这将启动 RViz,就像你保存的那样:相同的菜单,相同的视图,相同的缩放,等等。我们将在本章中重复使用此配置文件。

注意

如果你希望更改配置,你只需要在 RViz 中修改你想要的任何设置,保存一个新的配置文件,并使用这个文件。

我们现在拥有所需的一切:URDF 文件和 RViz 配置文件,我们知道必须启动哪些节点以及如何启动它们。

让我们现在将所有内容正确地组织到一个 ROS 2 包中。我们首先创建包,然后添加一个启动文件以一次性启动所有节点。

创建一个包来安装 URDF

我们创建的所有文件现在都在我们的家目录中。是时候创建一个 ROS 2 包并将所有文件移动到正确的位置,以便它们可以在我们的 ROS 2 工作空间中安装和使用。

我们将首先创建一个专门用于机器人模型的包。然后,我们将安装此应用程序所需的所有文件。这将允许我们在编写启动文件时使用 URDF 和 RViz 文件,以启动我们之前看到的所有节点。

让我们创建一个新的包,但在我们这样做之前,创建一个新的 ROS 2 工作空间可能是个好主意。

添加一个新的工作空间

到目前为止,我们的 ros2_ws 工作区包含了本书 第二部分 中使用的所有代码,包括各种示例来阐述核心概念,Turtlesim 的机器人控制器,一些自定义接口和启动文件。对于 第三部分 项目,我们不需要这些;因此,我们不会继续向这个工作区添加更多内容,而是创建一个新的工作区。作为一个一般规则,如果你有两个不同的应用程序,你将有两个不同的工作区。

然后,创建一个名为 my_robot_ws 的新工作区。一个好的做法是将工作区命名为你的应用程序或机器人名称。这将在长期内帮助你避免混淆。

在你的家目录中创建一个新的工作区:

$ mkdir ~/my_robot_ws

然后,在这个工作区内部创建一个 src 目录:

$ mkdir ~/my_robot_ws/src

现在,并且这非常重要,你可以拥有任意多的 ROS 2 工作区,但你不应该同时使用两个不同应用程序的工作区。

你目前每次打开终端时都在源化 ros2_ws 工作区。如果你记得,你在 .bashrc 中添加了一行额外的代码来做这件事:

source ~/ros2_ws/install/setup.bash

再次打开 .bashrc 文件并注释掉那一行(在行前添加 #)。现在,关闭所有终端,打开一个新的终端,然后构建并源化我们的新工作区:

$ cd ~/my_robot_ws/
$ colcon build

再次打开 .bashrc 并添加一行来源化这个新的工作区。.bashrc 的末尾将看起来像这样:

source /opt/ros/jazzy/setup.bash
#source ~/ros2_ws/install/setup.bash
source ~/my_robot_ws/install/setup.bash

作为提醒,我们首先源化全局 ROS 2 安装。然后,我们源化我们的工作区。注释掉不使用的工作区是非常实用的。这样,如果你想在这两个工作区之间切换,你只需要取消注释/注释这两行,关闭所有终端,然后打开一个新的终端来源化你想要使用的工作区。

创建一个 _description 包

现在我们已经正确配置并源化了这个新的空工作区,让我们在里面添加一个新的包。

为了命名包,我们使用机器人的名称,后面跟着 _description。这是许多 ROS 开发者使用的一个常见约定。通过使用这种命名格式,你可以清楚地知道这个包将包含你的机器人的 URDF 文件。这将使协作变得更加容易。

要创建这个新包,我们使用 ament_cmake 构建类型,就像我们创建一个 C++ 包一样,并且我们不指定任何依赖项。让我们创建这个包:

$ cd ~/my_robot_ws/src/
$ ros2 pkg create my_robot_description --build-type ament_cmake

目前,这个包是一个标准的 C++ ROS 2 包。然而,我们不会在其中编写任何节点。我们只需要这个包来安装我们的机器人模型。因此,你可以删除 srcinclude 目录:

$ cd my_robot_description/
$ rm -r include/ src/

到目前为止,你的包将只包含两个文件:package.xmlCMakeLists.txt

使用 IDE 打开工作区。如果你使用 VS code,运行以下命令:

$ cd ~/my_robot_ws/src/
$ code .

为了使事情更加简洁,我们将简化 CMakeLists.txt 文件。删除 find_package(ament_cmake REQUIRED) 行后面的注释,并且,因为我们现在不需要它,所以删除 if(BUILD_TESTING) 块。确保你保留 ament_package() 指令,它应该是文件的最后一行。

安装 URDF 和其他文件

现在我们有了 robot_description 包,让我们安装我们需要的所有文件。要安装一个文件,我们将遵循以下过程:

  1. 创建一个文件夹来存放文件。

  2. CMakeLists.txt 中添加一个指令来安装文件夹。

在这里,我们将看到如何安装 URDF 文件、自定义网格和 RViz 配置。

安装 Xacro 和 URDF 文件

要安装我们的 Xacro 和 URDF 文件,请进入 my_robot_description 包并创建一个名为 urdf 的新文件夹:

$ cd ~/my_robot_ws/src/my_robot_description/
$ mkdir urdf

你现在可以将所有三个 Xacro 文件放入这个 urdf 文件夹中:common_properties.xacro, mobile_base.xacro, 和 my_robot.urdf.xacro

一切都应该正常工作,但为了使 include 路径更加健壮,让我们修改 my_robot.urdf.xacro 内的 xacro:include 标签:

<xacro:include filename="$(find my_robot_description)/urdf/common_properties.xacro" />
<xacro:include filename="$(find my_robot_description)/urdf/mobile_base.xacro" />

而不是只提供相对路径(在这种情况下仍然应该可以工作),我们使用包名前的 find 关键字提供文件的绝对路径,以安装文件。

现在,打开 CMakeLists.txt,并添加安装 urdf 文件的指令:

find_package(ament_cmake REQUIRED)
install(
  DIRECTORY urdf
  DESTINATION share/${PROJECT_NAME}/
)
ament_package()

这将在构建包时在 share 目录内安装 urdf 文件夹。它将允许工作空间中的任何包找到你的机器人的 URDF。这实际上与我们之前在 第九章 安装启动文件和参数文件时所做的非常相似。

让我们继续讨论自定义网格。

安装自定义网格

如果你现在没有使用任何自定义网格(如果你是使用这本书从头开始学习 ROS 2,这可能是情况),你可以跳过这个小节,直接转到 RViz 配置。

当我们创建 URDF 的链接时,我在 第十一章 简要提到了自定义网格,那时我们正在创建链接。如果你意外地使用 标签在链接的 标签内包含自定义网格,你将不得不安装相应的网格文件(具有 .stl.dae 扩展名)。

在此情况下,在 my_robot_description 中,你会创建一个名为 meshes 的新文件夹:

$ cd ~/my_robot_ws/src/my_robot_description/
$ mkdir meshes

在这个文件夹中,你会添加所有你想要在 URDF 中使用的 .stl.dae 文件。然后,比如说,你添加了一个名为 base.stl 的文件。在 URDF 中,你将使用以下语法来包含它:

<mesh filename="file://$(find my_robot_description)/meshes/base.stl" />)

要安装 meshes 文件夹,你也需要在 CMakeLists.txt 中添加一个指令。因为我们之前已经添加了 install() 块,你只需要添加你想要安装的新文件夹的名称:

install(
  DIRECTORY urdf meshes
  DESTINATION share/${PROJECT_NAME}/
)

注意

当在 DIRECTORY 后添加新的文件夹名称时,你可以用空格分隔它们,或者将它们放在新的一行上;这不会产生影响。

对于自定义网格,这就是全部内容。即使我们现在不使用它们,我也想包括这个,这样你就有所有创建具有自定义形状的完整 URDF 所需的知识。

安装 RViz 配置

让我们通过安装我们之前保存的 RViz 配置来结束本节。这样,当我们稍后从启动文件启动 RViz 时,我们可以直接从包中使用这个配置。

在包内创建一个 rviz 文件夹:

$ cd ~/my_robot_ws/src/my_robot_description/
$ mkdir rviz

urdf_config.rviz 文件移动到这个新的 rviz 文件夹中。

现在,为了安装文件夹,将文件夹名称添加到 install() 指令中,在 CMakeLists.txt 内部:

install(
  DIRECTORY urdf meshes rviz
  DESTINATION share/${PROJECT_NAME}/
)

我们现在有了这个包所需的所有文件。在我们使用 colcon build 安装它们之前,让我们添加一个启动文件,这样我们就可以启动所有必要的节点来发布 TFs 以及在 RViz 中可视化机器人模型。

编写用于发布 TFs 和可视化机器人的启动文件

my_robot_description 包已经完成,但我们将添加一个启动文件,这样我们就可以启动我们在本章开头发现的节点和参数。这样,我们就可以在 RViz 中发布 TFs 并可视化机器人。这也会是一个关于启动文件的很好的实践练习,我们将在下一章构建机器人的 Gazebo 模拟时重用部分代码。

注意

通常,我们将所有启动文件添加到 _bringup 包中(用于启动和配置文件的专用包)。这里,我们做一个例外,因为这个启动文件将仅用于可视化和开发。我们将为这个应用程序编写的任何其他启动文件都将放置在 my_robot_bringup 包中(我们将在下一章创建)。

我们首先将使用 XML 编写启动文件,然后使用 Python。这将是 XML 启动文件比 Python 启动文件更容易编写的一个例子。

XML 启动文件

在编写任何启动文件之前,我们首先需要创建一个 launch 文件夹,我们将把所有我们的启动文件放在这个文件夹中,用于 my_robot_description 包。

创建和安装启动文件夹

要创建和安装一个 launch 文件夹,我们将遵循之前的过程。首先,创建文件夹:

$ cd ~/my_robot_ws/src/my_robot_description/
$ mkdir launch

然后,为了指定安装此文件夹的指令,只需在 CMakeLists.txt 中添加文件夹名称即可:

install(
  DIRECTORY urdf meshes rviz launch
  DESTINATION share/${PROJECT_NAME}/
)

由于 install() 指令已经配置好了,如果我们想安装一个新的文件夹,我们只需在其后添加其名称。目前,我们在这个包中安装了四个文件夹。

完成此操作后,在文件夹内创建一个新的启动文件。我们将将其命名为 display.launch.xml

$ cd launch/
$ touch display.launch.xml

现在,让我们为这个启动文件编写内容。

编写启动文件

在这个启动文件中,我们将简单地启动我们在本章开头发现和列出的节点和参数:

  • robot_state_publisher 使用 URDF 作为 robot_description 参数。此节点将在 /``tf 主题上发布。

  • joint_state_publisher 用于在 /``joint_states 主题上发布。

  • rviz2,因为我们还想可视化机器人模型。

我们已经知道如何从终端启动这些节点;现在,我们只需要将它们添加到一个启动文件中。

在启动文件中,首先打开并关闭一个 <****launch> 标签:

<launch>
</launch>

现在,请确保将以下所有行都写入此 标签内。你也可以为这些行添加缩进(四个空格)。

由于我们需要找到 URDF 文件和 RViz 配置文件的路径,我们在启动文件的开头添加了两个变量,使文件更干净、更易读。此外,如果你以后需要修改这些值,你知道你只需要修改文件顶部的变量。

我们还没有看到如何在启动文件中添加(常量)变量,但这并不复杂。你将使用 标签,并带有两个参数:namevalue。让我们添加我们需要的两个变量:

<let name="urdf_path" value="$(find-pkg-share my_robot_description)/urdf/my_robot.urdf.xacro" />
<let name="rviz_config_path" value="$(find-pkg-share my_robot_description)/rviz/urdf_config.rviz" />

然后,要使用变量,你可以写 $(****var name)

注意

虽然启动文件和 Xacro 文件之间的 XML 语法看起来很相似,但请确保不要混淆:要查找一个包,你在启动文件中使用 find-pkg-share,而在 Xacro 中使用 find要使用变量,你在启动文件中使用 $(var name),而在 Xacro 中使用 ${name}

现在,让我们逐个启动我们需要的所有节点。以下是 robot_state_publisher 节点的代码:

<node pkg="robot_state_publisher" exec="robot_state_publisher">
    <param name="robot_description"
           value="$(command 'xacro $(var urdf_path)')" />
</node>

这里没有什么特别的;我们使用与我们在终端中之前运行的命令相同的值。要在 XML 启动文件中指定要运行的命令,你可以使用 $(****command '...')

接下来,我们可以启动 joint_state_publisher 节点。在这里,我们使用带有 _gui 后缀的可执行文件来获取一个带有光标的图形窗口,可以移动关节:

<node pkg="joint_state_publisher_gui"
      exec="joint_state_publisher_gui" />

这个节点很容易编写,因为我们不需要提供任何其他东西,除了包和可执行文件。让我们以 RViz 节点结束:

<node pkg="rviz2" exec="rviz2" args="-d $(var rviz_config_path)" />

在此节点中,我们使用 -****d 选项提供保存的 RViz 配置文件。

启动启动文件

启动文件现在已经完成。我们可以构建工作空间来安装我们添加的所有文件和文件夹:

$ cd ~/my_robot_ws/
$ colcon build --packages-select my_robot_description

然后,别忘了源工作空间(source install/setup.bash),然后你可以启动你的新启动文件:

$ ros2 launch my_robot_description display.launch.xml

运行此命令后,你应该能在 RViz 中看到你的机器人模型以及 TF。如果你在终端上列出节点和主题,你应该能找到我们启动的所有内容。使用 rqt_graph,你也应该得到与我们在终端中启动所有三个节点相同的结果。

my_robot_description 包现在已经完成。我们已经安装了我们需要的所有文件:URDF、自定义网格、RViz 配置和一个用于发布 TF 和可视化机器人模型的启动文件。在我们结束之前,让我们简要谈谈启动文件的 Python 版本。

Python 启动文件

我们将以 Python 启动文件结束本章,以执行相同的事情:启动robot_state_publisherjoint_state_publisher和 RViz。我在这里这样做的主要原因是为了提供一个额外的例子,强调 Python 和 XML 启动文件之间的差异。您可以将这看作是第九章中我们讨论的内容的扩展,即XML 与 Python 的 启动文件

这也是我最后一次使用 Python 来编写启动文件,因为在下一章关于 Gazebo 的内容中,我们将只关注 XML(提醒一下:如果您需要使用现有的 Python 启动文件,您可以在 XML 启动文件中包含它,所以这里没有问题)。

现在,让我们创建一个 Python 启动文件。为此,在my_robot_description包的launch文件夹中创建一个新文件。您可以将其命名为display.launch.py——与 XML 启动文件同名,但带有 Python 扩展名。

在包中不需要添加任何额外的配置,因为CMakeLists.txt已经包含了安装launch文件夹的指令。

让我们开始分析这个 Python 文件(您可以在本书的 GitHub 仓库中找到这个文件的完整代码)从导入行开始:

from launch import LaunchDescription
from launch_ros.parameter_descriptions import ParameterValue
from launch_ros.actions import Node
from launch.substitutions import Command
import os
from ament_index_python.packages import get_package_share_path

如您所见,这里有很多东西需要导入,很容易在这里出错。导入之后,我们开始执行generate_launch_description函数:

def generate_launch_description():

从现在开始,我们所有的代码都将在这个函数内部(带有缩进)。我们创建了两个变量用于 URDF 和 RViz 路径:

urdf_path = os.path.join(
    get_package_share_path('my_robot_description'),
    'urdf',
    'my_robot.urdf.xacro'
)
rviz_config_path = os.path.join(
    get_package_share_path('my_robot_description'),
    'rviz',
    'urdf_config.rviz'
)

为了使内容更易读,我们还创建了一个用于robot_description参数的变量:

robot_description = ParameterValue(Command(['xacro ', urdf_path]), value_type=str)

现在,我们开始启动三个节点:

robot_state_publisher_node = Node(
    package="robot_state_publisher",
    executable="robot_state_publisher",
    parameters=[{'robot_description': robot_description}]
)
joint_state_publisher_gui_node = Node(
    package="joint_state_publisher_gui",
    executable="joint_state_publisher_gui"
)
rviz2_node = Node(
    package="rviz2",
    executable="rviz2",
    arguments=['-d', rviz_config_path]
)

最后,我们需要返回一个包含我们想要启动的所有节点的LaunchDescription对象:

return LaunchDescription([
    robot_state_publisher_node,
    joint_state_publisher_gui_node,
    rviz2_node
])

如您所见,代码更长:Python 有 38 行,而 XML 有 15 行。我们可以优化空格,但即使如此,仍然会有两倍之多。此外,我个人认为 Python 语法更复杂,并不太直观。说实话,我写 Python 启动文件的唯一方法就是从 GitHub 上找到的现有项目中摘取代码片段,看看它是否可行。

我不会提供比这更多的细节,因为这段代码主要在这里是为了让我们有另一个例子,说明我们之前在第九章中讨论的内容。我们将继续使用 XML 启动文件。

摘要

在本章中,您已经发布了机器人的 TFs,并且正确地打包了您的应用程序。

您首先发现,最重要的节点是robot_state_publisher,它有两个输入:robot_description参数中的 URDF 和/****joint_states主题上每个关节的当前状态。

从这里,您从终端启动了所有节点和参数,以便能够重现我们之前使用urdf_tutorial包所得到的输出。

然后,你创建了自己的包来正确组织你的应用程序。以下是这个包的最终架构,包括所有文件和文件夹:

~/my_robot_ws/src/my_robot_description
├── CMakeLists.txt
├── package.xml
├── launch
│   ├── display.launch.py
│   └── display.launch.xml
├── meshes
├── rviz
│   └── urdf_config.rviz
└── urdf
    ├── common_properties.xacro
    ├── mobile_base.xacro
    └── my_robot.urdf.xacro

这种组织方式非常标准。如果你查看几乎任何由 ROS 驱动的机器人的代码,你都会发现这个_ 描述包使用的是或多或少相同的架构。因此,创建这个包不仅对我们在这本书中遵循的过程有帮助,而且对你轻松开始任何由 ROS 开发者制作的其他项目的工作也有帮助。

现在,我们将使用这个包作为下一章的基础,我们将学习如何使用 Gazebo 为机器人创建模拟。

第十三章:在 Gazebo 中模拟机器人

在前面的章节中,你编写了一个 URDF 来描述机器人,发布了该机器人的 TFs,并将所有文件正确地组织到my_robot_description包中。

现在,你将要在 Gazebo 中模拟机器人。这将标志着第三部分项目的结束。目标是带着一个可工作的模拟完成这本书。之后,我将通过给你一些关于如何进一步使用 ROS 的建议来结束。

我们将首先了解 Gazebo 是什么,它是如何与 ROS 2 集成的,以及如何与之工作。这将使我们能够为 Gazebo 调整机器人 URDF,在模拟器中生成它,并使用插件来控制它。我们还将正确地打包应用程序,以便我们可以从一个单独的启动文件开始一切。

到本章结束时,你将能够模拟一个机器人在 Gazebo 中,并使用 ROS 2 与之交互。一旦你完成这个过程一次,模拟下一个机器人将变得容易得多。

本章的难度比我们之前所做的高。我们将达到一个文档缺乏很多信息的地方。通常,找到有用的信息意味着在 Google 上做大量的研究,以及找到可以使用的 GitHub 代码,作为灵感的来源。

为了完成本章,你还需要利用在这本书中学到的先前知识——例如,创建和组织包,处理主题和参数,以及编写启动文件。如果你有任何疑问,不要犹豫,参考前面的章节。

我们将使用ch12文件夹中的代码(位于github.com/PacktPublishing/ROS-2-from-Scratch的 GitHub 仓库中)作为起点。你可以在ch13文件夹中找到最终代码。

在本章中,我们将涵盖以下主题:

  • Gazebo 的工作原理

  • 为 Gazebo 调整 URDF

  • 在 Gazebo 中生成机器人

  • 在 Gazebo 中控制机器人

技术要求

如果你像书中开头用 VirtualBox 解释的那样在虚拟机(VM)中安装了 ROS 2,那么它可能对第一部分第二部分的所有章节以及第三部分的前几章都工作得很好,即使是在运行 RViz 时也是如此。

然而,使用 Gazebo,虚拟机可能不够用。VirtualBox 与 3D 模拟工具配合得不好。从现在开始,我强烈建议你安装带有双启动的 Ubuntu。我知道有些人使用 VMware Workstation(使用个人使用的免费版本)或 Windows 上的 WSL 2 取得了更大的成功。如果你找到了对你有效的工作组合,那很好,但我仍然推荐双启动,这可能会带来更少的错误,并为你提供更好的整体体验。

因此,如果你目前正在运行虚拟机,请花时间设置双启动并安装 Ubuntu 24.04。然后,再次遵循第二章中的说明来安装 ROS Jazzy。这可能会在短期内占用你一些时间,但从长远来看,这将更加高效。

Gazebo 是如何工作的

在我们开始我们的应用程序之前,了解 Gazebo 是什么以及它是如何工作的非常重要。

Gazebo 是一个 3D 仿真引擎。它包含一个物理引擎(具有重力、摩擦和其他物理约束),你可以用它来模拟一个机器人,就像它在真实世界中一样。

这也是 Gazebo 的一个优点。你可以主要使用 Gazebo 仿真来开发你的应用程序,然后与真实机器人一起工作。这带来了很多好处。例如,你可以工作在尚未存在的机器人上,测试极端用例而不会损坏真实机器人,创建你无法在日常环境中访问的定制环境,远程工作等等。

在本节中,我们将启动 Gazebo 并探索一些功能。我们还将了解如何将 Gazebo 与 ROS 2 连接,并理解我们需要采取的步骤来适应我们的机器人在 Gazebo 中的使用。在开始之前,很多人都会问的一个常见问题是,Gazebo 和 RViz 之间有什么区别?

阐明 - Gazebo 与 RViz 的区别

我们从这一点开始,因为我相信这是你可能会有的第一个困惑。我们之前已经使用了 RViz,我们可以在其中可视化机器人,以及很多其他信息。那么,为什么我们需要 Gazebo?

为了理解这一点,让我们首先回顾一下 RViz 是什么。RViz 是一个 3D 可视化工具。使用 RViz,你可以可视化 URDF、TFs 以及从 ROS 主题获取的数据。这是一个在开发时非常有用的工具,因为它可以帮助你检查你所做的是否正确。

现在,RViz不是一个仿真工具。你不会模拟任何东西;你只可视化已经存在的东西。所以,你在 RViz 中看到的机器人和所有数据只是外部发生的事情(在 RViz 外部)的表示。你不必在终端中查看和交互所有数据,你可以通过图形界面来做,并在 3D 中查看数据。这就是 RViz 为你带来的(非常简单地来说)。

例如,使用 TFs 时,RViz 将订阅/tf主题并在屏幕上显示 TFs。然而,RViz 并不控制 TFs;它只是显示它们。

相反,Gazebo 是一个仿真工具。它将模拟重力以及机器人的真实物理属性。它还有一些控制插件,这样你可以模拟硬件控制,甚至为你的机器人发布关节状态和 TFs。

那么,我们在这里使用的是 Gazebo 还是 RViz?最终,这并不是一场竞争;两者都是互补的。

如果您没有真实机器人,或者不想使用它,或者想要在不同的环境中测试机器人系统,例如,Gazebo 是可用的。使用 Gazebo,您可以复制您机器人的行为,使其非常接近现实生活中的表现。

在 Gazebo 之上,您可以使用 RViz 来可视化您的应用程序中的 TFs 和其他重要数据。RViz 在开发阶段仍然是一个非常有用的工具。因此,困境不是 Gazebo 与 RViz 的比较,而是 真实机器人 与 Gazebo 的比较。最终,您可以选择以下任一方式:

  • 真实机器人与 RViz

  • Gazebo 模拟与 RViz

在本章中,我们将使用第二种组合。我们将在 Gazebo 中生成并控制我们的机器人。然后,我们还将使用 RViz 来可视化它。我们在 RViz 中看到的内容将是 Gazebo 中发生情况的反映。如果我们后来切换到真实机器人,我们将放弃 Gazebo,但仍然使用 RViz 来查看一切是否仍然运行良好。

现在我们已经澄清了这一点,我们可以开始使用 Gazebo。

启动 Gazebo

让我们运行 Gazebo 并熟悉界面。

首先,您需要安装 Gazebo。我们之前已经使用 ros--desktop 安装了 ROS 2,它已经包含了很多软件包。为了获取 Gazebo 所需的一切,请安装此附加软件包:

$ sudo apt install ros-<distro>-ros-gz

请确保将 替换为您的当前 ROS 2 发行版,并在运行此命令后,source 您的环境。

注意

由于我们使用的是 Ubuntu 24.04,这将安装Gazebo Harmonic。对于其他 Ubuntu 版本,您可以在以下位置找到推荐的 Gazebo 版本:gazebosim.org/docs/latest/getstarted/

需要注意的是,Gazebo 实际上与 ROS 2 独立。您可以在没有 ROS 2 的情况下运行 Gazebo。实际上,您甚至可以在没有任何 ROS 2 软件包的情况下安装 Gazebo。

Gazebo 被设计为一个独立的机器人仿真器,您可以使用它与 ROS 2 一起使用,以及其他机器人框架。在这里,我将只关注 ROS 2。我之所以提到这一点,只是为了让您明白 Gazebo 和 ROS 是独立的项目。

我们将首先单独运行 Gazebo。然后,我们将看到如何连接 Gazebo 和 ROS,这对于规划适应我们的项目和在 Gazebo 上模拟我们的 ROS 机器人非常有用。

因此,要启动 Gazebo(不使用 ROS),请在终端中运行此命令:

$ gz sim

您将被带到 Gazebo 快速入门菜单。在那里,您可以点击来加载一个空世界。您还有其他现有的世界(点击不同的图片或使用搜索栏),您可以在以后自己探索。警告——这些世界中的一些可能包含错误并且无法正常工作。

要停止模拟,请在启动 Gazebo 的终端中按 Ctrl + C

当运行 Gazebo 命令时,你也可以直接指定你想要启动的世界。Gazebo 中的世界描述文件使用 SDF 格式(一个带有.sdf扩展名的文件),这与 URDF 非常相似。让我们用空世界启动 Gazebo:

$ gz sim empty.sdf

现在,请花些时间使用鼠标控制移动空间。你也可以使用笔记本电脑的触摸板,但如果可能的话,我强烈建议你使用鼠标,这样会更容易进行下一步操作。

在屏幕的左下角,你会看到一个播放按钮。点击它以开始模拟。

在启动模拟后,你会在屏幕的右下角看到一个百分比,表示实时因子。基本上,这将立即告诉你你的计算机是否足够强大以运行 Gazebo。我个人有一个大约 98%的实时因子,这意味着在 Gazebo 中的模拟时间以 98%的速度与实时保持同步(即,在 100 个真实秒后,Gazebo 中过去了 98 秒)。如果你的百分比太低,这可能是一个你需要更好的性能的信号。如果你不确定,继续阅读本章,你将很快看到机器人模拟是否正常工作。

注意

Gazebo 可能会出现很多问题,所以如果你在某些时候看到它崩溃,甚至是在功能强大的计算机上,也不要感到惊讶。如果你无法正确关闭 Gazebo(在终端中使用Ctrl + C),当你再次启动它时可能会遇到一些麻烦。在这种情况下,你可以尝试停止所有可能仍在后台运行的 Gazebo 进程。为此,运行ps aux | grep gz以找到所有相关进程。对于每个gz进程(如果有),你将找到一个包含四个数字的pid。要停止一个进程,运行kill –9 <pid>。如果什么方法都不奏效,最好的办法是重新启动你的计算机。

让我们回到我们开始模拟的场景——在屏幕顶部,你可以点击不同的形状并将它们添加到空空间中。花些时间来实验一下。将一个盒子添加到空间中。找到平移模式和旋转模式。移动盒子(尤其是在z轴上)并看看会发生什么。如果你把盒子举起来,你应该看到盒子掉到地板上。这是因为重力和盒子的物理属性。

图 13.1:Gazebo 模拟器中空世界中的盒子

图 13.1:Gazebo 模拟器中空世界中的盒子

我们接下来可以探索 Gazebo 使用的通信。Gazebo 也使用主题和服务,但它们与 ROS 2 的主题和服务并不相同。

例如,你可以使用此命令列出所有 Gazebo 主题:

$ gz topic -l

你将看到很多主题,但如果你在另一个终端中尝试运行ros2 topic list,这些主题将不会出现。同样,当运行ros2 node list时,你也不会看到任何节点。

通过这个,你可以看到 Gazebo 完全独立于 ROS 2,并且它们之间没有交互。

Gazebo 如何与 ROS 2 协同工作

我们现在将探讨如何将 Gazebo 和 ROS 2 连接起来。

首先,你可以使用ros_gz_sim软件包中的 ROS 2 启动文件启动 Gazebo。这对我们来说将更加实用,因为当我们编写自己的启动文件时,我们可以包括这个文件:

$ ros2 launch ros_gz_sim gz_sim.launch.py

这将以与gz sim命令相同的方式启动 Gazebo。你也可以使用gz_args参数指定要启动的世界:

$ ros2 launch ros_gz_sim gz_sim.launch.py gz_args:=empty.sdf

然而,即使我们从 ROS 2 启动文件启动了 Gazebo,Gazebo 仍然是独立的。尝试在终端中列出所有节点和主题;你将看到与之前相同的结果。

要连接 Gazebo 和 ROS 2 的主题(或服务),你需要在这两者之间创建一个桥梁。ros_gz_bridge软件包为我们做了这件事,所以我们将使用这个软件包。我们只需要提供一些配置来指定我们想要桥接的主题;如何编写这个配置将在本章后面介绍。

图 13.2:使用 ros_gz_bridge 连接 Gazebo 和 ROS 2

图 13.2:使用 ros_gz_bridge 连接 Gazebo 和 ROS 2

图 13.2中,你可以看到以下内容:

  • 在右侧,我们的当前 ROS 2 应用程序与robot_state_publisher节点,发布在/tf主题上。

  • 在左侧,Gazebo。在Gazebo内部,我们将添加插件(也称为系统)来模拟机器人的硬件行为。例如,你可以有一个插件来控制两个轮子,另一个插件来发布轮子的关节状态。这就是我们在本章中要实现的内容。

  • 然后,为了使所有组件协同工作,我们将使用ros_gz_bridge软件包。在关节状态示例中,Gazebo 关节状态发布插件将使用 Gazebo 主题发布关节状态。通过使用ros_gz_bridge,我们将匹配此主题与 ROS 2 的/joint_states主题。

这里需要理解的重要一点是,Gazebo 和 ROS 2 存在于两个不同的环境中,但你可以使它们协同工作。无论你在 Gazebo 模拟还是真实机器人上工作,你的 ROS 2 应用程序都是一样的。如果你在真实机器人上工作,那么你将直接控制轮子,并从编码器获取关节状态数据。使用 Gazebo,你使用插件来模拟硬件。

现在,以下是我们在以下章节中将要采取的步骤来创建机器人的 Gazebo 模拟:

  1. 为 Gazebo 适配 URDF。为了让机器人在 Gazebo 上工作,我们首先需要在 URDF 中提供惯性和碰撞属性。

  2. 一旦 URDF 正确,我们将启动 Gazebo 并在其中生成 URDF。在这个时候,我们还将创建一个包含启动文件的软件包。

  3. 然后,我们将添加一些插件(系统)来控制机器人,使用ros_gz_bridge软件包使这些插件与我们的 ROS 2 应用程序通信。

你可以遵循这个过程来模拟任何你想要在 Gazebo 中模拟的 ROS 2 机器人。如果某些事情仍然不清楚,继续阅读本章,进行实现工作,并在最后回到这一节。一切都会变得更有意义。

让我们从第一步开始。

适配 URDF 以用于 Gazebo

我们可以尝试直接在 Gazebo 中生成我们的机器人,但这是不可能的,因为 URDF 缺少两个关键元素——惯性和碰撞属性。Gazebo 需要这些来正确模拟机器人。

因此,在我们对 Gazebo 做任何事情之前,我们需要回到我们的 URDF 并添加这些属性。对于代表物理部分的每个机器人链接,我们将添加一个标签和一个标签。在本节中,你将学习如何正确配置这些标签。

我们将修改在my_robot_description包中创建的 URDF。快速回顾一下,这个包包含 URDF、一个用于在 RViz 中显示机器人模型的启动文件和一个 RViz 配置文件。这个包中最重要的东西是 URDF。启动文件将帮助我们验证我们在 URDF 中设置的值是否正确。

让我们从惯性特性开始,然后添加碰撞特性。

惯量标签

没有惯性特性的 URDF 无法在 Gazebo 中加载。因此,这是你需要添加的第一件事。

一个标签将包含几个元素,包括一个表示惯量张量的 3x3 矩阵。在这本书中,我们不会深入探讨理论上的惯量细节;如果你有兴趣,可以自己查阅,因为互联网上有相当好的文档。相反,我们将专注于找到正确的公式并应用它们,这样我们就可以生成机器人并快速进入下一步。

因此,目前我们的 URDF 被分成三个文件。我们将向这些文件添加一些代码:

  • common_properties.xacro:在这里,我们将添加一些宏来指定盒子、圆柱体和球体的惯性特性。这样,我们只需要为这些形状写一次惯性公式,你可以在任何项目中重复使用它们。

  • mobile_robot.xacro:在代表物理部分的每个链接内,我们将使用之前定义的相应惯性宏。

  • my_robot.urdf.xacro:这里没有变化;我们仍然导入前两个文件。

现在,我们如何为 URDF 中的形状编写惯性宏?

我们在标签内写什么?

我们将创建三个包含标签的 Xacro 宏——一个用于盒子,一个用于圆柱体,一个用于球体。在 URDF 的标签内,你需要提供:

  • 元素的质量(以千克为单位)。

  • 惯性的起源(以米和弧度为单位)。

  • 惯量张量或矩阵的九个元素(以千克每平方米为单位)。由于矩阵是对称的,我们只需要六个元素——ixxixyixziyyiyzizz(例如,ixyiyx是相同的,所以我们省略第二个)。

由于我们在这个项目中没有物理机器人,我们将任意决定每个连杆的质量属性—当然,同时尽量使这些值有意义。

那么,我们如何计算惯性矩阵呢?这通常是编写 标签时最困难的部分。

如果你使用 CAD 软件设计你的机器人—例如,使用 SolidWorks—那么你可以直接从软件中导出每个属性并将它们添加到你的 URDF 中。由于我们没有这个软件,我们需要自己进行计算。

幸运的是,互联网上有一些有用的资源。你可以在维基百科上找到一个惯性矩的列表:en.wikipedia.org/wiki/List_of_moments_of_inertia。在那里,你还可以找到我们拥有的每个简单形状的惯性矩,以及一个 3D 惯性张量列表,这基本上是我们为 URDF 需要的矩阵。需要注意的是,矩阵只有三个非零分量—ixxiyyizz。所有其他分量都设置为 0

有了这个信息,我们可以开始编写 标签。

为基本形状添加惯性宏

由于基本形状的惯性宏可以被任何机器人使用,我们将在 common_properties.xacro 文件中添加所有宏。这样,如果你想为另一个机器人创建另一个 URDF,你只需重用这个 Xacro 文件即可。

第一个宏将是用于盒子的惯性。现在,如果你查看前面的维基百科链接,可能会有些困惑,因为它们使用 widthdepthheightwdh)。在 ROS 2 中,我们为 xyz 维度指定了长度、宽度和高度。哪一个对应哪一个?

正确编写这个矩阵的一个简单方法就是意识到一个维度的分量使用了其他两个维度。例如,为了计算 w 分量(矩阵中的 ixx),我们使用 h(z)和 d(y)。只有遵循这一点才能消除很多困惑,特别是与不同的命名约定有关。

这里是我们将使用的内容(左边是维基百科的值,右边是 ROS 2 的值):

  • w: x 轴维度

  • d: y 轴维度

  • h: z 轴维度(这同时也是指向上的轴)

这里是盒子的 标签。你可以在 标签之后,在 标签内添加这个标签:

<xacro:macro name="box_inertia" params="m x y z o_xyz o_rpy">
    <inertial>
        <mass value="${m}" />
        <origin xyz="${o_xyz}" rpy="${o_rpy}" />
        <inertia ixx="${(m/12) * (z*z + y*y)}" ixy="0" ixz="0"
                 iyy="${(m/12) * (x*x + z*z)}" iyz="0"
                 izz="${(m/12) * (x*x + y*y)}" />
    </inertial>
</xacro:macro>

为了简化,我直接使用了 xyz(而不是 wdh),这样在我们需要使用链接中的宏时,会更容易,因为盒子的尺寸是用 xyz 定义的。当你开发 API/接口/宏/函数时,最佳实践是为 API 的客户端设计接口,而不是为编写 API 的开发者设计。

现在,让我们编写圆柱体的宏。这个稍微简单一些。我们有两个组件——半径和高度。这将与我们在 URDF 中定义的半径和长度相对应:

<xacro:macro name="cylinder_inertia" params="m r l o_xyz o_rpy">
    <inertial>
        <mass value="${m}" />
        <origin xyz="${o_xyz}" rpy="${o_rpy}" />
        <inertia ixx="${(m/12) * (3*r*r + l*l)}" ixy="0" ixz="0"
                 iyy="${(m/12) * (3*r*r + l*l)}" iyz="0"
                 izz="${(m/2) * (r*r)}" />
    </inertial>
</xacro:macro>

最后,我们可以为(实心)球体编写宏。这是最简单的,我们只有一个组件——球体半径:

<xacro:macro name="sphere_inertia" params="m r o_xyz o_rpy">
    <inertial>
        <mass value="${m}" />
        <origin xyz="${o_xyz}" rpy="${o_rpy}" />
        <inertia ixx="${(2/5) * m * r * r}" ixy="0" ixz="0"
                 iyy="${(2/5) * m * r * r}" iyz="0"
                 izz="${(2/5) * m * r * r}" />
    </inertial>
</xacro:macro>

通过这三个宏,我们拥有了所有基本 URDF 形状所需的一切。

包括链接中的惯性宏

我们现在可以使用这些宏为机器人的每个链接提供惯性属性。

由于 base_footprint 不代表一个物理部分(我们称之为虚拟链接),它不会有惯性。对于所有其他链接(基础、右轮、左轮和球体),我们将使用惯性宏。

打开 mobile_base.xacro 文件,这是我们继续编写代码的地方。

现在,要添加链接的惯性属性,你需要在 <link> 标签内添加一个 标签。为了添加这个标签,我们将使用之前创建的宏。

让我们从 base_link 开始。在 <link name="base_link"></link> 标签内,并在 <visual> 标签之后,添加 box_inertia 宏:

<xacro:box_inertia m="5.0" x="${base_length}" y="${base_width}" z="${base_height}" o_xyz="0 0 ${base_height / 2.0}" o_rpy="0 0 0" />

注意

<visual><inertial> 标签都是 <link> 标签的子标签;不要在 <visual> 标签内添加 <inertial> 标签。

我们指定了宏所需的全部参数:

  • 5.0 kg。

  • xyz 维度(我们创建了宏以便可以使用 ROS 2 轴系约定)。

  • <inertial> 标签,我们将在 RViz 中验证惯性。在那里,你可以轻松地看到惯性是否放置正确。

现在,添加两个轮子的惯性。你需要在 wheel_link 宏内添加 cylinder_inertia 宏(在宏内使用另一个宏是完全可行的)。确保将其放置在 <link> 标签内,并在 <visual> 标签之后。这个惯性宏将应用于两个轮子:

<xacro:cylinder_inertia m="1.0" r="${wheel_radius}" l="${wheel_length}" o_xyz="0 0 0" o_rpy="${pi / 2.0} 0 0" />

这里是我们指定的参数:

  • 1.0 kg。

  • 圆柱体属性:圆柱体的半径和长度。

  • z 轴。轮子的视觉在 x 轴上已旋转 90°,以便旋转轴成为 y 轴。在这里,我们提供相同的旋转给原点。基本上,再次使用你在视觉原点中写的相同值。

最后,我们为 caster_wheel_link 添加了 sphere_inertia 宏:

<xacro:sphere_inertia m="0.5" r="${wheel_radius / 2.0}"
    o_xyz="0 0 0" o_rpy="0 0 0" />

这个很简单。我们选择 0.5 kg 的质量,然后提供 半径,这是唯一的球体属性。我们不需要指定原点的任何偏移量。

这就是 <inertial> 标签的全部内容。主要困难在于定义简单形状的宏(但你需要只做一次),然后正确使用宏并提供正确的原点。现在,让我们使用 RViz 验证我们提供的正确尺寸值和原点。

使用 RViz 验证惯性

为了确保每个链接的惯性属性是正确的,你可以使用 RViz。因为我们已经在my_robot_description包内部创建了一个启动文件,让我们使用它来在 RViz 中可视化 URDF。

在你开始 RViz 之前,不要忘记构建和源代码工作空间。然后,启动启动文件:

$ ros2 launch my_robot_description display.launch.xml

你将看到与上一章相同的视图和配置。为了看到惯性,首先禁用视觉。在左侧菜单中,打开RobotModel并取消选中Visual Enabled。然后,仍然在RobotModel中,打开Mass Properties并勾选Inertia框。你应该看到类似这样的东西:

图 13.3:在 RViz 中可视化惯性

图 13.3:在 RViz 中可视化惯性

使用这个视图,你可以轻松地发现错误。例如,如果base_link惯性的偏移量不正确,那么盒子将不会正确放置。另一个常见的错误将是轮子惯性的旋转。在前面的图中,你可以看到惯性盒子大致位于轮子上方,方向正确。如果不是这样,你就知道你必须在代码中修复惯性。

一旦你完成了标签,你就可以添加标签。

碰撞标签

到目前为止,在我们的 URDF 的每个链接内部,我们都有一个标签来查看链接,以及一个标签来描述 Gazebo 的物理属性。

然而,还有一些东西是缺失的。视觉只是让你在 RViz 或 Gazebo 中可视化链接。然而,Gazebo 需要更多的东西来模拟机器人。你将不得不在链接中添加标签,以便 Gazebo 可以计算两个部分如何相互碰撞。

为了给你一个概念,如果你没有为你的机器人设置任何碰撞属性,那么机器人将会穿过地面并无限期地继续下落。有了碰撞属性,机器人将会与地面碰撞,因此不会下落。除此之外,该属性还将用于计算机器人不同部分之间的碰撞,或者与环境中其他机器人或元素的碰撞。例如,如果机器人与墙壁或物体碰撞,它将会撞击它而不会穿过它。再次强调,标签不会做任何这些事情。

在我们为我们的机器人编写标签之前,让我们了解如何定义碰撞。

如何定义碰撞元素

标签将包含与标签大致相同的内容:标签。你基本上定义一个形状。

作为一条一般规则,对于碰撞,你将使用比视觉(如果可能的话)更简单的形状。原因是简单的——形状越复杂,计算链接与其他元素之间碰撞所需的计算能力就越多。这可能会大大减慢仿真速度。因此,设计更简单的形状是一种最佳实践。

这里是关于定义碰撞元素的一些更多细节:

  • 如果你正在使用复杂的 Collada 文件(几个 MB)进行视觉设计,那么对于碰撞,请使用更简单的 Collada 或甚至 STL 文件。你可以将这些文件添加到 meshes 文件夹中,并在 URDF 中包含它们。

  • 如果形状接近基本形状(如盒子、圆柱体或球体),那么你可以使用基本形状进行碰撞。例如,如果你设计了一个移动机器人,机器人的底部看起来像一个盒子,那么你可以只使用盒子进行碰撞,而只使用复杂的 Collada 或 STL 文件进行视觉设计。对于一个轮子,你可以使用圆柱体、球体等等。

  • 即使使用基本形状,你也可以减少复杂性——例如,当视觉是圆柱体或球体时,使用盒子进行碰撞(然而,我们将在本节稍后看到一个例外,以减少 Gazebo 中的摩擦)。

要找到这种形状简化的真实示例,你可以在 GitHub 上简单地搜索现有项目。找到一个由 ROS 2 驱动的机器人,并在 Google 中输入 <机器人名称> + 描述 + github——你可以尝试使用 TurtleBot 3(或更新的版本)机器人。你通常会找到一个类似于上一章中创建的包。在这个包中,搜索 URDF 或 Xacro 文件,并找到 <collision> 标签。你通常会看到碰撞使用了简化的 STL 文件或基本的 URDF 形状。

现在我们有了这种简化思维,让我们在 URDF 中添加 <collision> 标签。

为链接添加碰撞属性

你将在 <link> 标签内添加 <collision> 标签,在 mobile_base.xacro 文件中。

注意你添加这些标签的位置。它们应该与 <visual><inertial> 标签处于同一级别,而不是在它们内部。顺序并不重要,但为了保持整洁,我通常从 <visual> 开始,然后是 <collision>,最后是 <inertial>

这里是 <collision> 标签的示例,用于 base_link

<collision>
    <geometry>
        <box size="${base_length} ${base_width} ${base_height}" />
    </geometry>
    <origin xyz="0 0 ${base_height / 2.0}" rpy="0 0 0" />
</collision>

盒子已经是你可以拥有的最简单形状了,所以我们只是使用另一个盒子。然后,我们设置与视觉相同的尺寸和原点。正如你所看到的,为这个连接添加碰撞基本上意味着复制和粘贴 <visual> 标签的内容(除了 <material>)。

现在,对于两个轮子,情况有点独特。它们是圆柱体,所以这是一个相当基本的形状。我们可以保持不变,甚至可以使用一个盒子,这会更简单。

然而,这些轮子将在 Gazebo 中直接接触地面。使用盒子不是一个好主意——想象一下一个车轮是方形的汽车;它可能不会很好地工作。

我们可以保留一个圆柱体,这是第二简单的形状,但如果你这样做,你可能会在 Gazebo 中遇到一些不必要的摩擦,这会导致机器人无法按照我们的预期移动。对于你控制并接触地面的轮子,最好使用球体进行碰撞,因为这减少了与地面的接触点数量(基本上,只有一个接触点)并,因此,减少了不必要的摩擦。

注意

这更像是一个 Gazebo 的技巧而不是真正的逻辑选择。让我们在这里明确一下——在这个阶段,你不可能得出这样的结论;这是你必须亲身体验并在 Gazebo 中修复的事情。我不是特别喜欢这样的技巧,但有时,你别无选择。此外,我提前给你正确的解决方案,以免使这一章节太长。

让我们在wheel_link宏内部为我们的轮子添加一个球体碰撞:

<collision>
    <geometry>
        <sphere radius="${wheel_radius}" />
    </geometry>
    <origin xyz="0 0 0" rpy="0 0 0" />
</collision>

我们只使用轮子半径属性;这里不需要轮子长度。由于它是一个球体,因此不需要为原点添加任何旋转。

最后,我们为转向轮添加碰撞。正如之前所述,我们可以用一个盒子来简化球体,但由于转向轮直接接触地面,我们不希望添加不必要的摩擦。因此,我们使用相同的球体来处理视觉和碰撞:

<collision>
    <geometry>
        <sphere radius="${wheel_radius / 2.0}" />
    </geometry>
    <origin xyz="0 0 0" rpy="0 0 0" />
</collision>

针对碰撞标签的说明就到这里。我们移动机器人的 URDF 现在已经完整。

使用 RViz 验证碰撞

就像我们在 URDF 中写的一切一样,你还可以在 RViz 中可视化碰撞属性。

构建你的工作空间,源代码环境,并再次启动display.launch.xml启动文件。

打开RobotModel菜单,取消勾选Visual Enabled复选框。然后,勾选Collision Enabled复选框。

图 13.4:在 RViz 中可视化碰撞

图 13.4:在 RViz 中可视化碰撞

在那里,你可以看到碰撞属性是否正确。在Links菜单(在RobotModel内部),如果需要,你可以只启用一些链接,这样你可以获得更精确的视图。

如果你看到某些碰撞元素放置不正确,或者它们太大或太小,你可以回到你的 URDF 文件并修复它们。

如前图所示,机器人的碰撞视图几乎与视觉视图相同。区别在于轮子,现在它们是球体。你也可能想知道,如果轮子是球体,它们的一部分在盒子内部,因此它们会与盒子发生碰撞。这是真的,但 Gazebo 不会考虑相邻链接之间的这种碰撞。

现在你已经添加了标签,我们可以进行下一步,在 Gazebo 中生成机器人。

在 Gazebo 中生成机器人

适应 URDF 以适应 Gazebo 的第一步至关重要,因为没有这个,机器人要么不会出现在 Gazebo 中,要么表现不正确。

现在 URDF 已经完成(并在 RViz 中进行了验证),我们可以在 Gazebo 中生成机器人。在本节中,您将看到需要运行的命令,然后我们将创建另一个带有启动文件的包来启动一切。

这里是我们将要做的:

  1. 使用 URDF 作为参数运行robot_state_publisher节点。

  2. 启动 Gazebo 模拟器。

  3. 在 Gazebo 中生成机器人。

让我们从终端命令开始。

从终端生成机器人

如同往常,在创建启动文件之前,我们将逐个在不同的终端中运行每个命令,这样我们可以清楚地了解需要运行的内容,以及所有必要的细节。

打开三个终端以启动所有命令。

首先要启动的是robot_state_publisher节点。这是我们第十二章中做的事情,这通常也是您在任何 ROS 2 应用程序中首先启动的内容。

在终端 1 中,运行以下命令:

$ ros2 run robot_state_publisher robot_state_publisher --ros-args -p robot_description:="$(xacro /home/<user>/my_robot_ws/src/my_robot_description/urdf/my_robot.urdf.xacro)"

这与上一章中的内容相同。我们通过robot_description参数传递 URDF。

执行此命令后,robot_state_publisher节点启动并执行三项操作——订阅/joint_states,在/tf上发布,并也在/robot_description上发布 URDF。如果需要,您可以使用rqt_graph进行验证(在这种情况下,请确保取消选中Dead sinksLeaf topics复选框)。

在第二个终端中,我们启动 Gazebo。实际上,您可以在启动robot_state_publisher之前启动 Gazebo;这两个步骤的顺序并不重要。

在终端 2 中,运行以下命令:

$ ros2 launch ros_gz_sim gz_sim.launch.py gz_args:="empty.sdf -r"

使用这种方法,我们在 Gazebo 中启动一个空的世界。默认情况下,当你启动 Gazebo 时,时间会停止。我们添加了-r选项来直接启动时间,这样我们就不必点击播放按钮。

最后,一旦完成了前两个步骤,您就可以在 Gazebo 中生成机器人。为此,我们将使用来自ros_gz_sim包的create可执行文件。这将使用机器人的 URDF 在 Gazebo 中生成一个机器人,我们可以通过-topic选项将其作为主题传递。由于robot_state_publisher节点在robot_description主题上发布 URDF,我们可以使用这个主题。

在终端 3 中,运行以下命令:

$ ros2 run ros_gz_sim create -topic robot_description

执行此命令后,您应该在 Gazebo 中看到机器人:

图 13.5:在 Gazebo 中生成的机器人

图 13.5:在 Gazebo 中生成的机器人

如果在任何终端中遇到任何错误,这通常意味着你的 URDF 文件不正确。在这种情况下,返回 Xacro 文件并仔细检查所有内容。

注意

要了解<inertial><collision>标签的重要性,请回到 URDF,注释掉轮子的惯性,然后再次运行命令。您会发现轮子没有在 Gazebo 中显示。然后,将惯性放回并注释掉轮子的碰撞。这次,轮子将显示出来,但由于它们不与地面碰撞,您会看到它们进入地面。

现在我们知道了要运行哪些命令,让我们编写一个启动文件。

从启动文件中启动机器人

我们现在将编写一个启动文件来启动这三个命令。这将是一个很好的基础,我们可以在此基础上添加更多内容。

让我们从在我们的工作空间中创建一个新的包开始。

创建一个bringup

如果你记得我们在第九章中做了什么,最佳实践是创建一个专门的包用于启动文件和配置文件(我们目前还没有配置文件,但我们将在本章后面添加一个)。

我们对display.launch.xml文件做了例外,我们将它放在了my_robot_description包内。正如第十二章中解释的,这个启动文件仅在开发期间用于可视化 URDF。因此,将启动文件放在与 URDF 相同的包中是有意义的。在这里,以及在我们应用的任何未来的启动和配置文件中,我们将使用一个新的专用包。

按照此类包的命名约定,我们将以机器人或应用的名称开始,添加_bringup后缀。因此,我们将创建my_robot_bringup包。

注意

请注意不要将这个包与我们在书的第二部分中创建的ros2_ws中的my_robot_bringup包混淆。在这里,在第三部分中,我们使用另一个工作空间,名为my_robot_ws,因此my_robot_bringup包是完全不同的。

让我们创建这个包,删除不必要的文件夹,并添加一个launch文件夹。我们还将添加一个config文件夹,我们将在本章后面使用它:

$ cd ~/my_robot_ws/src/
$ ros2 pkg create my_robot_bringup --build-type ament_cmake
$ cd my_robot_bringup/
$ rm -r include/ src/
$ mkdir launch config

现在,在my_robot_bringup包的CMakeLists.txt文件中,添加安装launchconfig文件夹的指令:

install(
  DIRECTORY launch config
  DESTINATION share/${PROJECT_NAME}/
)

包已经正确设置,因此我们现在可以添加和安装文件。

编写启动文件

让我们创建、编写并安装启动文件,以便在 Gazebo 中启动机器人。

首先,在启动文件夹内创建一个新文件。由于这个启动文件将是主要的,让我们简单地使用机器人的名称(或机器人应用),my_robot.launch.xml

$ cd ~/my_robot_ws/src/my_robot_bringup/launch/
$ touch my_robot.launch.xml

打开文件并编写 XML 启动文件的最小代码:

<launch>
</launch>

然后,在这个标签内,让我们逐步添加我们需要的一切。

启动文件的开始部分将非常类似于我们在上一章中编写的display.launch.xml文件,因此我们可以基本上复制粘贴一些部分。我们首先添加一个用于 URDF 文件路径的变量:

<let name="urdf_path" value="$(find-pkg-share my_robot_description)/urdf/my_robot.urdf.xacro" />

现在,我们可以启动robot_state_publisher节点:

<node pkg="robot_state_publisher" exec="robot_state_publisher">
    <param name="robot_description"
           value="$(command 'xacro $(var urdf_path)')" />
</node>

然后,我们使用空世界启动 Gazebo,并且我们还使用-r选项自动启动时间:

<include
    file="$(find-pkg-share ros_gz_sim)/launch/gz_sim.launch.py">
    <arg name="gz_args" value="empty.sdf -r" />
</include>

最后,我们在 Gazebo 中启动机器人:

<node pkg="ros_gz_sim" exec="create" args="-topic robot_description" />

至此,启动文件的编写就完成了。稍后,我们将添加更多内容,并启动 RViz 来可视化 TFs。目前,我们只想在 Gazebo 中看到机器人。

我们可以做一些事情来使事情变得稍微干净一些——因为我们正在使用来自其他包的文件、节点和启动文件,让我们在my_robot_bringup包的package.xml文件中添加对它们的依赖。在<buildtool_depend>行之后,添加以下行:

<exec_depend>my_robot_description</exec_depend>
<exec_depend>robot_state_publisher</exec_depend>
<exec_depend>ros_gz_sim</exec_depend>

我们使用较宽松的<exec_depend>标签而不是,因为我们只需要这些依赖来运行启动文件,而不是编译任何代码。有了这个,例如,如果你没有安装ros_gz_sim包,你尝试构建my_robot_bringup包,当运行colcon build时,你会得到错误,然后你可以立即修复问题。如果没有这些行,构建将工作,但当你启动启动文件时,你会得到错误,这可能会成为一个大问题,尤其是在生产环境中。因此,最佳实践是在package.xml文件中指定所有需要的依赖项。

现在,保存所有文件,构建工作空间,源环境,并启动启动文件(确保在这样做之前 Gazebo 没有在另一个终端中运行):

$ ros2 launch my_robot_bringup my_robot.launch.xml

你应该得到的结果应该和我们之前在终端中运行所有三个命令时的结果相同。

在 Gazebo 中控制机器人

我们的可移动机器人在 Gazebo 中模拟,具有物理属性。现在怎么办?机器人什么也没做。我们将通过添加控制插件来完成本章,这样我们就可以模拟机器人的硬件并执行以下操作:

  • 发送命令使机器人在 Gazebo 中移动,就像它在现实世界中一样

  • 从机器人读取所有必要的关节状态,以获取我们 ROS 2 应用程序中的所有 TF

在我们开始讨论 Gazebo 系统和桥接之前,让我们深入一点,了解缺少了什么以及我们需要添加什么。

我们需要做什么?

当你启动my_robot.launch.xml启动文件时,你会在 Gazebo 中看到机器人。然而,我们没有控制它的任何方法。在终端中,如果你列出所有节点、主题、服务或甚至动作,你将找不到我们可以使用的内容。

此外,在启动启动文件后,如果你打印 TF 树,你将看不到左右轮的 TF。你可以用 RViz 观察到相同的情况——为了简化问题,你可以使用我们之前保存的配置来启动 RViz:

$ ros2 run rviz2 rviz2 -d ~/my_robot_ws/src/my_robot_description/rviz/urdf_config.rviz

你应该在RobotModel中看到一些错误,说从[left_wheel_link]没有变换没有变换 from [right_wheel_link]

图 13.6:在 Gazebo 中启动机器人后的 RViz 中的 TF 错误

图 13.6:在 Gazebo 中启动机器人后的 RViz 中的 TF 错误

这种 TF 缺失是因为没有人发布在/joint_states主题上。在第十二章中,当我们只是可视化机器人模型时,我们使用了一个假的关节状态发布者。我们在这里不会这样做。

那么,我们需要做什么?

对于真实机器人,你需要创建一个硬件驱动程序来控制轮子。这个驱动程序将公开一个主题/服务/动作接口,这样你就可以使机器人移动。然后,你会从编码器读取位置/速度数据,并在/joint_states主题上发布这些数据。有了这个,循环就闭合了。

对于 Gazebo 模拟,我们将做同样的事情,但当然,没有硬件。我们将使用 Gazebo 插件(也称为系统)来模拟机器人的控制并获取关节状态。然后,我们将配置一个,使这些插件能够与我们的 ROS 2 应用程序通信。

让我们从 Gazebo 系统开始。

添加 Gazebo 系统

Gazebo 系统基本上是硬件组件的模拟。你可以有一个系统模拟摄像头并发布图像,另一个监控电池状态,等等。对于这本书,我们将使用两个系统——一个用于控制差速驱动机器人(两个平行轮),另一个用于发布关节状态。

现在,好消息是已经有很多现成的系统可供使用,包括我们需要的两个。

坏消息是,那些系统的文档几乎不存在(在撰写本文时),你将不得不深入研究代码本身以找到要包含在你自己的代码中的内容。不用担心这一点——我们将逐步进行这个过程,并且它适用于你使用的任何其他系统。

对于 Gazebo Harmonic 和 ROS 2 Jazzy,你可以在 GitHub 上找到所有可用的 Gazebo 系统:github.com/gazebosim/gz-sim/tree/gz-sim8/src/systems(对于其他 Gazebo 版本,你可能需要使用不同的分支)。

注意

如果互联网上已经足够混乱,你经常会看到术语插件系统;它们都指同一件事。即使应该首选单词系统,在实践中并不清楚应该使用哪一个;例如,为了在我们的代码中包含一个系统,我们需要使用一个<plugin>标签。因此,在本节中,我必须使用这两个术语。

现在,我们将在哪里添加我们想要模拟的机器人的系统?我们将在 URDF 中这样做。

Gazebo 的 Xacro 文件

我们机器人的 Gazebo 系统将在 URDF 中指定。因此,我们需要回到my_robot_description包。

我们现在的 URDF 文件已经分为三个:一个包含通用属性,一个描述机器人(链接和关节),另一个用于包含前两个文件。

要添加 Gazebo 系统,我们将创建另一个 Xacro 文件,专门用于所有与 Gazebo 相关的内容。通过将此文件与其他文件分开,我们使事情更清晰。如果你稍后想在不使用 Gazebo 的情况下使用 URDF,你只需要删除包含 Gazebo 文件的引用。

my_robot_description包的urdf文件夹中,添加一个第四个文件,命名为mobile_base_gazebo.xacro

打开文件并添加最小的 Xacro 代码:

<?xml version="1.0"?>
<robot >
</robot>

现在,在my_robot.urdf.xacro中,在另外两个文件之后包含该文件:

<xacro:include filename="$(find my_robot_description)/urdf/mobile_base_gazebo.xacro" />

Xacro 文件已经准备好了,我们现在可以添加系统。

差速驱动控制器

我们将添加的第一个系统是一个差速驱动控制器。通过差速驱动,我们指的是由机器人两侧的两个车轮控制的机器人。

如果你浏览可用的系统(链接在上一页提供),你可以找到一个diff_drive文件夹——在 ROS 中,我们通常使用diff drive作为differential drive的缩写。

在这个文件夹中,你会看到一个DiffDrive.hh文件。打开这个文件,在开头附近,你会找到与系统相关的 XML 标签(这里可能缺少一些标签;对于某些系统,你可能需要阅读完整的源代码来找到所有可用的标签)。

这是将系统添加到我们的 Xacro 文件(mobile_base_gazebo.xacro)的方法:

<gazebo>
    <plugin
        filename="gz-sim-diff-drive-system"
        name="gz::sim::systems::DiffDrive">
        <left_joint>base_left_wheel_joint</left_joint>
        <right_joint>base_right_wheel_joint</right_joint>
        <frame_id>odom</frame_id>
        <child_frame_id>base_footprint</child_frame_id>
        <wheel_separation>0.45</wheel_separation>
        <wheel_radius>0.1</wheel_radius>
    </plugin>
</gazebo>

我们从标签开始。与 Gazebo 相关的所有内容都将包含在这样的标签中。然后,我们使用标签包含系统。我们还需要指定系统的文件名和名称。

注意

通常,文件名和名称将遵循以下语法:

gz-sim-<name-with-dashes>-system

gz::sim::systems::<UpperCamelCaseName>(你也可以在系统的.cc文件底部找到这个名称)

关于这个差速驱动系统的不同参数,这里有一些更多信息:

  • left_jointright_joint:你需要提供你在 URDF 中为车轮定义的关节的确切名称。

  • frame_id: 当机器人移动时,我们将跟踪它相对于起始位置的位置。这个起始位置将被称为odom(里程计的简称)。

  • child_frame_id: 我们写入base_footprint,因为它是我们机器人的根链接,也是我们想要用于里程计跟踪的链接。

  • wheel_separation: 我们可以从 URDF 中计算出这个值。底座宽度是 0.4,每个车轮的起点都位于车轮中心。由于每个车轮长度是 0.05,我们需要加上 0.4 + 0.025 + 0.025,这使得0.45

  • wheel_radius: 我们从这个值从 URDF 中获取,它被定义为0.1

  • 加速度和速度的最小值和最大值:你可以选择设置一些限制。这可以是一个好主意,这样控制器就不会接受一个会使机器人移动得太快并可能对自己或环境造成危险的命令。对于值,再次提醒,你应该使用公制系统和弧度来表示角度。

差速驱动系统就到这里。现在,除了这些,我们还需要为万向轮添加一个设置。如果你记得,万向轮是一个被动关节,所以我们将其定义为固定球体。

当车轮转动和机器人移动时,地面和万向轮之间会有一些摩擦。在 Gazebo 中你不会看到太多的摩擦,但它会稍微减慢机器人的速度,稍后,如果你在 RViz 中可视化机器人,你不会得到相同的结果。

因此,我们将减小转向轮的摩擦力。你可以在差速驱动系统的代码之前添加此代码:

<gazebo reference="caster_wheel_link">
    <mu1 value="0.1" />
    <mu2 value="0.1" />
</gazebo>

有两个参数,mu1mu2,你可以设置以获得对摩擦力的更多控制。我选择了值 0.1;稍后,你可以进一步减小这个值。

关节状态发布

我们已经添加了一个控制轮子的系统,但在测试它之前,让我们完成 Xacro 文件并添加我们需要的第二个系统。单独的差速驱动系统不会发布轮子的关节状态;我们需要添加一个关节状态发布系统。

返回 GitHub 上的系统页面,你将找到一个 joint_state_publisher 文件夹。在这个文件夹中,你可以获取 JointStatePublisher.hh 文件中 XML 标签的 文档

让我们在上一个系统之后将系统添加到 Xacro 文件中:

<gazebo>
    <plugin
        filename="gz-sim-joint-state-publisher-system"
        name="gz::sim::systems::JointStatePublisher">
    </plugin>
</gazebo>

关节状态发布系统更容易设置。此外,我们没有指定任何 <joint_name> 标签来发布所有可用的关节状态。如果你的机器人系统包含很多关节,只指定你想要使用的关节可能很有用。

我们现在完成的 mobile_base_gazebo.xacro 文件,我们不需要在 URDF 中修改任何其他内容。我们可以在 Gazebo 中再次启动机器人,看看它如何与那些系统交互。

桥接 Gazebo 和 ROS 2 的通信

为了使这个模拟完整,我们需要做的最后一件事是桥接 Gazebo 和 ROS 2 的通信。

让我们先了解缺少了什么。

我们需要桥接哪些主题?

如果你记得,我们在章节开头就讨论过这个问题。Gazebo 使用主题和服务,但这些与 ROS 2 是独立的。因此,我们刚刚添加的系统将工作,但它们将只有 Gazebo 接口。

你可以通过再次启动 my_robot.launch.xml 文件来验证这一点——确保在之前编译和源代码工作空间,以便获取更新的 URDF。

然后,在另一个终端中列出所有 Gazebo 主题。列表将包含很多东西;这里,我只包括我们将要使用的内容:

$ gz topic -l
/model/my_robot/tf
/world/empty/model/my_robot/joint_state
/model/my_robot/cmd_vel

/tf 结尾的第一个主题将包含从 odom 帧到 base_footprint 的 TF。带有 /joint_state 的主题将包含两个轮子的关节状态,而带有 /cmd_vel 的主题将用于向机器人发送速度命令。

然而,如果你使用 ros2 topic list 检查 ROS 2 主题,你将看不到 /cmd_vel 主题。你会看到 /joint_states/tf,但这只是因为 robot_state_publisher 节点为这些主题创建了一个订阅者和发布者。没有内容被发布;你可以使用 ros2 topic echo 来验证这一点。

因此,从 ROS 2 的角度来看,我们无法与 Gazebo 通信。我们需要使用 ros_gz_bridge 包(参见章节开头 图 13.2)在 ROS 2 和 Gazebo 之间创建一个桥梁。

为了做到这一点,我们将从 ros_gz_bridge 包中运行 parameter_bridge 节点,并使用我们想要桥接的接口配置。

添加配置文件以桥接主题

让我们从配置文件开始。在 my_robot_bringup 包中,在 config 文件夹内(我们之前已经创建过),创建一个名为 gazebo_bridge.yaml 的新文件。

打开此文件以编写配置。这是我们将要创建的第一个桥接器:

- ros_topic_name: "/cmd_vel"
  gz_topic_name: "/model/my_robot/cmd_vel"
  ros_type_name: "geometry_msgs/msg/Twist"
  gz_type_name: "gz.msgs.Twist"
  direction: ROS_TO_GZ

这里是我们将要使用的不同字段:

  • ros_topic_name:ROS 2 端的主题名称。您可以选择主题名称(/cmd_vel 还不存在,因此我们创建它)或使其与现有主题匹配(对于下一个,我们将必须指定确切的 /joint_states)。

  • gz_topic_name:Gazebo 端的主题名称。我们使用 gz topic -l 找到它。

  • ros_type_name:ROS 2 的主题接口。

  • gz_type_name:Gazebo 的主题接口。您可以使用 gz topic -i -``t <topic> 找到它。

  • direction:可以是 ROS_TO_GZGZ_TO_ROSBIDIRECTIONAL。例如,/cmd_vel 是我们在 ROS 2 中发布并订阅的主题,因此我们使用 ROS_TO_GZ。对于 /joint_states,我们在 Gazebo 中发布并在 ROS 2 中订阅,所以它将是 GZ_TO_ROS。如果您想在同一主题的两侧都有发布者和订阅者,可以使用 BIDIRECTIONAL

如您所见,我们需要提供两边的主题名称和接口,并指定要使用的通信方向。有了这个,ros_gz_bridge 将创建连接。

使用这个第一个桥接器,我们将能够向机器人发送命令,使其通过差速驱动系统移动。现在,让我们添加 /joint_states 主题(由关节状态发布系统发布)的配置:

- ros_topic_name: "/joint_states"
  gz_topic_name: "/world/empty/model/my_robot/joint_state"
  ros_type_name: "sensor_msgs/msg/JointState"
  gz_type_name: "gz.msgs.Model"
  direction: GZ_TO_ROS

这将使我们能够获取机器人的所有关节状态,从而在 RViz 中看到车轮 TF。最后,为了获取由差速驱动系统发布的 odombase_footprint TF,我们还要添加这个桥接器:

- ros_topic_name: "/tf"
  gz_topic_name: "/model/my_robot/tf"
  ros_type_name: "tf2_msgs/msg/TFMessage"
  gz_type_name: "gz.msgs.Pose_V"
  direction: GZ_TO_ROS

配置文件已完成。由于我们已经在 CMakeLists.txt 中添加了安装指令,因此无需做其他任何事情。

使用配置启动 Gazebo 桥接器

我们现在可以向我们的 my_robot.launch.xml 文件中添加一个新的节点来启动桥接器,使用我们刚刚创建的 YAML 配置文件。

首先,在文件的开头,让我们添加一个新变量来查找配置文件的路径:

<let name="gazebo_config_path" value="$(find-pkg-share my_robot_bringup)/config/gazebo_bridge.yaml" />

然后,在 Gazebo 中使用来自 ros_gz_simcreate 可执行文件启动机器人后,启动 Gazebo 桥接器。您需要通过 config_file 参数传递配置文件:

<node pkg="ros_gz_bridge" exec="parameter_bridge">
    <param name="config_file"
           value="$(var gazebo_config_path)" />
</node>

由于我们在 my_robot_bringup 中使用了 ros_gz_bridge 包,我们还将向 package.xml 文件中添加一个新的依赖项:

<exec_depend>ros_gz_bridge</exec_depend>

Gazebo 桥接器现在已正确配置。当您启动应用程序时,ROS 2 和 Gazebo 将能够相互通信。

测试机器人

在本节最后,我们将通过测试机器人的行为以及可视化 RViz 中的机器人和 TF 来确保一切正常。

保存所有文件,构建并源代码工作区,然后再次启动my_robot.launch.xml文件。

在另一个终端中列出所有主题,您将看到我们之前配置的/cmd_vel主题。此主题的接口与我们在书籍的第二部分中使用的 Turtlesim 相同,因此您应该熟悉它。从终端发送速度命令:

$ ros2 topic pub /cmd_vel geometry_msgs/msg/Twist "{linear: {x: 0.5}}"

机器人应该在 Gazebo 中开始移动(要停止,发送带有{x: 0.0}的相同命令)。如果您看到机器人移动,这意味着网桥配置正确,因为 ROS 2 主题可以到达 Gazebo 系统。这也意味着差速驱动系统工作正常。

为了实现更好的机器人控制和进行更多测试,您可以运行此节点:

$ ros2 run teleop_twist_keyboard teleop_twist_keyboard

这将监听您的键盘并发布到/cmd_vel主题(如果您为该主题使用了不同的名称,只需使用–-ros-args -r添加重映射即可)。

现在,我们已经验证了当我们发送命令时,机器人在 Gazebo 中可以移动。

要检查 TF,您可以执行以下操作:

  • 订阅到/joint_states主题,查看左右轮的状态

  • 订阅到/tf主题,查看所有发布的 TF

  • 打印 TF 树(ros2 run tf2_tools view_frames),它应包含所有 TF,包括轮子的两个 TF 以及odombase_footprint之间的一个额外 TF。

注意

如果某些事情不起作用或某些主题数据缺失,那么系统或网桥中至少有一个配置不正确。为了解决这个问题,首先检查 Gazebo 侧的主题(gz topic命令行)。如果您看到正确数据,那么网桥配置错误;如果没有,从系统开始。

因此,我们可以控制机器人并在我们的 ROS 2 应用程序中获得正确的 TF。最后,让我们启动 RViz。您可以使用命令行,但您也可以根据需要将 RViz 直接添加到启动文件中。在这种情况下,我们首先创建一个变量来查找 RViz 配置路径:

<let name="rviz_config_path" value="$(find-pkg-share my_robot_description)/rviz/urdf_config.rviz" />

我们将使用之前在my_robot_description中创建的文件。您也可以创建一个新的 RViz 配置文件并将其安装到my_robot_bringup中。然后,在启动所有其他节点之后,我们开始启动 RViz:

<node pkg="rviz2" exec="rviz2" args="-d $(var rviz_config_path)" />

因此,当您启动启动文件时,您将同时拥有 Gazebo 和 RViz。我们之前在 RViz 中得到的 TF 错误(见图 13**.6)应该不再存在。

您可以做的事情之一是在全局选项中选择odom作为固定框架。使用此设置,当机器人在 Gazebo 中移动时,您也会在 RViz 中看到它从起始位置移动。

我们的应用程序现在已完成。Gazebo 系统正确工作,并且可以与 ROS 2 侧通信。循环已闭合。

摘要

在本章中,您学习了如何在 Gazebo 中模拟您的机器人。

你首先发现了 Gazebo 是如何工作的。Gazebo 是一个 3D 模拟工具,可以在环境中模拟重力以及你的机器人的物理属性——与仅作为开发调试有帮助的可视化工具 RViz 不同。

然后,你遵循了在 Gazebo 中模拟机器人的过程。以下是步骤的回顾:

  1. 在你开始之前,确保你有一个正确描述你机器人所有链接和关节的 URDF(这是我们之前章节中做的事情)。

  2. 通过为每个链接添加<inertial><collision>标签来调整 URDF 以适应 Gazebo。你可以使用 RViz 来可视化这些属性,并确保它们是正确的。

  3. 在 Gazebo 中生成机器人。为此,你首先启动 Gazebo 模拟器和robot_state_publisher节点。然后,你可以生成机器人。

  4. 使用插件(即系统)控制机器人。要使用一个系统,你需要在你的 URDF 中添加一个<plugin>标签。然后,为了能够将 Gazebo 系统与 ROS 2 连接,你可以使用ros_gz_bridge包,并在一个 YAML 文件中提供桥接配置。

在整个过程中,我们将应用程序组织成两个包:

  • my_robot_description:这包含 URDF,包括链接、关节、惯性和碰撞属性以及 Gazebo 系统。

  • my_robot_bringup:这包含启动应用程序的 launch 文件和 Gazebo 桥接的 YAML 配置文件。

我们在第三部分开始的项目现在已经完成。你有一个完整的 3D 机器人模拟,你可以将整个过程(不仅限于本章,还包括所有之前的章节)应用到任何你创建的定制机器人上。

当然,这并不是结束;你可能会想对你的机器人以及 ROS 2 做更多的事情。在下一章中,我们将结束本书,并提供额外的资源和技巧,帮助你更进一步。

第十四章:进一步学习 – 下一步该做什么

你现在已经完成了这本书——恭喜!学习 ROS 2 是一个相当大的挑战,你已经迈出了重要的一步。

回顾一下,这是你所学到的内容:

  • 第一部分: 你澄清了一些误解,安装了 ROS 2,并通过实验发现了一些主要概念。这为你阅读本书的其余部分奠定了基础。

  • 第二部分: 这是你学习了最重要的 ROS 2 概念的地方:如何编写节点并与主题、服务和动作进行通信,以及如何通过参数和启动文件使你的应用程序更加动态。

  • 第三部分: 你构建了一个模拟机器人,在练习核心概念的同时,你学习了 TF、URDF 和 Gazebo。TFs 几乎是每个 ROS 2 应用程序的骨架。

现在,你有一个坚实的基础,可以用于任何其他 ROS 2 项目。现在,我不想让你止步于此,只是说这就结束了。ROS 2 包含了很多东西,而机器人技术总体上比 ROS 2 更广泛。因此,为了帮助你更好地为未来做准备,在这最后一章的小节中,我将给你一些建议,告诉你下一步该做什么。

下一步该做什么对每个人来说都不一样。我将首先尝试提供一个通用的路线图,然后探讨不同的细微差别和细节,这将帮助你根据自己的目标选择学习的内容。我还会分享一些额外的资源,你可以使用这些资源来学习更多关于 ROS 2 的知识。

到本章结束时,你将对自己的下一步行动有一个更好的想法,这取决于你的项目、工作或学习目标。

在本章中,我们将涵盖以下主题:

  • ROS 2 路线图 – 探索阶段

  • 有特定目标的学习

ROS 2 路线图 – 探索阶段

在学习一个技术主题时,我会说通常的模式是这样的:

  1. 发现阶段: 你从每个人都应该学习的基础知识开始,对技术有一个广泛的理解,并学习如何使用它。

  2. 探索阶段: 一旦你有了基础知识,你将尝试探索与该技术相关的不同应用、项目和主题。这将使你连接许多点,并使你对全局图景有更深入的理解。你也会在技术上变得更好。

  3. 专业化阶段: 一个人不可能对每件事都成为专家。在某个时候,为了能够深入一个项目,找到一份工作,或者建立职业生涯,你需要在一个特定领域进行专业化。在探索了许多主题之后,你将更好地了解你想做什么,或者什么最需要。然后你可以集中精力进行专业化。

通过这本书,你已经完成了第一步。你学到了绝对需要并且肯定会在几乎所有未来项目中使用的基础知识。

我们接下来要讨论的是探索阶段,它紧接着那个阶段。在本节中,我将尝试为你提供一个 ROS 2 路线图。这个路线图是一个(非详尽)的列表,列出了你可以接下来学习的内容,没有特定的顺序。

在我们开始之前,请注意我不建议完全按照路线图进行。最佳的学习方式是通过项目。因此,在本节中,我们有一个技能列表,在下一节中,我们将探索不同的项目/工作示例,以了解如何选择你需要学习的技能。

此外,这是我的个人版本;并不是每个 ROS 专家都会必然同意我的观点。如果你找到了对你有效的方法,那完全没问题。这里的目的是取得进步。

我没有包括本节的链接,因为使用提供的关键词进行简单的 Google 搜索就可以。你将主要在官方文档、独立教程和 YouTube 视频中找到资源,GitHub 项目以及论坛中提出的问题。

常见的堆栈和框架

在学习核心编程基础和概念,如 TF 和 URDF 之后,一个非常常见的下一步是学习一些现有的 ROS 2 堆栈框架,以及学习如何创建 ROS 2 和硬件组件之间的接口。

注意

你经常会看到术语 堆栈框架 以及其他变体。它们通常意味着相同的事物。基本上,它们是专注于解决特定问题的包的集合。

在那些堆栈/框架中,你可以找到 Navigation 2(用于 移动机器人)、MoveIt 2(用于 机器人臂 和抓手)、以及 ros2_control(用于 硬件控制)。

我们将讨论它们,因为它们被广泛应用于各种应用中,了解它们对你来说很可能是有益的。让我们从硬件接口开始。

硬件接口(以及 ros2_control)

最后,机器人开发者创建软件是为了控制硬件组件。你使电机移动,从传感器读取数据,并在中间添加一些算法来创建一个有用的机器人系统。

硬件接口部分至关重要,除非你只做仿真工作,否则你将不得不与硬件打交道。因此,我建议你更熟悉如何编写硬件驱动程序(不特定于 ROS)以及如何将此硬件驱动程序与 ROS 2 接口。

你可以先尝试为简单的硬件(任何东西:电机、摄像头或任何其他传感器)创建自己的驱动程序。一旦你可以用 Python 或 C++ 控制你的硬件,将你的驱动程序包含在一个 ROS 2 节点中,并添加主题/服务/参数以在驱动程序和 ROS 2 之间创建桥梁。

你也可以找到现有的具有 ROS 2 驱动程序的硬件组件,并查看它们的接口和代码(通常可在 GitHub 上找到)。

理解如何与硬件交互是你在 ROS 2 学习过程中的一个重要步骤。

然后,一旦你掌握了这个,就有一个伟大的框架,它允许你创建你 ROS 2 应用程序和硬件驱动程序之间稳健的接口。它被称为 ros2_control。这个框架被大多数由 ROS 2 驱动的机器人使用。一旦你理解了它是如何工作的,你就可以非常快速地设置带有硬件连接的新机器人。

警告:学习 ros2_control 并不容易,文档也不是我所说的适合初学者的。它还要求你用 C++编写代码,并了解更多关于高级 ROS 2 概念,如生命周期节点和组件。我不建议你在阅读这本书后直接学习 ros2_control,尤其是如果你是 ROS 2 旅程的初学者。

我建议的是,你首先熟悉为 ROS 2 创建基本硬件接口。然后,随着你在项目中的进展,你会更多地了解高级 ROS 2 概念,并达到可以处理 ros2_control 的阶段。

Navigation 2堆栈,也称为Nav2,因其原因而非常受欢迎:大多数使用 ROS 2 的机器人都是移动机器人,而你通常会用移动机器人做什么呢?你让它在一个物理环境中自主导航。

现在,凭借这本书中你学到的 ROS 2 基础知识,你该如何实现这一点呢?在编写节点和 URDFs 与使机器人使用路径规划算法导航之间存在着很大的差距。如果你要自己实现,这将花费你大量的时间和精力。

如果你还记得,在这本书的引言中,我们讨论了在机器人领域,大部分时间都花在重新发明轮子上。我们不想那样做。

幸运的是,你可以使用 Nav2 堆栈。使用这个堆栈,你可以轻松地创建环境地图,使用同时定位与建图SLAM),然后使用这个地图让你的机器人从一个地方导航到另一个地方,同时避开障碍物。

当然,Nav2 堆栈有其自身的挑战,但一旦你理解了它,你就能迅速搭建一个新的自主移动机器人,并且可以轻松地使用这个堆栈处理数百个现有的机器人项目。

因此,我强烈建议你学习一些关于 Nav2 的知识。即使你并不打算与移动机器人合作,你也能在几个小时内获得基本理解。深入学习和实际为 Nav2 适配机器人将需要更长的时间,但现阶段,只需掌握基础知识即可。

MoveIt 2

因此,有一个移动机器人的堆栈,猜猜看?还有一个机械臂的堆栈。

注意

在 ROS 2 项目和工作中,你将遇到的最常见的两种机器人是移动机器人和机械臂。然后是无人机,但它们更专业,支持度较低。这并不意味着你不能找到好的包来帮助你处理无人机,但肯定会有更多的挑战。然后,还有更多专业(且支持度更低)的,比如船只、蜘蛛机器人、潜艇等等。

如果你正在使用机械臂(比如说 5 轴、6 轴或 7 轴),你需要找到一种方法来计算这个机械臂的位置和轨迹。你是如何让机械臂达到一个特定点并保持一个定义好的方向,或者拿起一个物体并将其放置在另一个地方?

这可能成为一个相当大的挑战,尤其是在你学习了逆运动学、运动规划,并尝试让机器人的所有关节同时移动并到达同一位置,同时不与任何物体碰撞,并保持连续的位置、速度和加速度时。

MoveIt 2 将为你进行运动规划,无论是为机械臂还是具有多个机械臂的系统。它还具有抓取功能。

你需要对你的机器人进行一些配置(从 URDF 开始),然后你可以在你的节点中直接使用 Python 或 C++ API 发送命令来控制机器人。

我建议你至少对 MoveIt 2 有一个基本的了解,即使你不会使用机械臂。你可以在几个小时内设置一个基本的项目,并查看主要功能。

使用 Nav2 和 MoveIt 2,你可以覆盖很多领域。我不知道使用这两个堆栈的机器人的确切百分比,但肯定超过所有 ROS 2 机器人的半数。Nav2 和 MoveIt 2 也都有与 ros2_control 的集成,当你学习更多关于 ros2_control 时,你可以探索这些集成。

现在我们来探讨更多与 ROS 2 相关的主题。

更多探索主题

在常见的堆栈和框架之上,还有很多其他你可以学习或改进的东西。在这里,我们将从基础知识开始探索。正如本节引言中所述,对于任何主题,在 Google 中输入相关关键词——也许后面跟着教程——你将找到你需要的东西(如前几章所述,对于更高级的 ROS 2 概念,文档可能非常稀缺)。再次强调,对于这个技能列表,你没有特定的顺序需要遵循。在本章的后面部分,我们将通过一些项目和工作的例子,看看根据你的目标应该学习什么。

回到基础知识

在跳到高级 ROS 2 概念之前,你应该确保你已经掌握了基础知识。也许当你阅读这本书并做练习时,使用所有终端命令对你来说是一个挑战。或者也许 Python 代码是好的,但使用面向对象编程(OOP)并不是你习惯的。

以供参考,在我的课程或研讨会中,我经常看到人们在终端中使用自动完成输入正确的命令时遇到困难。这是一个基本技能,它会使你在 Linux 上工作快五倍,但如果你不知道如何正确地做,你的整个 ROS 2 学习体验将会大大减慢。你不能忽视基础知识。

在这一点上,提高以下技能可能会有所帮助:

  • .bashrc操作,导航到包安装文件夹,使用 SSH 获取远程访问:这些都是你为了将来不陷入困境需要了解的例子。你不需要成为 Linux 专家,但花几个小时提高基础知识不会有坏处。

  • Python:我想在这个时候你应该对 Python 相当熟悉了,但如果你因为 Python(而不是 ROS 2)遇到了一些挑战,那么回顾一些基础知识可能会有所帮助,特别是关于类和面向对象编程。

  • C++:如果你只看了 Python 示例,现在是尝试用 C++做同样事情的好时机。这不仅会让你再次回顾这些概念,而且随着你对 ROS 2 的深入,你会意识到大量的代码只使用 C++编写,特别是对于需要大量计算能力的硬件控制和算法。如果你想成为一名优秀的机器人开发者,你需要 C++。

现在,一旦你掌握了这些基础知识,你还能学习哪些其他核心的 ROS 2 概念?

更高级的 ROS 2 概念

一旦你熟悉了这本书中的先决条件和概念,你就可以进一步学习更高级的 ROS 2 概念,例如以下内容:

  • 动作:我们在第七章中看到了动作,但我明确指出,这是一个更高级的概念,如果你当时感到不知所措,可能值得跳过。如果你还没有处理过动作,现在是一个好时机。动作,连同主题和服务,将允许你在节点之间使用所有 ROS 2 通信机制。

  • 生命周期节点(也称为管理节点):这些节点包含一个状态机,允许你轻松地将代码分离到初始化和激活的不同部分。这在处理硬件时特别有用。例如,你可以在将硬件组件用于应用程序的关键部分之前,确保它正确连接并初始化。此外,如果你想学习 ros2_control,生命周期节点也将非常有用。

  • 执行器:使用执行器,你可以更好地控制节点或多个节点内回调的处理(我们在第七章添加取消机制部分中看到了一个例子)。

  • 组件:通过将你的节点做成组件,你可以在一个可执行文件中运行多个节点。这可以减少资源使用并加快通信。要了解组件,你首先需要理解执行器。然后,组件还将帮助你理解 ros2_control。

这不是一个最终列表,但我可以说,几乎每个 ROS 开发者都可能在某个时候需要这些概念。

此外,还有许多与 ROS 无关的附加技术,这些技术可能很有帮助。

额外的技术和领域

正如我们在本章后面通过查看一些工作示例时将看到的,成为一名 ROS 或机器人开发者并不意味着你只需要学习 ROS。还需要掌握许多其他技能。

这里有一些你可以探索的技术、领域和工具(我再次强调,这是一个非详尽的列表,只是几个例子,且没有特定的顺序):

  • 电子/硬件: 机器人中有很大一部分是硬件。最终,你编写代码来控制硬件。没有硬件,就没有一切。这是一个完整的领域。你可以了解更多关于硬件平台、通信协议、焊接组件、设计印刷电路板PCB)等方面的知识。有些人就是从这个领域(硬件工程师)开始职业生涯的。即使你不想走这条路,了解一些这方面的知识也是有帮助的。

  • 机械、CAD 软件、3D 设计: 就像硬件一样,有些人将职业生涯建立在机械工程上。不过不深入探讨,了解如何使用 CAD 软件设计机械部件可能是有用的。

  • 快速原型设计: 这结合了许多领域,其目标,正如其名称所暗示的,是快速创建原型以验证(或不验证)一个想法。对于快速原型设计,你可以使用 3D 打印和嵌入式硬件板,如 Arduino 和 Raspberry Pi(你可以在 Raspberry Pi 上运行 ROS 2)。

  • Git、持续集成/部署: 很可能你将与其他人一起工作。这些工具将帮助你更容易地进行协作和发布代码。

  • Docker: 在机器人开发者中,Docker 的使用正在增加,因为你可以轻松地使用正确的 Ubuntu/ROS 2 版本设置一个新的环境。这对于同时处理多个项目和在不同的环境中测试你的代码来说非常有用。

  • DDS、网络: ROS 2 的通信依赖于数据分发服务DDS),根据你的项目,你可能需要更深入地了解这一点,以及一般性的网络。

  • 图像处理、机器学习等: 机器人领域有一个巨大的分支是用于分析环境和从中提取有用信息的。你可以找到很多与相机、激光扫描、深度传感器以及如 OpenCV 等库的 ROS 2 集成。

正如你所见,这有很多东西。当看到这个列表时,你可能会感到有些气馁,因为你意识到即使完成了一本关于 ROS 2 的书,你所知仍然很少。不过别担心;你不需要学习所有这些。我个人并不是所有这些领域的专家,没有人是。现在我们将通过设定一个具体的目标来更有效地学习 ROS 2。

有针对性的学习

之前列出的框架、堆栈和技术可以帮助你看到机器人领域的全局图景,并帮助你选择接下来需要学习的内容。

但是,最终,根据你的目标,你的学习路径可能会有所不同。如果你是一名学生,想要用移动机器人完成大学项目,或者如果你需要提高速度为一家创建模拟产品的机器人初创公司工作,答案将会不同。

在本节中,我们将探讨一些项目和工作的例子,看看哪些学习路径更适合每个例子。请注意,我们将看到的例子并不构成路径的完整列表。你自己的路径将是独一无二的。本节的真正目的是向你展示,你应该首先考虑你想要用 ROS 2 做什么,然后根据这个目标选择要学习的内容。

不要过于担心:即使你选择了路径后来又改变了,也不要担心。你现在不需要对想要追求的项目或职业有一个明确的答案。记住,你仍然处于探索阶段。探索意味着走一条路,然后可能意识到你更喜欢另一条路,直到你找到真正吸引你的东西。你在探索过程中获得的所有知识都将是有价值的。

让我们深入探讨。

为项目学习什么?

最好的整体方法是找到一个项目并在实践中学习。在构建项目的过程中,你将遇到一些挑战。解决这些挑战通常意味着学习新事物,并且它们将迫使你发展更好的实际理解。

现在,在哪里寻找项目呢?互联网上有大量的项目想法。根据你的硬件和财务资源,你可能从只涉及模拟的项目开始,或者如果你有一些硬件,你可以自己制作一个机器人或机器人的部分,并让机器人在现实世界中执行任务。

一些项目示例

让我们考虑几个项目和它们涉及的学习路径:

  • 使用移动机器人寻找图书馆中的书籍(典型的大学项目):在这里,你需要让机器人导航,因此你可能需要学习关于 Nav2 堆栈的知识,以便绘制图书馆地图并让机器人在其中移动。除此之外,你还需要想出如何找到书籍,可能需要使用摄像头。你将选择并测试一个摄像头,并将其集成到你的机器人中。这将使你练习与机器人硬件的接口。然后,为了识别书籍,你将使用图像处理。此外,你可以从使用现有的移动平台开始,然后设计你自己的。

  • 使用移动机器人进行仓库管理:在这个项目中,你也需要让机器人导航,但这次你将不得不以有组织的方式让多个机器人在同一环境中工作。你可以学习关于控制机器人集群的知识,这是我们之前没有列出的一项新学习内容。

  • 使用无人机进行维护巡逻:在这种情况下,精确的硬件控制和远程通信将是第一个挑战。然后,你必须控制无人机的行为。无人机是一种特定的机器人,MoveIt 2 或 Nav2 堆栈不适用;你需要找到其他方法。遗憾的是,在撰写本文时,ROS 2 中还没有现成的用于无人机的即插即用堆栈。因此,你需要进行一些额外的研究和努力。

  • 在生产线中分类物体:在这里,我们进入了一个完全不同的领域,即物体的操作。如果没有提供,你需要找到可以使用哪种机器人臂(或其他设备)来拾取和放置物体,需要使用哪种抓取系统,等等。然后,你可以使用 MoveIt 2 来控制机器人。之后,你还需要找到一种方法来分类产品。可能你需要一个摄像头;在这种情况下,你必须正确放置摄像头,进行一些校准,并协调整个系统,以便机器人知道应该拾取哪些物体,以及在哪里。

这些只是一些例子,但你可以看到,根据你将使用哪种类型的机器人,以及你打算如何使用这些机器人,应用将完全不同,这导致了不同的学习路径。

我个人的学习路径

为了再给你举一个例子,这里是我的与 ROS 的个人故事。我是在共同创立一家机器人初创公司时发现 ROS 的。我们想要创建一个教育用的 6 轴机器人臂,而我负责软件部分。

这大致是我的 ROS 学习路径:我从基础知识开始,然后迅速转向创建一个简化的 URDF 文件,让机器人通过 MoveIt 移动,这样我们就可以在 RViz 中看到它移动(我们当时还没有 Gazebo 模拟,但我们正在构建一个真实的物理机器人,所以我们首先关注物理控制)。

然后,最大的挑战是找到可靠的低成本组件和电机来控制每个 6 个轴,为每个组件创建一个硬件驱动程序,并从集成的 Raspberry Pi 板上控制它们。这涉及到学习通信协议(以及 ros_control),与硬件紧密合作,并进行大量实验。

在另一个层面,目标是创建一个直观的用户界面,所以我们基于 ROS 开发了一些 API,以及一个使用 Angular 的图形界面。仅此一项就需要其他类型的技能。

当然,这个故事过于简化,但正如你所见,在掌握基础知识之后,我学会了我在需要时需要学习的内容,这样我就可以在项目上取得进展。例如,我并没有首先学习 Gazebo 或导航堆栈;这来得晚得多。原因很简单,那段时间那不是最重要的学习内容。

要获得工作需要学习什么?

你可能会想:做项目很好,但如果你目标是获得一份工作并开始机器人领域的职业生涯,你应该学习什么?

再次强调,答案仍然是:视情况而定。然而,你可以查看互联网上找到的工作机会,看看你想要追求的那种工作需要什么。

此外,对于初级职位,通常不期望你在任何特定领域是专家。公司知道你刚开始,你还需要学习。因此,对于初级职位或实习,强烈的求知欲(加分项:展示个人项目的作品集)通常比在 x、y 或 z 方面的技能更重要。

现在,为了给你一些实际的例子,我找到了一些现有的工作机会,我将向你展示他们要求的一些技术要求。为了清楚起见:我不会在这里推广任何公司或工作机会,我只会简要回顾他们要求的技术技能(为了简洁,我已重新编写),仅用于示例。

第 1 个工作岗位 – ROS 2 开发实习

这里是围绕 ROS 2 开发实习的要求:

  • Python 3

  • ROS 1, ROS 2

  • Linux、Bash、Git

  • 路径规划和避障的概念

  • 网络和通信协议(可选)

  • C++(可选)

为了准备这个实习,你会发现你需要发展你的 Python、Linux 和 ROS 技能。

注意

他们也提到了 ROS 1。值得注意的是,有时需要 ROS 1(为了处理遗留项目),但你可以在路上学习它。有时,撰写工作描述的人并不真正理解工作的技术部分,只是添加了他们遇到的尽可能多的关键词,以使提供的工作看起来更通用,吸引更多候选人。在这种情况下,你可能会使用 ROS 2。

路径规划和避障也包括在内,这意味着探索 Nav2 堆栈可能会给你带来竞争优势。

第 2 个工作岗位 – 人工智能机器人实习

这里是另一个以人工智能为重点的实习:

  • 机器学习

  • 精通 Python,有 PyTorch 或 TensorFlow 的经验

  • ROS

  • 对移动和智能机器人的热情

对于这个实习,机器学习可能是工作中最重要的部分,同时与使用 ROS 的机器人一起工作。因此,对于这样的工作,你应该同样学习机器学习,如果你对此感兴趣的话。

正如你所看到的,许多 ROS 工作并不是完全围绕 ROS 进行的。ROS 只是他们使用的工具之一。

第 3 个工作岗位 – 仿生机器人开发

这里是一些中级工作(需要工作经验的工作)的要求,其中你将参与仿生机器人的开发:

  • ROS 2

  • Linux、Git 和 CI 工作流程

  • Jetson 平台

  • 机器人运动学和动力学

  • 控制算法:力、阻抗、MPC

  • 通信协议:CAN、I2C、SPI 等

在这个工作中,机器人控制、通信协议以及它们在嵌入式 Linux 平台上的应用至关重要。很可能 80%的挑战将集中在这一点上,其余的将关于如何使它与 ROS 2 协同工作。

我就到这里。重点是展示不同的工作可能需要非常不同的技能组合,尽管它们都被标记为 ROS 工作。在这些工作中,ROS 可能是主要焦点,但许多工作只是将 ROS 作为众多工具中的一个来使用。

因此,如果你有一个想要申请的梦想工作,首先关注所需的技能。为了学习这些技能,找到一个与这些技能相匹配的项目,然后,随着你的进展,找到帮助你完成项目的学习资源。

摘要

在这一章中,我们关注了你在完成这本书后可以做什么来有效地继续学习 ROS 2。这个问题可能很难回答,因为机器人技术太过广泛,任何人都不可能掌握所有内容。

为了特定目标(例如,求职或工作/学校项目)的学习是最好的,因为你将学习可以直接应用的东西。

如果你真的不知道该做什么,我也为你列出了一个常见的 ROS 2 堆栈/框架列表,以及其他与 ROS 2 相关的主题,你可以学习。我强烈建议你通过做项目来学习这些内容,并专注于通过实践教授你的实用资源。

如果你喜欢这本书以及我的教学方法,这里有一些我提供的更多资源:

  • 机器人后端网站 (roboticsbackend.com/):在这里,你可以找到更多关于 ROS 2 和其他机器人相关主题的书面教程

  • 机器人后端 YouTube 频道 (www.youtube.com/c/RoboticsBackend):提供视频教程和免费的快速课程

  • 完整在线课程 (roboticsbackend.com/courses/):我还提供了一些完整的 ROS 2 课程,你可以购买,这些课程重点在于实践学习

正如我们在本章中看到的,你现在已经完成了探索阶段,并进入了探索阶段。随着你进步并提高你的技能,你将开始找到你特别想深入研究的领域。根据这个,以及你获得的机会,你将开始专业化。

在那之前,不要过度思考任何事情,尽可能多地学习和探索。开始几个项目,学习其他技术,保持好奇心。同时,别忘了在学习和构建项目时享受乐趣。这是能激励你更进一步的最重要的事情之一。

我祝愿你在旅途中好运,希望这本书能帮助你迈出正确的第一步!

posted @ 2025-09-23 21:56  绝不原创的飞龙  阅读(201)  评论(0)    收藏  举报