欢迎光临
我们一直在努力

ROS2 C++ 小车控制完整实战(四):自定义 Action 通信保姆级教程

目录

摘要

功能包完整目录:

一、为什么还需要 Action 通信?

1.1 前面三种通信方式分别解决了什么?

(1)Topic 通信

(2)自定义 msg 通信

(3)自定义 srv 通信

1.2 Topic、Service、Action 到底有什么区别?

(1)Topic:一直持续,但是没有反馈

(2)Service:一次请求,一次处理,一次返回结果

(3)Service 是否一定立马返回?

(4)Action:发送一个目标,过程中持续反馈,最后返回结果

(5)更严谨地理解三种通信方式

1.3 Service 与 Action

(1)Service 的局限性

(2)Action 适合什么场景?

二、自定义 Action 接口文件

2.1 Action 文件放在哪里?

2.2 LimoAction.action 文件内容

(1)Goal:目标部分

(2)Result:结果部分

(3)Feedback:反馈部分

三、修改 limo_msgs 接口包配置

3.1 修改 CMakeLists.txt

3.2 修改 package.xml

3.3 编译接口包

四、Action 服务端代码

4.1 Action 服务端的作用

4.2 新建服务端文件

五、Action 客户端代码

5.1 Action 客户端的作用

5.2 新建客户端文件

六、修改 limo_learning 功能包

6.1 正常功能包目录:

6.2 修改 limo_learning 的 CMakeLists.txt

(1)添加 action 依赖

(2)添加可执行文件

(3)添加安装配置

6.3 修改 package.xml

七、编译与运行

7.1 编译功能包

7.2 先启动 Action 服务端

7.3 再启动 Action 客户端

7.4 查看 Action 列表

7.5 查看 Action 信息

7.6 使用命令行发送 Action 目标

八、Topic、Service、Action 对比总结

8.1 Topic 通信

8.2 Service 通信

8.3 Action 通信

8.4 三者对比表

九、本篇总结


摘要

前面几篇文章已经分别详细讲解了 ROS2 C++ 小车控制中的几种常见通信方式:

第一篇主要讲解:Topic + 官方标准 msg:

ROS2 C++ 小车控制完整实战(一):一文搞懂 Topic 发布订阅、Twist 消息与 /cmd_vel 速度控制-CSDN博客https://blog.csdn.net/m0_58954356/article/details/161829804?spm=1001.2014.3001.5502也就是使用 ROS2 官方已经定义好的消息类型,例如:

geometry_msgs/msg/Twist

通过 /cmd_vel 话题发布速度指令,实现对小车前进、后退、转向等基础运动控制。

第二篇主要讲解:Topic + 自定义 msg:

ROS2 C++ 小车控制完整实战(二):自定义 msg 消息发布与订阅保姆级教程-CSDN博客https://blog.csdn.net/m0_58954356/article/details/161834874?spm=1001.2014.3001.5502我们自己定义了 LimoStatus.msg,用来发布小车状态信息,例如:

电池电压、车辆状态、控制模式、运动模式、错误码等。

第三篇主要讲解:Service + 自定义 srv:

ROS2 C++ 小车控制完整实战(三):自定义 srv 服务通信保姆级教程-CSDN博客https://blog.csdn.net/m0_58954356/article/details/161834909?spm=1001.2014.3001.5502也就是客户端发送一次请求,服务端处理后返回一次响应。例如客户端发送 x、y、z 速度参数,服务端收到后控制小车运动,并返回 success 表示是否执行成功。

但是 Service 更适合“短时间、一次性、有明确返回结果”的任务。

