这篇博客是 B 站《古月·ROS2入门21讲》的第十一个视频的图文记录,主要介绍了动作 action 的使用和实现示例,虽然 action 底层是以 service + topic 方式实现的,但对于上层使用者而言其 action 的封装可以极大减轻状态量的管理琐事。原始视频链接如下:
- B 站视频链接:11. 动作:完整行为的流程管理
1. 动作 action 的意义
在之前的博客中有介绍到 话题 和 服务 这两个概念,前者其实不关系订阅者的状态,后者虽然会等待服务提供者返回,但也不关系中间状态。
然而,在实际应用中经常会出现需要紧盯中间态的情况,并在必要的时候将其打断,例如小车运动过程中可以实时返回自己走了多久等。虽然你也可以通过话题和服务组合的形式实现这样的功能,但这样 打破了完整性,即你需要自己手动管理话题数据什么时候能作为决策依据并生效,还需要预估服务大概执行到什么状态,当后期机器人状态特别多的时候你 可能会同时维护十几个话题,这种状态机的复杂度是指数增加的。
action 就是为了应对上面情况而产生的,你只需要将有用的中间态作为 feedback 返回给调用者进行决策,而不需要将所有状态都开放出来。
2. 小海龟示例
2.1 仿真器与键盘控制
先打开一个终端启动小海龟仿真器:
bash
$ ros2 run turtlesim turtlesim_node

然后再开一个终端控制小海龟进行运动,在该终端上有 G|B|V|C|E|R|T 这几个选项分别表示让小海龟转向固定的位置,你可以在键盘上敲击这几个键,同时还有 F 键表示在转动过程中打断旋转,这里就是通过 action 实现的:
bash
$ ros2 run turtlesim turtle_teleop_key

2.2 终端形式调用
使用 list 命令可以查看当前有哪些 action 可以使用,当前仅有一个 /turtle1/rotate_absolute 动作可用:
bash
$ ros2 action list

使用 info 命令可以查看该 action 的详细信息:
bash
$ ros2 action info /turtle1/rotate_absolute

使用 send_goal 命令可以向海龟发送一个 action 请求,注意下面这个请求中 theta 的单位是 弧度,在发送请求可以可以观察到小海龟执行了原地旋转:
bash
$ ros2 action send_goal /turtle1/rotate_absolute turtlesim/action/RotateAbsolute "{theta: 3.14}"

可以通过 --feedback 命令将小海龟的中间状态打印出来:
bash
$ ros2 action send_goal /turtle1/rotate_absolute turtlesim/action/RotateAbsolute "{theta: 0.0}" --feed
back

3. 动作 action 实现
3.1 Server & Client
这两个节点在 learning_action 功能包下。此处的 Server 和 Client 和之前服务的概念基本一致,唯一不同的是这里的 Server 和 Client 是以 action 作为媒介交互的。
打开一个终端启动 Server,启动后同样会阻塞等待 Client 请求:
bash
$ ros2 run learning_action action_move_server

再开一个终端启动 Client,启动后会向 Server 发送一个动作请求,并在执行过程中有反馈:
bash
$ ros2 run learning_action action_move_client

3.2 action 接口定义
在 learning_interface 功能包中的 action 文件夹下定义了接口,具体位置在 src/ros2_21_tutorials/learning_interface/action/MoveCircle.action:
MoveCircle.action:
python
# Goal
bool enable # 定义动作的目标,表示动作开始的指令
---
# Result
bool finish # 定义动作的结果,表示是否成功执行
---
# Feedback
int32 state # 定义动作的反馈,表示当前执行到的位置
action 和 srv、msg 一样,需要在 CMakeLists.txt 文件中添加:
cmake
rosidl_generate_interfaces(${PROJECT_NAME}
"msg/ObjectPosition.msg"
"srv/AddTwoInts.srv"
"srv/GetObjectPosition.srv"
"action/MoveCircle.action"
)

