第5章 动作
在上一章中,我们探讨了 ROS 的服务机制。服务机制常用于同步的请求/响应交互方式,这种情形下,异步的ROS话题机制并不合适。然而,即使对于同步的请求/响应而言,服务机制有时也未必是一个好的选择,尤其是当需要完成的任务比较复杂时。
虽然服务机制对于值查询,管理配置等简单操作来说非常方便,但是当你需要开始一个耗时较长的操作时,服务机制就不太好用了。举例说,你想通过“goto_position”命令让一个机器人运动到较远的地方,这个操作显然要花费较长的时间(数秒,数分钟甚至更长),而且具体的完成时间也无法预知,因为半路上很可能会出现障碍物,导致整个操作的耗时进一步增加。
如果现在已经有了一个叫“goto_position”的服务接口,那么通过这个接口进行控制的流程大概就会像这样:首先发送一个包含目标位置的请求,然后等待一段不确定的时间,直到收到响应。在等待响应期间,请求程序会被强行阻塞,因而完全无法获知机器人的操作进度,更不能取消操作或是改变目标位置。这都给我们带来了很多的不便。为了解决这些问题,ROS 引入了动作机制。
ROS 的动作机制非常适合作为时间不确定,目标导向型的操作的接口,比如 goto_position。服务机制是同步的,而动作机制则是异步的。与服务的请求/响应结构类似,动作使用目标来启动一次操作,在操作完成后会发送一个结果。在此基础上,动作引入了反馈来提供操作的进度信息,还支持取消当前进行中的操作。从原理上看,动作使用话题实现,其本质上相当于一个规定了一系列话题(目标,结果,反馈等)的组合使用方法的高层协议。
改用动作接口实现 goto_position ,整个控制流程就会变成这样:首先还是发送一个目标,然后就可以转去做其他工作了。在操作的执行过程中,会周期性收到执行进度的消息(已经移动的距离,预计完成时间等等),直到操作完成,收到最终的结果(顺利完成或是提前终止)。而且,如果突然来了更重要的任务,可以随时取消当前的操作,并开始一个新的操作。
动作的定义和使用只比服务稍微复杂了一点,但是却强大得多,也灵活得多。下面就来看看动作机制是如何工作的。
动作的定义
创建一个动作,首先要在动作定义文件中定义目标,结果和反馈的消息格式。动作定义文件的后缀名为.action。其组成与服务的.srv文件非常相似,只是多了一个消息项。在编译过程中,.action 文件也会被打包为一个消息类型。
为简便起见,我们下面将定义一个行为类似定时器的动作(对 goto_position 这样复杂行为的动作封装会留在第10章讲解)。这个定时器会进行倒计时,并在倒计时停止时发出信号。在计数过程中,它会定期告诉我们剩余的时间。计数结束后,它会告诉我们总共的计数时长。
我们编写这样一个定时器主要是因为它足够简单,便于用来讲解ROS 的动作机制。在真实的机器人系统中,你可以使用 ROS 内置的客户端程序库实现定时器的功能,如 rospy.sleep()。
Example 15-1 定义了一个满足定时器需求的动作。
这是一个动作定义文件,由三部分组成:目标,结果和反馈
第一部分:目标,由客户端发送
我们希望等待的总时长
第二部分:结果,由服务端在完成时发送
我们总共等待的时长
计数过程中总共发出了多少更新消息
第三部分:反馈,由服务端在执行过程中定期发送
定时器从开始到现在的计数时长
剩下的计数时间
就像服务定义文件一样,我们用三个短横线表示不同定义部分的分隔符,只不过服务定义文件只有两个部分(请求和响应),而动作定义文件有三个部分(目标,结果和反馈)。
动作定义文件 Timer.action 应放在 ROS 包的 action 目录下。在我们之前使用的样例中已经包含了这个文件,位于basics 包下。
编写好定义文件后,下一步就是运行 catkin_make,创建该动作实际使用的代码和类定义。这样就需要在 CMakeLists.txt 中添加一些内容。首先,添加 actionlib-msgs 至 find-package 的括号中(附在其他包之后)。
接下来,在 add-action-files() 中告诉 catkin 你想要编译哪些动作文件。
确保你已经在 generate_message() 中列出了所有的消息依赖。除此之外,你还要显式的列出 actionlib-msgs,以保证动作能够被正确编译。
最后,在 catkin-package 中添加 actionlib-msgs 依赖。
上述所有工作到位后,在 catkin 工作区的顶层目录运行 catkin_make。动作被正确编译后会生成一些消息文件,包括 Timer.action,TimerActionFeedback.msg,TimerActionGoal.msg,TimerActionResult.msg,TimerFeedback.msg,TimerGoal.msg,TimerResult.msg。这些消息文件就会被用于实现动作的 client/server 协议。最终,消息文件会被转化为相应的类定义。在多数情况下,你只需用到这些类的一部分,下面的样例程序会清楚地说明这一点。
实现一个基本的动作服务器
准备好了所需的动作定义后就可以开始编写代码了。动作与话题和服务一样,都使用回调机制,即当你的回调函数会在收到消息时被唤醒和调用。
对于动作服务器,直接使用 actionlib 包中的 SimpleActionServer 类可以简化编写过程。这样我们就只需要定义收到目标时的回调函数。回调函数中会根据目标来操作定时器,并在操作结束后返回一个结果。反馈则会在下一步中加上。Example 5-2 展示了动作服务器的一部分代码:
下面来仔细看一下上诉代码中的关键部分。首先,我们导入了 Python 的time 标准库,用于提供定时器的计时功能。我们还导入了 ROS 的actionlib 包来提供将要使用的 SimpleActionServer。最后导入的是一些从 Timer.action 中自动生成的消息类。
下一步,定义了函数 do_timer()。这个函数会在收到一个新的目标时被执行。在函数体中,我们对收到的目标进行了处理,并在返回前构造出了动作的结果。传入 do-timer() 中的goal 参数本质上是一个 TimerGoal 类型的对象,其成员即 Timer.action 中 goal 部分中的内容。我们使用 Python 的 time.time() 函数保存当前时间,然后按照目标中的时长进行休眠,注意应将 time-to-wait 对象从 ROS 的 duration 类型转换为秒。
接下来是构造结果消息,对应的类型为 TimerResult,成员即 Timer.action 中result 部分中的内容。time_elapsed 部分由当前时间与开始时间做差得到(需从秒转换为ROS的 Duration 类型)。而 update-sent 则为0,因为我们实际上还没有发送任何反馈。
最后一步就是以结果作为参数,调用set_succeeded(), 告诉SimpleActionServer我们已经成功的执行了目标。执行失败时的做法会在后面说明。
继续看下面的代码。我们像往常一样对节点进行了命名和初始化,然后创建了一个 SimpleActionServer。构造函数的第一个参数为动作服务器的名称,这个名称会成为其所有子话题的名字空间。第二个参数为动作服务器的类型,对于我们来说是 TimerAction。第三个参数为目标的回调函数,即之前讲解过的 do_timer()。最后,通过传递 False 参数来关闭动作服务器的自动启动功能。完成上述构造后,我们显式调用 start() 来启动动作服务器,并进入 ROS 的 spin() 循环中,等待目标的到来。
对于动作服务器而言,自动启动功能应当始终处于禁用状态。因为这样可能会造成竞争问题,从而导致一系列奇怪错误的发生。构造函数的默认行为确实是 ROS 的失误,之所以没有更正,是因为问题被发现时,已经有太多的代码依赖于这个默认行为,导致修改变得十分困难。
功能检查
完成了动作服务器的编写后,需要检查其工作是否正常。启动 roscore,然后运行动作服务器。
先看看相应的话题有没有出现:
看起来不错。可以看见,timer 名字空间下出现了五个话题。使用 rostopic 看看 /timer/goal:
TimerActionGoal 是什么?使用rosmsg 进一步看看:
在 goal.time-to-wait 部分看到了我们的目标定义。只是其他未经我们指定的部分是做什么的?其实这些额外的信息是被服务器和客户端用来追踪动作执行状态的。不过,在目标传入服务器端的回调函数中之前,这些信息就已经被去除了。最后剩下的就只有 TimerGoal 消息,如下面所示:
一般来说,如果你使用的是 actionlib 包中的一些程序库,就不应当访问这些名字里含有 Action 的类型。单纯的 Goal,Result和Feedback已经完全够用了。
如果你有特殊的需求,也可以直接发布或订阅动作服务器的话题,并使用自动生成的这些动作消息类型。这也是ROS 动作机制的特性之一:动作机制仅仅只是基于 ROS 消息的一个高级协议,你可以对其进行充分的自定义。不过,对于大部分应用而言(包括本书中涉及的所有样例),actionlib 程序库足以帮你处理所有的底层消息。
动作的使用
同样为简便起见,我们将直接使用 actionlib 包中的 SimpleActionClient 作为客户端。Example 5-3 展示了一段代码,客户端向服务器发送了一个目标,并且等待结果的到来。
下面讲解一些代码中的要点。在导入模块和节点初始化之后,我们创建了一个 SimpleActionClient。构造函数的第一个参数为动作服务器的名称,用于组成客户端将要访问的话题名。名称必须与我们之前创建的服务器相匹配,即 timer。第二个参数是动作的类型,也要与服务器相匹配,即 TimerAction。
客户端创建完成后,我们让它等待动作服务器启动,等待过程是通过检查先前看到的五个话题实现的。与 rospy.wait-for-service() 类似,SimpleActionClient.wait-for-server() 会阻塞至服务器启动完成。
接下来创建目标。构造一个 TimerGoal 对象,并填入我们希望定时器等待的时间(5s),然后将其发送给服务器。
最后就是等待服务器的结果了。如果一切正常的话,我们应该会在此处阻塞5秒。结果到来后,就可以用 get_result 来获得它,并打印服务器汇报的 time-elapsed 信息。
功能检查
同样的,需要对客户端进行检查。确保 roscore 和动作服务器均已启动,然后运行客户端:
在启动客户端和打印结果信息之间,应该会出现约 5s 的延迟。而结果中的time_elapsed 则会比5s 稍微多一些,因为time.sleep() 的阻塞时间往往会比请求的时间长。
实现一个更复杂的动作服务器
到目前为止,动作看起来跟服务还是非常像的,只是在配置和启动的上多了一些步骤。实际上,动作与服务的主要区别在于动作的异步特性。下面的代码中,是对服务侧的代码进行一些修改后得到的,实现了终止目标,处理打断请求和实时反馈等功能。如Example 5-4:
下面讲解相对 Example 5-2 做出的修改。首先,由于需要提供反馈,我们增加了对 TimerFeedback 消息类型的导入。
再看到 do_timer() 函数,我们增加了一个变量,用于统计总共发布了多少反馈信息。
同时,我们还增加了一些错误检查。对于请求 time-to-wait 时长大于60s 的情形,我们会通过显式调用 set_aborted() 来终止当前的目标执行。这个调用会向客户端发送一个消息,告知其本次目标已经终止。类似 set-success(),调用过程中,我们传入了 result 和一个警告字符串作为参数,这样可以帮助客户端发现问题所在,即不应当设置过长的等待时间。由于这种情形下不会开始计时,因此在set-aborted() 调用结束后,就会从回调函数中返回。
如果成功通过了错误检查,我们就会进入一个循环,并在循环中进行间断的休眠等待(在之前的实现中,我们就直接开始按照目标时长进行休眠了)。这样的休眠方式下,我们可以在动作的执行过程中进行一些操作,比如检查是否发生打断,提供反馈等等。
在上面的循环中,我们首先通过调用 is-preempt-reques-ted() 检查是否发生打断,如果发生了打断(即客户端在前一个动作还在执行时,发送了新的目标),函数会返回 True,此时就需要填充一个result,同时提供一个表示状态的字符串,然后调用 set-preempted()。
通过了对打断的检查后就该发送反馈了。反馈的消息类型为 TimerFeedback,定义在 Timer.action 的 feedback 部分中。我们需要填充反馈中的 time-elapsed 和 time-remaining 两个成员,然后调用 publish-feedback() 来把反馈发送给客户端。当然,还要增加 update-count ,表示我们进行了一次反馈。
最后就是进行短暂的休眠。下面代码中采用的“固定休眠时长”方法虽然简单,但其实并不好,因为这样很容易导致实际休眠时长超过请求时长的问题。休眠结束后,循环回到开头的条件检查部分。
如果从循环中退出,就表明我们的休眠时长达到了目标,可以告诉客户端目标被成功执行了。具体代码与之前的简单动作服务器差不多,只是增加了对 updates-sent 的填充,还顺带了一个状态字符串。
使用更复杂的动作
现在我们将对动作的客户端做一些调整,以测试服务端的新功能:包括对反馈进行处理,打断正在执行的目标,以及引发一个终止。 改进后的客户端代码见 Example5-5。
下面将对照 Example5-3 对上述代码进行讲解。首先我们定义了一个回调函数 feedback-cb(),当收到反馈消息时会被执行。在回调函数中,我们只是简单地把反馈消息的内容打印了出来。
紧接着,我们将回调函数作为feedback-cd 关键词的参数,传入 send-goal() 中,完成回调的注册:
在接收到结果之后,我们打印了一些信息来显示当前的状态。get-state() 函数返回本次目标对应的执行结果,类型为 actionlib-msgs/GoalStatus。可能的状态共有10种,在上例中只考虑了其中三种:PREEMPTED=2,SUCCEEDED=3 和 ABORTED=4。除此之外还打印了服务端发送的状态字符串。
功能检查
现在可以试试新写好的动作服务器和客户端了。与以前一样,先启动roscore,然后运行server。
在另一个终端内,运行客户端:
所有节点都如期运行起来了:等待过程中,每秒会收到一次反馈。等待结束后,会收到目标执行成功的结果(SUCCEED=3)。
现在试试打断一个执行中的目标。在客户端代码的send-goal()调用后对下面两行解除注释,这样会让客户端在短暂的休眠后打断当前的目标。
再次运行客户端:
表现出的行为与预期吻合:服务器首先执行最开始的目标,并定期进行反馈,直到客户端发出了终止请求。发出终止请求后,很快客户端便收到了服务端发来的结果,表明上一次执行被打断(PREEMPTED=2)。
现在来引发一个服务端的主动终止。在客户端代码中,对下面的代码解除注释,将等待时间从5s 改为500s。
再次运行客户端:
就像我们之前预期的那样,服务端立即主动终止了目标的执行(ABORTED=4)。
小结
本章中,我们探讨了 ROS 的动作机制。动作机制是 ROS 中一个功能强大,使用广泛的通信工具。Table 5-1 展示了话题,服务和动作三大通信机制之间的对比。与服务类似,动作允许你发起一个请求(即目标),同时接收一个响应(即结果)。不过,动作提供了更多的控制形式。服务端可以在执行过程中提供反馈,客户端也可以取消之前发出的目标。并且由于建立在 ROS 消息机制之上,动作机制是异步的,允许服务端和客户端采用无阻塞的编程方式。
Table 5-1 话题,服务和动作机制的对比
类型 | 最佳使用场景 |
---|---|
话题 | 单工通信,尤其是接收方有多个时(如传感器数据流) |
服务 | 简单的请求/响应式交互场景,如询问节点的当前状态 |
动作 | 大部分请求/响应式交互场景,尤其是执行过程不能立即完成时(如导航前往一个目标点) |
综合上文中提到的所有特性,动作机制在机器人编程的许多方面都很适合。在机器人应用中,执行一个时长不定,目标引导新的任务是很常见的,无论是 go-to-position,还是clean-the-house。任何情况下,当需要执行一个任务时,动作都可能是最正确的选择。事实上,每当你想要使用服务时,都值得考虑一下是否可以替换为动作。虽然使用动作需要写更多的代码,但是却比服务更强大,扩展性更好。后续章节中我们将会看到许多样例,在这些样例中,动作为许多复杂的行为提供了许多丰富而易用的接口。
同往常一样,本章没有包含动作机制的完整 API。动作本身还有很多非常精妙的用法,可以用来控制系统的行为。比如怎样应对同时存在的多目标,多客户端场景。更多细节请参加 actionlib 的 API 文档。
到此为止,你应该已经基本了解了 ROS 的大部分基础知识:节点如何组成一张图,如何使用基本的命令行工具,如何写简单的节点,如何让节点之间互相通信。在进一步学习第7章中的第一个完整机器人应用前,先花一点时间讨论一下机器人系统的各个组分(真实的或虚拟的),以及这些组分与 ROS 之间的关系。