如果任务执行时间比较长,比如:

  • 让小车持续运动一段时间
  • 让小车执行导航任务
  • 让小车执行巡检任务
  • 让小车执行一个可以反馈进度的控制任务
  • 这时候就更适合使用 Action 通信。

    本篇文章就继续在前面 Topic、msg、srv 的基础上,讲解 ROS2 中更适合长时间任务的通信方式:

    自定义 Action 通信。


    需要说明的是:

    本篇重点先放在 Action 通信的整体流程和工程配置上,让大家先知道 Action 是什么、Action 文件怎么写、功能包怎么配置、代码怎么编译运行、终端命令怎么测试。

    至于 Action 服务端和客户端代码中每一行到底是什么意思,例如 create_server、create_client、GoalHandle、handle_goal、handle_cancel、handle_accepted、execute、feedback_callback、result_callback 等核心内容,会放到下一篇文章中单独进行详细拆解。


    功能包完整目录:

    ros2_ws/
    └── src
    ├── limo_msgs
    │ ├── msg
    │ │ └── LimoStatus.msg
    │ ├── srv
    │ │ └── LimoSrv.srv
    │ ├── action
    │ │ └── LimoAction.action
    │ ├── CMakeLists.txt
    │ └── package.xml

    └── limo_learning
    ├── src
    │ ├── limo_topic_cmd.cpp
    │ ├── limo_topic_sub.cpp
    │ ├── limo_srv_server.cpp
    │ ├── limo_srv_client.cpp
    │ ├── limo_action_server.cpp
    │ └── limo_action_client.cpp
    ├── CMakeLists.txt
    └── package.xml

    这里有两个包:

    功能包作用
    limo_msgs 定义自定义接口:msg、srv、action
    limo_learning 编写 C++ 节点代码:Topic、Service、Action 服务端和客户端

    一、为什么还需要 Action 通信?

    1.1 前面三种通信方式分别解决了什么?

    在正式写 Action 代码之前,我们先回顾一下前面几篇文章中已经使用过的 ROS2 通信方式。

    前面主要讲了三部分内容:

  • Topic 通信
  • 自定义 msg 通信
  • 自定义 srv 通信
  • 这三种通信方式各自解决的问题并不一样。

    (1)Topic 通信

    Topic 通信适合持续不断地发布数据。

    例如:

    小车速度控制:

    /cmd_vel

    小车状态发布:

    /limo_status

    雷达数据发布:

    /scan

    Topic 的特点是:

  • 发布者只管发布,订阅者只管接收。
  • 发布者不关心订阅者有没有收到,也不等待返回结果。
  • 所以 Topic 适合:

    • 持续发布速度
    • 持续发布状态
    • 持续发布传感器数据

    Topic 就像广播。


    (2)自定义 msg 通信

    自定义 msg 的作用是:

    ROS2 官方提供的消息类型不够用时,我们可以自己定义消息结构。

    例如前面我们定义过:

    LimoStatus.msg

    里面包含:

    std_msgs/Header header
    uint8 vehicle_state
    uint8 control_mode
    uint8 motion_mode
    float64 battery_voltage
    uint16 error_code

    这样就可以把小车状态封装成一个完整的自定义消息,然后通过 Topic 发布出去。

    所以:

    msg 解决的是“传什么数据”的问题。


    (3)自定义 srv 通信

    Service 通信适合一次请求、一次响应。

    例如前面我们定义过:

    LimoSrv.srv

    内容类似:

    float32 x
    float32 y
    float32 z

    bool success

    客户端发送请求:

    x = 0.2
    y = 0.0
    z = 0.0

    服务端收到请求后,转换成 Twist 速度指令发布到 /cmd_vel,然后返回:

    success = true

    所以 Service 适合:

  • 客户端请求一次
  • 服务端处理一次
  • 服务端返回一次结果
  • Service 就像“问一句,答一句”。


    1.2 Topic、Service、Action 到底有什么区别?

    在正式讲 Action 之前,一定要先把 ROS2 中最常见的三种通信方式区分清楚:

  • Topic
  • Service
  • Action
  • 三种通信方式都可以让 ROS2 节点之间进行数据交互,但是它们适合的场景完全不一样。

    可以先用一张表简单对比:

    通信方式是否持续通信是否有反馈 / 结果适合场景
    Topic 持续发布 / 持续接收 没有返回结果 速度、雷达、状态、里程计
    Service 一次请求 / 一次响应 有最终响应 查询、开关、短时间任务
    Action 一个目标 / 持续反馈 / 最终结果 有过程反馈,也有最终结果 导航、巡检、长时间任务

    简单来说:

  • Topic 适合持续数据流。
  • Service 适合一次性请求。
  • Action 适合长时间任务。

  • (1)Topic:一直持续,但是没有反馈

  • Topic 通信就像广播。
  • 发布者 一直向某个话题发布数据,订阅者只要订阅了这个话题,就可以持续接收数据。
  • 例如:

    发布者 → /cmd_vel → 订阅者

    发布者只管发布,订阅者只管接收。

    Topic 的特点是:

  • 持续通信
  • 异步通信
  • 没有返回结果
  • 比如在小车控制中:

    /cmd_vel 持续发送速度指令
    /scan 持续发送雷达数据
    /odom 持续发送里程计数据
    /limo_status 持续发送小车状态

    这些数据都适合用 Topic。

    因为它们不是问答关系,而是持续不断地发布和接收。

    一句话理解:

    Topic 就是一直发、一直收,但是不返回结果。


    (2)Service:一次请求,一次处理,一次返回结果

  • Service 通信更像去窗口办业务。
  • 客户端先提出一个请求,服务端收到请求后进行处理,处理完成后再返回响应结果。
  • 整体流程是:

    客户端发送请求

    服务端收到请求

    服务端处理请求

    服务端返回响应结果

    客户端拿到结果

    所以 Service 不是一直通信,而是:

    请求一次,处理一次,返回一次。

    例如前面自定义 srv 通信中,客户端发送:

    x = 0.2
    y = 0.0
    z = 0.0

    服务端收到请求后,把它转换成 Twist 速度指令,然后发布到:

    /cmd_vel

    最后返回:

    success = true

    这就是典型的 Service 通信。

    一句话理解:

    Service 就是客户端问一次,服务端处理一次,然后返回一次结果。


    (3)Service 是否一定立马返回?

    这里是很多初学者最容易混淆的地方。

    Service 不是一定“立马返回”,而是:

    服务端处理完这一次请求后,才会返回结果。

    也就是说,Service 返回快还是慢,取决于服务端回调函数里面到底做了多少事情。

    比如客户端请求:

    让小车前进 1 秒。

    如果服务端代码里面这样写:

    收到请求
    发布速度
    等待 1 秒
    停止小车
    返回 success = true

    那么客户端就需要等服务端这一整套流程执行完,才能拿到返回结果。

    但是如果服务端代码这样写:

    收到请求
    发布一次速度
    立刻返回 success = true

    那么客户端就会很快收到响应结果。

    所以关键不在于 Service 本身,而在于:

    服务端回调函数里面到底执行了多长时间。

    可以这样理解:

    Service 返回的时机 = 服务端处理完这一次请求并设置 response 的时机

  • 因此,Service 更适合短时间任务。
  • 如果任务执行时间很长,客户端一直等待响应就不太合适了。

  • (4)Action:发送一个目标,过程中持续反馈,最后返回结果

    Action 通信适合长时间任务。

    它不是简单的“一问一答”,而是:

    客户端发送目标 Goal

    服务端开始执行任务

    执行过程中不断发送 Feedback

    任务完成后返回 Result

    比如导航任务:

    目标:去 A 点
    反馈:当前完成 20%
    反馈:当前完成 50%
    反馈:当前完成 80%
    结果:到达成功

    Action 和 Service 最大的区别。

  • Service 只有最终响应。
  • Action 不仅有最终结果,还可以在执行过程中持续反馈状态。
  • Action 的特点是:

  • 可以执行长时间任务
  • 中途可以持续反馈
  • 最后有最终结果
  • 任务中途还可以取消
  • Action 就是发送一个目标,服务端慢慢执行,执行过程中不断反馈,最后返回最终结果。


    (5)更严谨地理解三种通信方式

  • Topic 是异步持续通信,发布者和订阅者互不等待,也没有返回结果。
  • Service 通常用于一次性请求-响应通信,客户端发送请求后,等待服务端处理完成并返回响应结果,适合短时间任务。
  • Action 是异步的目标任务通信,客户端发送目标后,服务端可以在执行过程中持续返回反馈,任务完成后再返回最终结果,适合长时间任务。
  • 结合小车控制来理解:

    Topic:持续发送 /cmd_vel 速度控制小车
    Service:请求小车执行一次简单动作,然后返回是否成功
    Action:请求小车执行一个长时间任务,过程中反馈状态,完成后返回最终结果

    到这里就可以自然引出本篇文章的重点:

    当我们希望小车执行一个持续一段时间的任务,并且希望在执行过程中不断收到状态反馈时,就应该使用 Action 通信。


    1.3 Service 与 Action

    (1)Service 的局限性

    Service 虽然很适合一次性请求,但是它不太适合长时间任务。

    比如现在有一个需求:

    让小车向前运动 10 秒,并且执行过程中不断反馈当前执行状态。

    如果使用 Service,就会出现一个问题:

    客户端发送请求后,只能等待服务端最终返回结果。

    在任务执行过程中,客户端很难持续知道:

  • 当前执行到哪一步了
  • 小车是否还在运动
  • 任务有没有中途失败
  • 任务进度是多少
  • 也就是说,Service 只适合短任务,不适合长任务。


    (2)Action 适合什么场景?

    Action 通信就是为长时间任务设计的。

    Action 比 Service 多了一个非常重要的能力:

    可以在任务执行过程中持续反馈状态。

    Action 通信一般包含三部分:

  • 目标 Goal
  • 反馈 Feedback
  • 结果 Result
  • 可以理解为:

  • 客户端先发送一个任务目标
  • 服务端开始执行任务
  • 执行过程中不断返回反馈信息
  • 任务结束后返回最终结果
  • 例如:

    客户端发送目标:

    让小车以 x=0.2 的速度前进

    服务端执行过程中持续反馈:

  • 当前状态 status = 1
  • 当前状态 status = 2
  • 当前状态 status = 3
  • 任务执行完成后返回结果:

    success = true

    所以 Action 更适合:

  • 导航任务
  • 巡检任务
  • 机械臂执行任务
  • 小车长时间运动控制
  • 需要中途反馈进度的任务
  • 一句话总结:

    Action 适合“执行时间较长,并且需要反馈过程状态”的任务。


    二、自定义 Action 接口文件

    2.1 Action 文件放在哪里?

    前面我们已经创建过接口功能包:

    limo_msgs

    这个功能包中可以放:

    msg
    srv
    action

    对应三种自定义接口:

    limo_msgs/
    ├── msg
    │ └── LimoStatus.msg
    ├── srv
    │ └── LimoSrv.srv
    ├── action
    │ └── LimoAction.action
    ├── CMakeLists.txt
    └── package.xml

    本篇要新增的就是:

    action/LimoAction.action


    2.2 LimoAction.action 文件内容

    创建文件:

    limo_msgs/action/LimoAction.action

    写入内容:

    float32 x
    float32 y
    float32 z

    bool success

    uint32 status

    这个 action 文件中有三个部分,中间用两个 — 分隔。


    (1)Goal:目标部分

    第一部分是 Goal,也就是客户端发送给服务端的目标。

    float32 x
    float32 y
    float32 z

    这里的含义是:

    x:小车线速度 x 方向 y:小车线速度 y 方向 z:小车角速度 z 方向

    后面服务端会把它们转换成:

    geometry_msgs::msg::Twist

    对应关系是:

    twist.linear.x = goal->x;
    twist.linear.y = goal->y;
    twist.angular.z = goal->z;

    也就是说:

    客户端通过 Action 发送目标速度,服务端根据目标速度控制小车运动。


    (2)Result:结果部分

    第二部分是 Result,也就是任务执行完成后,服务端返回给客户端的最终结果。

    bool success

    这里表示任务是否执行成功。

    如果任务正常完成:

    success = true

    如果任务执行失败:

    success = false


    (3)Feedback:反馈部分

    第三部分是 Feedback,也就是任务执行过程中,服务端持续反馈给客户端的状态。

    uint32 status

    这里可以表示当前执行状态。

    例如:

    status = 1
    status = 2
    status = 3
    status = 4
    status = 5

    表示任务正在一步步执行。

    当然,在真实项目中,status 也可以设计成更具体的含义,例如:

    • 0:空闲
    • 1:正在执行
    • 2:任务暂停
    • 3:任务完成
    • 4:任务失败

    本篇为了方便理解,先用 status 表示执行进度。


    三、修改 limo_msgs 接口包配置

    3.1 修改 CMakeLists.txt

    前面自定义 msg 和 srv 时,已经配置过 rosidl_generate_interfaces。

    现在需要把 action 文件也加入进去。

    打开:

    limo_msgs/CMakeLists.txt

    找到:

    rosidl_generate_interfaces(${PROJECT_NAME}
    "msg/LimoStatus.msg"
    "srv/LimoSrv.srv"
    DEPENDENCIES std_msgs
    )

    修改为:

    rosidl_generate_interfaces(${PROJECT_NAME}
    "msg/LimoStatus.msg"
    "srv/LimoSrv.srv"
    "action/LimoAction.action"
    DEPENDENCIES std_msgs
    )

    这样编译时,ROS2 才会根据 LimoAction.action 自动生成 C++ 代码。


    3.2 修改 package.xml

    打开:

    limo_msgs/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>

    如果前面自定义 msg 和 srv 已经配置过,那么这里一般不用重复修改。


    3.3 编译接口包

    回到工作空间根目录:

    cd ~/agilex_open_class_ws

    编译:

    colcon build –packages-select limo_msgs

    编译完成后刷新环境变量:

    source install/setup.bash

    查看 action 接口是否生成成功:

    ros2 interface show limo_msgs/action/LimoAction

    正常情况下会看到:

    float32 x
    float32 y
    float32 z

    bool success

    uint32 status

    说明自定义 Action 接口已经生成成功。


    四、Action 服务端代码

    4.1 Action 服务端的作用

    Action 服务端负责:

  • 接收客户端发送的目标
  • 根据目标控制小车运动
  • 执行过程中持续发布反馈
  • 任务结束后返回最终结果
  • 在本篇案例中,Action 服务端的核心功能是:

  • 客户端发送 x、y、z 速度目标,服务端把目标转换成 Twist
  • 服务端向 /cmd_vel 发布速度
  • 服务端循环反馈 status
  • 最后返回 success
  • 也就是说:

    Action 服务端是真正执行任务的一方。


    4.2 新建服务端文件

    在 limo_learning 功能包下创建:

    limo_learning/src/limo_action_server.cpp

    写入代码:

    // 引入智能指针相关头文件
    // std::shared_ptr、std::make_shared 会用到
    #include <memory>

    // 引入线程相关头文件
    // std::thread 会用到
    #include <thread>

    // 引入 ROS2 C++ 客户端库
    #include "rclcpp/rclcpp.hpp"

    // 引入 ROS2 Action 通信相关头文件
    #include "rclcpp_action/rclcpp_action.hpp"

    // 引入 Twist 速度消息类型
    // 用于向 /cmd_vel 发布速度指令
    #include "geometry_msgs/msg/twist.hpp"

    // 引入自定义 Action 接口
    // 对应 limo_msgs/action/LimoAction.action
    #include "limo_msgs/action/limo_action.hpp"

    // 自定义 Action 服务端节点类
    // 继承 rclcpp::Node,说明它是一个 ROS2 节点
    class LimoActionServer : public rclcpp::Node
    {
    public:
    // 给自定义 Action 类型起一个别名,方便后面书写
    // limo_msgs::action::LimoAction 对应 LimoAction.action 生成的 C++ 类型
    using LimoAction = limo_msgs::action::LimoAction;

    // 给 Action 目标句柄类型起一个别名
    // GoalHandle 用来表示客户端发送过来的一个目标任务
    using GoalHandleLimoAction = rclcpp_action::ServerGoalHandle<LimoAction>;

    // 构造函数
    // Node("limo_action_server") 表示当前节点名称为 limo_action_server
    LimoActionServer() : Node("limo_action_server")
    {
    // 创建 /cmd_vel 速度发布者
    // 发布 geometry_msgs::msg::Twist 类型消息
    // 队列长度为 10
    cmd_pub_ = this->create_publisher<geometry_msgs::msg::Twist>(
    "/cmd_vel",
    10
    );

    // 创建 Action 服务端
    // Action 类型:LimoAction
    // Action 名称:/limo_action
    // handle_goal:收到目标请求时调用
    // handle_cancel:收到取消请求时调用
    // handle_accepted:目标被接受后调用
    action_server_ = rclcpp_action::create_server<LimoAction>(
    this,
    "/limo_action",
    std::bind(
    &LimoActionServer::handle_goal,
    this,
    std::placeholders::_1,
    std::placeholders::_2
    ),
    std::bind(
    &LimoActionServer::handle_cancel,
    this,
    std::placeholders::_1
    ),
    std::bind(
    &LimoActionServer::handle_accepted,
    this,
    std::placeholders::_1
    )
    );

    // 打印节点启动日志
    RCLCPP_INFO(this->get_logger(), "limo_action_server node has started.");
    }

    private:
    // /cmd_vel 速度发布者对象
    // 用于控制小车运动
    rclcpp::Publisher<geometry_msgs::msg::Twist>::SharedPtr cmd_pub_;

    // Action 服务端对象
    // 用于提供 /limo_action 动作服务
    rclcpp_action::Server<LimoAction>::SharedPtr action_server_;

    // 处理客户端发送过来的 Goal 目标请求
    // 当客户端发送目标时,会先进入这个函数
    rclcpp_action::GoalResponse handle_goal(
    const rclcpp_action::GoalUUID & uuid,
    std::shared_ptr<const LimoAction::Goal> goal)
    {
    // 当前代码中没有使用 uuid
    // 写 (void)uuid 是为了避免编译器提示“变量未使用”
    (void)uuid;

    // 打印客户端发送过来的目标参数
    // goal->x 表示目标中的 x 方向速度
    // goal->y 表示目标中的 y 方向速度
    // goal->z 表示目标中的 z 方向角速度
    RCLCPP_INFO(
    this->get_logger(),
    "Received goal: x=%.2f, y=%.2f, z=%.2f",
    goal->x,
    goal->y,
    goal->z
    );

    // 接受并立即执行该目标
    // ACCEPT_AND_EXECUTE 表示服务端同意执行客户端发来的任务
    return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE;
    }

    // 处理客户端发送的取消请求
    // 当客户端想取消正在执行的 Action 任务时,会进入这个函数
    rclcpp_action::CancelResponse handle_cancel(
    const std::shared_ptr<GoalHandleLimoAction> goal_handle)
    {
    // 当前代码中没有直接使用 goal_handle
    // 写 (void)goal_handle 是为了避免编译器提示“变量未使用”
    (void)goal_handle;

    // 打印收到取消请求的日志
    RCLCPP_INFO(this->get_logger(), "Received request to cancel goal.");

    // 接受客户端的取消请求
    return rclcpp_action::CancelResponse::ACCEPT;
    }

    // 当目标被接受后,会进入这个函数
    // 这里不直接执行任务,而是开启一个新线程执行 execute()
    void handle_accepted(
    const std::shared_ptr<GoalHandleLimoAction> goal_handle)
    {
    // 创建一个新线程执行 execute 函数
    // 这样做的目的:
    // 避免长时间任务阻塞 ROS2 主线程
    //
    // detach() 表示线程独立运行
    // 主线程不会等待这个线程结束
    std::thread{
    std::bind(
    &LimoActionServer::execute,
    this,
    std::placeholders::_1
    ),
    goal_handle
    }.detach();
    }

    // 真正执行 Action 任务的函数
    // 目标被接受后,会在新线程中执行这个函数
    void execute(
    const std::shared_ptr<GoalHandleLimoAction> goal_handle)
    {
    // 打印开始执行任务的日志
    RCLCPP_INFO(this->get_logger(), "Executing goal…");

    // 获取客户端发送过来的目标数据
    const auto goal = goal_handle->get_goal();

    // 创建反馈对象
    // Feedback 对应 LimoAction.action 中第二个 — 后面的反馈部分
    auto feedback = std::make_shared<LimoAction::Feedback>();

    // 创建结果对象
    // Result 对应 LimoAction.action 中第一个 — 后面的结果部分
    auto result = std::make_shared<LimoAction::Result>();

    // 设置循环频率为 1Hz
    // 也就是每秒执行一次循环
    rclcpp::Rate loop_rate(1);

    // 创建 Twist 速度消息对象
    geometry_msgs::msg::Twist cmd_vel;

    // 将 Action 目标中的 x、y、z 转换成 Twist 速度指令
    // goal->x 对应 linear.x,表示前后方向速度
    // goal->y 对应 linear.y,表示左右方向速度
    // goal->z 对应 angular.z,表示绕 z 轴旋转角速度
    cmd_vel.linear.x = goal->x;
    cmd_vel.linear.y = goal->y;
    cmd_vel.angular.z = goal->z;

    // 循环执行 5 次
    // 因为 loop_rate 是 1Hz,所以这里大约执行 5 秒
    for (uint32_t i = 1; i <= 5; i++)
    {
    // 判断当前目标是否正在被取消
    // 如果客户端发送了取消请求,并且服务端接受取消,就会进入这里
    if (goal_handle->is_canceling())
    {
    // 创建停止速度指令
    // Twist 默认值都是 0,所以表示停止小车
    geometry_msgs::msg::Twist stop_cmd;

    // 发布停止指令到 /cmd_vel
    cmd_pub_->publish(stop_cmd);

    // 设置最终结果为失败
    result->success = false;

    // 通知客户端:目标已经取消
    goal_handle->canceled(result);

    // 打印取消日志
    RCLCPP_INFO(this->get_logger(), "Goal canceled.");

    // 结束 execute 函数
    return;
    }

    // 发布速度指令,让小车按照目标速度运动
    cmd_pub_->publish(cmd_vel);

    // 设置反馈状态
    // 这里用 i 表示当前执行进度
    // 第 1 秒 status=1,第 2 秒 status=2,以此类推
    feedback->status = i;

    // 向客户端发布反馈信息
    // 客户端可以在任务执行过程中不断收到 feedback
    goal_handle->publish_feedback(feedback);

    // 打印当前反馈状态
    RCLCPP_INFO(
    this->get_logger(),
    "Publishing feedback: status=%d",
    feedback->status
    );

    // 按照 1Hz 频率休眠
    // 也就是每次循环间隔约 1 秒
    loop_rate.sleep();
    }

    // 任务执行完成后,发布停止指令
    // 防止小车继续运动
    geometry_msgs::msg::Twist stop_cmd;
    cmd_pub_->publish(stop_cmd);

    // 设置最终结果为成功
    result->success = true;

    // 通知客户端:目标执行成功
    goal_handle->succeed(result);

    // 打印任务成功日志
    RCLCPP_INFO(this->get_logger(), "Goal succeeded.");
    }
    };

    // main 函数,程序入口
    int main(int argc, char ** argv)
    {
    // 初始化 ROS2
    rclcpp::init(argc, argv);

    // 创建 LimoActionServer 节点对象
    auto node = std::make_shared<LimoActionServer>();

    // 运行节点
    // spin 会让节点持续运行,等待 Action 请求、取消请求等回调触发
    rclcpp::spin(node);

    // 关闭 ROS2
    rclcpp::shutdown();

    return 0;
    }
    ```


    五、Action 客户端代码

    5.1 Action 客户端的作用

    Action 客户端负责:

  • 等待 Action 服务端启动
  • 发送目标给服务端
  • 接收服务端反馈
  • 接收最终执行结果
  • 在本篇案例中,客户端发送:

    x = 0.2
    y = 0.0
    z = 0.0

    表示让小车向前运动。

    服务端执行过程中,客户端会不断收到 feedback。

    最后收到 result,判断任务是否执行成功。


    5.2 新建客户端文件

    在 limo_learning 功能包下创建:

    limo_learning/src/limo_action_client.cpp

    写入代码:

    // 引入智能指针相关头文件
    // std::shared_ptr、std::make_shared 会用到
    #include <memory>

    // ROS2 C++ 客户端库头文件
    #include "rclcpp/rclcpp.hpp"

    // ROS2 Action 通信相关头文件
    #include "rclcpp_action/rclcpp_action.hpp"

    // 引入自定义 Action 接口头文件
    // 对应 limo_msgs/action/LimoAction.action
    #include "limo_msgs/action/limo_action.hpp"

    // 自定义 Action 客户端节点类
    class LimoActionClient : public rclcpp::Node
    {
    public:
    // 给自定义 Action 类型起一个别名,方便后面使用
    using LimoAction = limo_msgs::action::LimoAction;

    // 给 Action 客户端目标句柄类型起一个别名
    // ClientGoalHandle<LimoAction> 表示客户端这边用于管理目标任务的句柄
    using GoalHandleLimoAction = rclcpp_action::ClientGoalHandle<LimoAction>;

    // 构造函数
    // 创建节点时,节点名称为 limo_action_client
    LimoActionClient() : Node("limo_action_client")
    {
    // 创建 Action 客户端对象
    // LimoAction 表示使用的 Action 接口类型
    // "/limo_action" 表示要连接的 Action 服务名称
    action_client_ = rclcpp_action::create_client<LimoAction>(
    this,
    "/limo_action"
    );

    // 打印日志,提示 Action 客户端节点已经启动
    RCLCPP_INFO(this->get_logger(), "limo_action_client node has started.");
    }

    // 发送 Action 目标任务的函数
    void send_goal()
    {
    // 等待 Action 服务端启动
    // 最多等待 5 秒
    // 如果 5 秒内没有发现 Action 服务端,则直接返回
    if (!action_client_->wait_for_action_server(std::chrono::seconds(5)))
    {
    RCLCPP_ERROR(this->get_logger(), "Action server not available.");
    return;
    }

    // 创建一个 Action 目标消息
    // LimoAction::Goal 对应 .action 文件中第一段 Goal 部分
    auto goal_msg = LimoAction::Goal();

    // 设置目标参数
    // 这里表示希望小车按照 x、y、z 指定的速度执行运动
    goal_msg.x = 0.2;
    goal_msg.y = 0.0;
    goal_msg.z = 0.0;

    // 打印日志,提示正在发送目标
    RCLCPP_INFO(this->get_logger(), "Sending goal…");

    // 创建发送目标时的选项对象
    // 里面可以绑定三个回调函数:
    // 1. goal_response_callback:目标是否被服务端接受
    // 2. feedback_callback:执行过程中接收反馈
    // 3. result_callback:执行结束后接收最终结果
    auto send_goal_options = rclcpp_action::Client<LimoAction>::SendGoalOptions();

    // 绑定目标响应回调函数
    // 当服务端接受或拒绝目标时,会自动调用 goal_response_callback
    send_goal_options.goal_response_callback =
    std::bind(
    &LimoActionClient::goal_response_callback,
    this,
    std::placeholders::_1
    );

    // 绑定反馈回调函数
    // 当服务端执行过程中发布 feedback 时,会自动调用 feedback_callback
    send_goal_options.feedback_callback =
    std::bind(
    &LimoActionClient::feedback_callback,
    this,
    std::placeholders::_1,
    std::placeholders::_2
    );

    // 绑定结果回调函数
    // 当 Action 任务执行完成、取消或失败时,会自动调用 result_callback
    send_goal_options.result_callback =
    std::bind(
    &LimoActionClient::result_callback,
    this,
    std::placeholders::_1
    );

    // 异步发送目标任务
    // goal_msg 是目标内容
    // send_goal_options 中包含三个回调函数
    action_client_->async_send_goal(goal_msg, send_goal_options);
    }

    private:
    // Action 客户端对象
    // 用于连接 /limo_action,并向 Action 服务端发送目标任务
    rclcpp_action::Client<LimoAction>::SharedPtr action_client_;

    // 目标响应回调函数
    // 当客户端发送目标后,服务端会决定是否接受该目标
    void goal_response_callback(
    const GoalHandleLimoAction::SharedPtr & goal_handle)
    {
    // 如果 goal_handle 为空,说明服务端拒绝了目标
    if (!goal_handle)
    {
    RCLCPP_ERROR(this->get_logger(), "Goal was rejected by server.");
    }
    // 如果 goal_handle 不为空,说明服务端接受了目标
    else
    {
    RCLCPP_INFO(this->get_logger(), "Goal accepted by server.");
    }
    }

    // 反馈回调函数
    // 服务端执行任务过程中,可以不断向客户端发送 feedback
    void feedback_callback(
    GoalHandleLimoAction::SharedPtr,
    const std::shared_ptr<const LimoAction::Feedback> feedback)
    {
    // 打印服务端返回的反馈状态
    // feedback->status 对应 .action 文件中 Feedback 部分的 status 字段
    RCLCPP_INFO(
    this->get_logger(),
    "Received feedback: status=%d",
    feedback->status
    );
    }

    // 最终结果回调函数
    // 当 Action 任务结束后,会进入这个函数
    void result_callback(
    const GoalHandleLimoAction::WrappedResult & result)
    {
    // 根据 result.code 判断任务最终状态
    switch (result.code)
    {
    // 任务成功完成
    case rclcpp_action::ResultCode::SUCCEEDED:
    RCLCPP_INFO(
    this->get_logger(),
    "Goal succeeded, success=%d",
    result.result->success
    );
    break;

    // 任务执行失败,被服务端中止
    case rclcpp_action::ResultCode::ABORTED:
    RCLCPP_ERROR(this->get_logger(), "Goal was aborted.");
    break;

    // 任务被取消
    case rclcpp_action::ResultCode::CANCELED:
    RCLCPP_WARN(this->get_logger(), "Goal was canceled.");
    break;

    // 其他未知状态
    default:
    RCLCPP_ERROR(this->get_logger(), "Unknown result code.");
    break;
    }

    // 收到最终结果后关闭 ROS2 节点
    rclcpp::shutdown();
    }
    };

    int main(int argc, char ** argv)
    {
    // 初始化 ROS2
    rclcpp::init(argc, argv);

    // 创建 Action 客户端节点对象
    auto node = std::make_shared<LimoActionClient>();

    // 调用 send_goal(),向 Action 服务端发送目标任务
    node->send_goal();

    // 进入 ROS2 事件循环
    // 用于等待并处理 Action 回调函数
    // 包括目标响应、过程反馈、最终结果
    rclcpp::spin(node);

    // 关闭 ROS2
    rclcpp::shutdown();

    return 0;
    }


    六、修改 limo_learning 功能包

    6.1 正常功能包目录:

    ros2_ws/
    └── src
        ├── limo_msgs
        │   ├── msg
        │   │   └── LimoStatus.msg
        │   ├── srv
        │   │   └── LimoSrv.srv
        │   ├── action
        │   │   └── LimoAction.action
        │   ├── CMakeLists.txt
        │   └── package.xml
        │
        └── limo_learning
            ├── src
            │   ├── limo_topic_cmd.cpp
            │   ├── limo_topic_sub.cpp
            │   ├── limo_srv_server.cpp
            │   ├── limo_srv_client.cpp
            │   ├── limo_action_server.cpp
            │   └── limo_action_client.cpp
            ├── CMakeLists.txt
            └── package.xml


    6.2 修改 limo_learning 的 CMakeLists.txt

    (1)添加 action 依赖

    因为 Action 代码中用到了:

    rclcpp_action

    所以需要在 CMakeLists.txt 中增加依赖。

    打开:

    limo_learning/CMakeLists.txt

    添加:

    find_package(rclcpp REQUIRED)
    find_package(rclcpp_action REQUIRED)
    find_package(geometry_msgs REQUIRED)
    find_package(limo_msgs REQUIRED)

    如果前面已经有 rclcpp、geometry_msgs、limo_msgs,就只需要补充:

    find_package(rclcpp_action REQUIRED)


    (2)添加可执行文件

    继续添加:

    add_executable(limo_action_server src/limo_action_server.cpp)
    ament_target_dependencies(
    limo_action_server
    rclcpp
    rclcpp_action
    geometry_msgs
    limo_msgs
    )

    add_executable(limo_action_client src/limo_action_client.cpp)
    ament_target_dependencies(
    limo_action_client
    rclcpp
    rclcpp_action
    limo_msgs
    )


    (3)添加安装配置

    在 install 中加入:

    install(TARGETS
    limo_action_server
    limo_action_client
    DESTINATION lib/${PROJECT_NAME}
    )

    如果前面已经有其他可执行文件,可以统一写成:

    install(TARGETS
    limo_topic_cmd
    limo_topic_sub
    limo_status_pub
    limo_status_sub
    limo_srv_server
    limo_srv_client
    limo_action_server
    limo_action_client
    DESTINATION lib/${PROJECT_NAME}
    )


    6.3 修改 package.xml

    打开:

    limo_learning/package.xml

    添加依赖:

    <depend>rclcpp</depend>
    <depend>rclcpp_action</depend>
    <depend>geometry_msgs</depend>
    <depend>limo_msgs</depend>

    其中:

    <depend>rclcpp_action</depend>

    是本篇 Action 通信需要新增的重点依赖。


    七、编译与运行

    7.1 编译功能包

    回到工作空间根目录:

    cd ~/agilex_open_class_ws

    编译:

    colcon build

    如果只想编译相关功能包,也可以:

    colcon build –packages-select limo_msgs limo_learning

    编译完成后刷新环境变量:

    source install/setup.bash


    7.2 先启动 Action 服务端

    打开第一个终端:

    cd ~/agilex_open_class_ws
    source install/setup.bash
    ros2 run limo_learning limo_action_server

    正常输出类似:

    [INFO] [limo_action_server]: limo_action_server node has started.

    说明 Action 服务端已经启动。


    7.3 再启动 Action 客户端

    打开第二个终端:

    cd ~/agilex_open_class_ws
    source install/setup.bash
    ros2 run limo_learning limo_action_client

    客户端输出类似:

    [INFO] [limo_action_client]: limo_action_client node has started.
    [INFO] [limo_action_client]: Sending goal…
    [INFO] [limo_action_client]: Goal accepted by server.
    [INFO] [limo_action_client]: Received feedback: status=1
    [INFO] [limo_action_client]: Received feedback: status=2
    [INFO] [limo_action_client]: Received feedback: status=3
    [INFO] [limo_action_client]: Received feedback: status=4
    [INFO] [limo_action_client]: Received feedback: status=5
    [INFO] [limo_action_client]: Goal succeeded, success=1

    服务端输出类似:

    [INFO] [limo_action_server]: Received goal: x=0.20, y=0.00, z=0.00
    [INFO] [limo_action_server]: Executing goal…
    [INFO] [limo_action_server]: Publishing feedback: status=1
    [INFO] [limo_action_server]: Publishing feedback: status=2
    [INFO] [limo_action_server]: Publishing feedback: status=3
    [INFO] [limo_action_server]: Publishing feedback: status=4
    [INFO] [limo_action_server]: Publishing feedback: status=5
    [INFO] [limo_action_server]: Goal succeeded.


    7.4 查看 Action 列表

    可以使用命令查看当前系统中的 Action:

    ros2 action list

    正常情况下可以看到:

    /limo_action


    7.5 查看 Action 信息

    ros2 action info /limo_action

    可以看到 Action 的服务端和客户端数量。


    7.6 使用命令行发送 Action 目标

    除了写客户端代码,也可以直接用终端命令测试 Action。

    ros2 action send_goal /limo_action limo_msgs/action/LimoAction "{x: 0.2, y: 0.0, z: 0.0}"

    如果想看到反馈信息,可以加上:

    –feedback

    完整命令:

    ros2 action send_goal /limo_action limo_msgs/action/LimoAction "{x: 0.2, y: 0.0, z: 0.0}" –feedback

    这样终端就会显示服务端不断返回的 feedback。

  • ros2 action send_goal 命令可以直接在终端中向 Action 服务端发送目标任务,不需要额外编写客户端代码。
  • 其中 /limo_action 表示 Action 名称,limo_msgs/action/LimoAction 表示自定义 Action 接口类型,"{x: 0.2, y: 0.0, z: 0.0}" 表示发送给服务端的 goal 目标内容。
  • 如果在命令末尾加上 –feedback,终端会实时显示服务端执行过程中返回的 feedback 信息,方便调试 Action 通信流程。

  • 八、Topic、Service、Action 对比总结

    8.1 Topic 通信

    Topic 适合持续发送数据。

    例如:

  • 速度控制
  • 状态发布
  • 雷达数据
  • IMU 数据
  • 里程计数据
  • 特点是:

  • 发布者不断发布
  • 订阅者不断接收
  • 没有请求和响应
  • 没有最终结果返回
  • 可以理解为:

    Topic 是广播型通信。


    8.2 Service 通信

    Service 适合一次请求、一次响应。

    例如:

  • 打开某个功能
  • 切换某种模式
  • 发送一次控制请求
  • 查询一次状态
  • 特点是:

  • 客户端发送请求
  • 服务端处理请求
  • 服务端返回结果
  • 过程一般比较短
  • 可以理解为:

    Service 是问答型通信。


    8.3 Action 通信

    Action 适合长时间任务。

    例如:

  • 导航到目标点
  • 执行巡检任务
  • 执行机械臂动作
  • 小车持续运动一段时间
  • 需要反馈执行进度的任务
  • 特点是:

  • 客户端发送目标
  • 服务端执行任务
  • 执行过程中持续反馈
  • 结束后返回最终结果
  • 任务中途还可以取消
  • 可以理解为:

    Action 是任务型通信。


    8.4 三者对比表

    通信方式核心特点是否持续通信是否有返回结果是否有过程反馈适合场景
    Topic 发布/订阅 速度、状态、传感器数据
    Service 请求/响应 短时间一次性任务
    Action 目标/反馈/结果 长时间任务、导航、巡检

    九、本篇总结

    本篇文章在前面 Topic、msg、srv 的基础上,继续讲解了 ROS2 中非常重要的 Action 通信机制。

    前面我们已经知道:

  • Topic 可以持续发布速度和状态
  • 自定义 msg 可以封装自己的数据结构
  • Service 可以实现一次请求和一次响应
  • 但是当任务执行时间比较长,并且需要在执行过程中持续反馈状态时,Service 就不太适合了

    这时候就需要 Action。

    本篇通过自定义:

    LimoAction.action

    实现了一个小车 Action 控制案例。

    其中:

  • Goal 负责接收客户端发送的目标速度
  • Feedback 负责在执行过程中反馈当前状态
  • Result 负责在任务结束后返回最终结果
    • 服务端收到目标后,将 x、y、z 转换成 Twist 速度指令,并发布到 /cmd_vel 控制小车运动。
    • 客户端发送目标后,可以持续接收服务端反馈,并在任务结束后获取 success 结果。

    同时,本篇还完成了 limo_msgs 接口功能包和 limo_learning 代码功能包的配置。

    其中:

  • limo_msgs 负责定义自定义接口,包括 msg、srv、action。
  • limo_learning 负责存放 C++ 节点代码,包括 Topic、Service、Action 的服务端和客户端。
  • 至此,ROS2 中最核心的三种通信方式已经串起来了:

  • Topic:持续发布和订阅数据
  • Service:一次请求和一次响应
  • Action:长时间任务、过程反馈、最终结果
  • 下一篇文章会专门进入 Action 服务端和客户端代码解析,重点讲清楚:

  • create_server 是如何创建 Action 服务端的。
  • create_client 是如何创建 Action 客户端的。
  • GoalHandle 到底是什么。
  • handle_goal、handle_cancel、handle_accepted 分别什么时候执行。
  • execute 函数为什么要单独开线程。
  • publish_feedback、succeed、canceled、abort 分别有什么作用。
  • 客户端的 goal_response_callback、feedback_callback、result_callback 分别负责什么。
  • 本篇先把 Action 通信完整跑通。 下一篇再把 Action 服务端和客户端代码彻底讲明白。

    掌握这三种通信方式后,再去看 ROS2 机器人项目中的导航、巡检、底盘控制、机械臂控制等代码,就会清晰很多。

    赞(0)
    未经允许不得转载:171主机测评 » ROS2 C++ 小车控制完整实战(四):自定义 Action 通信保姆级教程
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址