3.3 Server 端实现
该节点在 learning_action 功能包中,具体位置为 src/ros2_21_tutorials/learning_action/learning_action/action_move_server.py:
action_move_server.py
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import time
import rclpy # ROS2 Python接口库
from rclpy.node import Node # ROS2 节点类
from rclpy.action import ActionServer # ROS2 动作服务器类
from learning_interface.action import MoveCircle # 自定义的圆周运动接口
class MoveCircleActionServer(Node):
def __init__(self, name):
super().__init__(name) # ROS2节点父类初始化
self._action_server = ActionServer( # 创建动作服务器(接口类型、动作名、回调函数)
self,
MoveCircle,
'move_circle',
self.execute_callback)
def execute_callback(self, goal_handle): # 执行收到动作目标之后的处理函数
self.get_logger().info('Moving circle...')
feedback_msg = MoveCircle.Feedback() # 创建一个动作反馈信息的消息
for i in range(0, 360, 30): # 从0到360度,执行圆周运动,并周期反馈信息
feedback_msg.state = i # 创建反馈信息,表示当前执行到的角度
self.get_logger().info('Publishing feedback: %d' % feedback_msg.state)
goal_handle.publish_feedback(feedback_msg) # 发布反馈信息
time.sleep(0.5)
goal_handle.succeed() # 动作执行成功
result = MoveCircle.Result() # 创建结果消息
result.finish = True
return result # 反馈最终动作执行的结果
def main(args=None): # ROS2节点主入口main函数
rclpy.init(args=args) # ROS2 Python接口初始化
node = MoveCircleActionServer("action_move_server") # 创建ROS2节点对象并进行初始化
rclpy.spin(node) # 循环等待ROS2退出
node.destroy_node() # 销毁节点对象
rclpy.shutdown() # 关闭ROS2 Python接口
代码中核心部分为 execute_callback 和 ActionServer 定义:

3.4 Client 端实现
该节点在 learning_action 功能包中,具体位置为 src/ros2_21_tutorials/learning_action/learning_action/action_move_client.py:
action_move_client.py
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import rclpy # ROS2 Python接口库
from rclpy.node import Node # ROS2 节点类
from rclpy.action import ActionClient # ROS2 动作客户端类
from learning_interface.action import MoveCircle # 自定义的圆周运动接口
class MoveCircleActionClient(Node):
def __init__(self, name):
super().__init__(name) # ROS2节点父类初始化
self._action_client = ActionClient( # 创建动作客户端(接口类型、动作名)
self, MoveCircle, 'move_circle')
def send_goal(self, enable): # 创建一个发送动作目标的函数
goal_msg = MoveCircle.Goal() # 创建一个动作目标的消息
goal_msg.enable = enable # 设置动作目标为使能,希望机器人开始运动
self._action_client.wait_for_server() # 等待动作的服务器端启动
self._send_goal_future = self._action_client.send_goal_async( # 异步方式发送动作的目标
goal_msg, # 动作目标
feedback_callback=self.feedback_callback) # 处理周期反馈消息的回调函数
self._send_goal_future.add_done_callback(self.goal_response_callback) # 设置一个服务器收到目标之后反馈时的回调函数
def goal_response_callback(self, future): # 创建一个服务器收到目标之后反馈时的回调函数
goal_handle = future.result() # 接收动作的结果
if not goal_handle.accepted: # 如果动作被拒绝执行
self.get_logger().info('Goal rejected :(')
return
self.get_logger().info('Goal accepted :)') # 动作被顺利执行
self._get_result_future = goal_handle.get_result_async() # 异步获取动作最终执行的结果反馈
self._get_result_future.add_done_callback(self.get_result_callback) # 设置一个收到最终结果的回调函数
def get_result_callback(self, future): # 创建一个收到最终结果的回调函数
result = future.result().result # 读取动作执行的结果
self.get_logger().info('Result: {%d}' % result.finish) # 日志输出执行结果
def feedback_callback(self, feedback_msg): # 创建处理周期反馈消息的回调函数
feedback = feedback_msg.feedback # 读取反馈的数据
self.get_logger().info('Received feedback: {%d}' % feedback.state)
def main(args=None): # ROS2节点主入口main函数
rclpy.init(args=args) # ROS2 Python接口初始化
node = MoveCircleActionClient("action_move_client") # 创建ROS2节点对象并进行初始化
node.send_goal(True) # 发送动作目标
rclpy.spin(node) # 循环等待ROS2退出
node.destroy_node() # 销毁节点对象
rclpy.shutdown() # 关闭ROS2 Python接口
在 Client 中 feedback_callback、get_result_callback 都是异步执行的,因为不知道 Server 端什么时候会完成任务,因此在等待 feedback 的时候 Client 可以同步执行其他动作:
