头颈接口说明
头颈接口说明
/fd_robot/head* · 双轴关节 · 逐接口说明 + 独立示例
软件版本 FDRS-V04-02-00 · 核对日期 2026-09-17一、概述
头颈是机器人的双轴云台机构:lower 为偏航(下关节,左右转头),upper 为俯仰(上关节,上下点头)。本文覆盖手册第四章头颈的全部二次开发接口,共 9 个:6 个话题(5 个控制 + 1 个状态读取)+ 3 个服务。每个接口都配有一段可直接复制的示例(命令行 / Python / C++)。
头颈的复杂度不在接口数量,而在三件事:① 同一套接口在三种头颈电机上行为不同;② 弧度和度在同一份代码里混用;③ 偏航/俯仰与 up/down 的命名不是直觉对应。本文第二章把这些约定集中讲清,后面各接口不再重复。
/fd_robot/head* 下,由 fd_head_motor_driver_pkg 提供。本章接口仅在启动头颈驱动后可用;具体启用哪些接口,由 robot_profile.yaml 中的 head_yaw_motor 与 head_pitch_motor 决定。1.1 与 fd_api 头部关节接口的关系
工作区已有 《fd_api 头部关节说明》(C++ 封装层)。两者是两个层级,不要混用:
| 对比项 | fd_api 封装层 | 本文(ROS 2 原生接口) |
|---|---|---|
| 调用方式 | fd_robot::Control::head_set_pos() | ros2 topic pub / ros2 service call |
| 轴参数 | HeadType::kLower / kUpper |
关节名 lower / upper,或选对应话题 |
| 角度单位 | 度 | 弧度与度混用(见 2.3) |
| 限幅 | 按 robot_profile 自动限幅 |
同一套软限位,见 2.4 |
| 适用 | C++/Python 上位机 | 脚本调试、任意语言、系统集成 |
1.2 核心特性
偏航 / 俯仰各一套话题与服务
单轴话题(度)与双轴 JointState(弧度)
damiao / hh_servo / gm6020
超出软限位的目标被规划器限幅
头部运动平滑,不硬起停
damiao / gm6020 可跟随声源方位
头颈没有 Action,全部是话题与服务
俯仰 / 偏航可分别自检(非 HH)
1.3 三种头颈电机类型
同一份对接代码在不同电机上行为不同。写代码前先确认机型属于哪一类:
| 类型 | 说明 | 能力差异 |
|---|---|---|
| damiao | 达妙电机 | 使用规划器;提供 /fd_robot/dm_motor/* 底层接口;支持自检与声源跟随 |
| hh_servo | HH 舵机 | 不支持自检、声源跟随和 dm_motor 底层命令;独有 head/rotate、initialize、is_online |
| gm6020 + el05 | Y1 头颈 | 使用规划器并发布 motor_info;不提供 dm_motor 命令话题;支持自检与声源跟随 |
hh_y2 与 hh_y2_bdt 使用 hh_servo;fd_y1 为 gm6020 + el05;其余机型以现场配置为准。二、运行前提与约定
2.1 环境与节点前提
| 前提 | 说明 |
|---|---|
| 头颈驱动已启动 | 本文全部 9 个接口的共同前提 |
| ROS 2 环境已 source | source install/setup.bash |
ROS_DOMAIN_ID=25 |
与整机一致,否则互相看不到话题 |
head_yaw_motor / head_pitch_motor 已配置 |
决定启用哪一类头颈及其可用的接口子集 |
| 机器人处于安全状态 | 头颈运动范围内无人或障碍物 |
# 连通性自检:能列出话题即说明域 ID 与环境正常
ros2 topic list | grep head
# 确认接口类型与字段(以本机生成的接口定义为准)
ros2 interface show sensor_msgs/msg/JointState
ros2 interface show fd_msgs/msg/HeadScurveCmd
ros2 interface show fd_msgs/srv/HeadRotate
2.2 关节名与轴向
| 关节名 | 中文 | 位置 | 对应话题 | 动作含义 |
|---|---|---|---|---|
| lower | 偏航(yaw) | 下关节 | head_down_move / _ex | 左右转头 |
| upper | 俯仰(pitch) | 上关节 | head_up_move / _ex | 上下点头 |
up / down。话题名 head_up_move 控制的是俯仰轴(upper),head_down_move 控制的是偏航轴(lower)——这里的 up/down 指上下两个关节,不是"向上 / 向下"。写错会导致完全相反的运动。2.3 单位约定(最容易出错的地方)
| 接口 / 字段 | 单位 | 说明 |
|---|---|---|
| /fd_robot/head_motor/cmd · position | 弧度 rad | 长度与 name 一致 |
| /fd_robot/head/current_pose · position | 弧度 rad | 读取值,不要按度解读 |
| /fd_robot/head_up_move · data | 度 ° | 俯仰轴目标角度 |
| /fd_robot/head_down_move · data | 度 ° | 偏航轴目标角度 |
| head_up/head_down_move_ex · angle | 度 ° | 超软限位时限幅 |
| head_*_move_ex · speed | 陡度 或 deg/s | 1–20,默认 5;含义随电机类型而变(见 2.5) |
| /fd_robot/head/rotate · angle | 舵机原始值 0–1000 | 中位约 500;不是角度,超出范围时限幅 |
head_motor/cmd 前用 deg * M_PI / 180.0(或 Python math.radians())换算,并给变量名加上 _deg / _rad 后缀。2.4 头颈软限位(单位:度)
内置默认值如下,可通过 robot_profile.yaml 中的 head_upper_range、head_lower_range 修改。超出限位的目标角度会被规划器限幅(截断后继续执行,通常无报错):
| 机型 | 俯仰(upper) | 偏航(lower) |
|---|---|---|
| fd_y1 | −25 ~ 50 | −90 ~ 90 |
| hh_y2 | 0 ~ 30 | −45 ~ 45 |
| 其他(含 fd_y2、hh_y2_bdt) | −15 ~ 25 | −45 ~ 45 |
2.5 运动方式与速度语义差异
| 接口 | damiao / gm6020 | hh_servo |
|---|---|---|
| head_up_move / head_down_move | 按 S 曲线运动(steepness=5) | 按角速度插值运动 |
| head_*_move_ex · speed | S 曲线陡度 steepness(1–20,默认 5) | 角速度 deg/s(同样限幅到 1–20,默认 5) |
speed 数值在两灰头颈上的实际速度完全不同。在 damiao/gm6020 上它是"曲线陡度"(无量纲),在 hh_servo 上它是"度/秒"。跨机型复用参数时必须重新整定,不能照抄。另外:
speed 传入非正数或非有限值时按默认值 5 处理,不会报错。2.6 两种写法怎么选
| 需求 | 推荐写法 | 理由 |
|---|---|---|
| 双轴同时下发、要绝对角度 | /fd_robot/head_motor/cmd | 一次 JointState 定两轴,弧度制 |
| 只动一个轴、按角度思考 | head_up_move / head_down_move | 单位是度,最直观 |
| 要控制速度/平滑度 | head_*_move_ex | 带 speed 字段 |
| HH 舵机、按原始位置控制 | /fd_robot/head/rotate | 0–1000 舵机值,仅 hh_servo 有 |
三、接口总览
3.1 二次开发接口(9 个)
以下为手册第四章中分割线以上的接口,是客户对接应当使用的全部头颈接口。
话题(6 个)
| 话题 | 类型 | 方向 | 功能 | 配置条件 |
|---|---|---|---|---|
| 话题 /fd_robot/head_motor/cmd | sensor_msgs/JointState | 发布 | 双轴关节位置命令(弧度) | 头颈驱动已启动 |
| 话题 /fd_robot/head_up_move | std_msgs/Float32 | 发布 | 俯仰轴目标角度(度) | 头颈驱动已启动 |
| 话题 /fd_robot/head_down_move | std_msgs/Float32 | 发布 | 偏航轴目标角度(度) | 头颈驱动已启动 |
| 话题 /fd_robot/head_up_move_ex | fd_msgs/HeadScurveCmd | 发布 | 俯仰轴目标角度 + 速度 | 头颈驱动已启动 |
| 话题 /fd_robot/head_down_move_ex | fd_msgs/HeadScurveCmd | 发布 | 偏航轴目标角度 + 速度 | 头颈驱动已启动 |
| 话题 /fd_robot/head/current_pose | sensor_msgs/JointState | 订阅 | 当前头颈姿态(弧度),name=["lower","upper"] |
头颈驱动已启动 |
服务(3 个,仅 hh_servo)
| 服务 | 类型 | 功能 | 配置条件 |
|---|---|---|---|
| 服务 /fd_robot/head/rotate | fd_msgs/HeadRotate | 转动到指定舵机位置(0–1000) | head_yaw_motor=hh_servo |
| 服务 /fd_robot/head/initialize | fd_msgs/HeadInitialize | 初始化复位;servo_ids 为空时默认 1、2 |
head_yaw_motor=hh_servo |
| 服务 /fd_robot/head/is_online | fd_msgs/HeadIsOnline | 查询舵机是否在线 | head_yaw_motor=hh_servo |
current_pose。3.2 系统内部接口(二次开发请勿使用)
下列接口在手册中列于「—— 系统内部 ——」分割线以下,属于驱动调试 / 自检 / 底层电机控制,不要与二次开发并行抢流:
| 类别 | 接口 | 配置条件 |
|---|---|---|
| 状态 / 底层 | /fd_robot/head_motor/motor_info(电机详细状态) /fd_robot/dm_motor/enable_cmd · mit_cmd · position_cmd · velocity_cmd · util_cmd |
damiao / gm6020 head_yaw_motor=damiao |
| 串口管理 | /fd_robot/head/list_ports · connect · disconnect | head_yaw_motor=hh_servo |
| 自检 | /fd_robot/self_check/head(俯仰轴) /fd_robot/self_check/neck(偏航轴) |
damiao / gm6020 |
| 电机参数 | /fd_robot/dm_motor/get_motor_status · add_motor_srv · read_param_srv · write_param_srv | head_yaw_motor=damiao |
| 声源输入 | /fd_robot/sound_module/sound_source_location | damiao / gm6020 |
/fd_robot/sound_module/sound_source_location(std_msgs/Float32,度)实现声源跟随,仅 damiao / gm6020 可用,hh_servo 不支持。该话题在手册第四章列为由 fd_head_motor_driver_pkg 发布、第十三章又列为 fd_iflytek_module 相关,两处描述不一致——二次开发请勿依赖该话题,需要声源跟随请直接对接语音模块章节的接口。四、话题接口详解
6 个话题按用途分两组:5 个命令话题(方向=发布)与 1 个状态话题(方向=订阅)。每个接口下方都给出「命令行 / Python / C++」三段示例。
0°、俯仰目标 10°(即弧度 0.0 与 0.1745)。Python/C++ 示例中的 node.spin_once()、rclcpp::spin_some() 只是为了给发布留出时间,实际工程中按你的主循环组织。4.1 /fd_robot/head_motor/cmd —— 双轴关节位置命令
| 字段 | 类型 | 取值 | 说明 |
|---|---|---|---|
| name | string[] | ['lower', 'upper'] |
关节名;lower=偏航,upper=俯仰 |
| position | float64[] | 弧度,长度与 name 一致 |
目标关节角,超出软限位会被限幅 |
position ≈ 0,该轴会执行初始化回中。这既是有用的复位手段,也是误操作风险——如果只是想把轴转到 0 度,请先确认这是你要的行为。示例 ① 命令行(ros2 CLI)
# 双轴同时下发:偏航 0 rad、俯仰 0.17 rad(约 10°)
ros2 topic pub -1 /fd_robot/head_motor/cmd sensor_msgs/msg/JointState \
"{name: ['lower', 'upper'], position: [0.0, 0.17]}"
# 偏航转到 30°(约 0.524 rad)、俯仰归零
ros2 topic pub -1 /fd_robot/head_motor/cmd sensor_msgs/msg/JointState \
"{name: ['lower', 'upper'], position: [0.524, 0.0]}"
# HH 舵机:发送 position≈0 使某轴初始化回中
ros2 topic pub -1 /fd_robot/head_motor/cmd sensor_msgs/msg/JointState \
"{name: ['lower', 'upper'], position: [0.0, 0.0]}"
示例 ② Python(rclpy)
import math
import rclpy
from sensor_msgs.msg import JointState
rclpy.init()
node = rclpy.create_node('head_cmd_demo')
pub = node.create_publisher(JointState, '/fd_robot/head_motor/cmd', 10)
msg = JointState()
msg.name = ['lower', 'upper']
# 业务层用「度」,发送前统一换算成弧度
yaw_deg, pitch_deg = 0.0, 10.0
msg.position = [math.radians(yaw_deg), math.radians(pitch_deg)]
pub.publish(msg)
node.get_logger().info(f'head_motor/cmd 已下发: {msg.position}')
node.spin_once(timeout_sec=0.2)
node.destroy_node()
rclpy.shutdown()
示例 ③ C++(rclcpp)
#include <cmath>
#include <rclcpp/rclcpp.hpp>
#include <sensor_msgs/msg/joint_state.hpp>
int main(int argc, char** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("head_cmd_demo");
auto pub = node->create_publisher<sensor_msgs::msg::JointState>(
"/fd_robot/head_motor/cmd", 10);
sensor_msgs::msg::JointState msg;
msg.name = {"lower", "upper"};
const double yaw_deg = 0.0, pitch_deg = 10.0;
msg.position = {yaw_deg * M_PI / 180.0, pitch_deg * M_PI / 180.0};
pub->publish(msg);
RCLCPP_INFO(node->get_logger(), "head_motor/cmd 已下发");
rclcpp::spin_some(node);
rclcpp::shutdown();
return 0;
}
4.2 /fd_robot/head_up_move —— 俯仰轴目标角度
| 字段 | 类型 | 取值 | 说明 |
|---|---|---|---|
| data | float32 | 度,受软限位约束 | 俯仰轴目标角度;超限被限幅 |
head_up_move 对应 upper(俯仰 / 上下点头)。要把机器人头左右转,用的是 4.3 的 head_down_move。示例 ① 命令行(ros2 CLI)
# 俯仰轴转到 10°
ros2 topic pub -1 /fd_robot/head_up_move std_msgs/msg/Float32 "{data: 10.0}"
# 俯仰轴转到 -5°
ros2 topic pub -1 /fd_robot/head_up_move std_msgs/msg/Float32 "{data: -5.0}"
示例 ② Python(rclpy)
import rclpy
from std_msgs.msg import Float32
rclpy.init()
node = rclpy.create_node('head_up_demo')
pub = node.create_publisher(Float32, '/fd_robot/head_up_move', 10)
pitch_deg = 10.0 # 单位:度
pub.publish(Float32(data=pitch_deg))
node.get_logger().info(f'head_up_move(俯仰) -> {pitch_deg} deg')
node.spin_once(timeout_sec=0.2)
node.destroy_node()
rclpy.shutdown()
示例 ③ C++(rclcpp)
#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/float32.hpp>
int main(int argc, char** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("head_up_demo");
auto pub = node->create_publisher<std_msgs::msg::Float32>(
"/fd_robot/head_up_move", 10);
std_msgs::msg::Float32 msg;
msg.data = 10.0f; // 单位:度
pub->publish(msg);
RCLCPP_INFO(node->get_logger(), "head_up_move(俯仰) -> %.1f deg", msg.data);
rclcpp::spin_some(node);
rclcpp::shutdown();
return 0;
}
4.3 /fd_robot/head_down_move —— 偏航轴目标角度
| 字段 | 类型 | 取值 | 说明 |
|---|---|---|---|
| data | float32 | 度,受软限位约束 | 偏航轴目标角度;超限被限幅 |
head_motor/cmd 一次下发更可靠;若两轴分别用 up_move / down_move 连续发送,两个话题的到达顺序不保证,可能出现短暂的"先后动"。示例 ① 命令行(ros2 CLI)
# 偏航轴转到 -15°(向左转)
ros2 topic pub -1 /fd_robot/head_down_move std_msgs/msg/Float32 "{data: -15.0}"
# 偏航轴回到 0°(正前方)
ros2 topic pub -1 /fd_robot/head_down_move std_msgs/msg/Float32 "{data: 0.0}"
示例 ② Python(rclpy)
import rclpy
from std_msgs.msg import Float32
rclpy.init()
node = rclpy.create_node('head_down_demo')
pub = node.create_publisher(Float32, '/fd_robot/head_down_move', 10)
yaw_deg = -15.0 # 单位:度,负值向左
pub.publish(Float32(data=yaw_deg))
node.get_logger().info(f'head_down_move(偏航) -> {yaw_deg} deg')
node.spin_once(timeout_sec=0.2)
node.destroy_node()
rclpy.shutdown()
示例 ③ C++(rclcpp)
#include <rclcpp/rclcpp.hpp>
#include <std_msgs/msg/float32.hpp>
int main(int argc, char** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("head_down_demo");
auto pub = node->create_publisher<std_msgs::msg::Float32>(
"/fd_robot/head_down_move", 10);
std_msgs::msg::Float32 msg;
msg.data = -15.0f; // 单位:度,负值向左
pub->publish(msg);
RCLCPP_INFO(node->get_logger(), "head_down_move(偏航) -> %.1f deg", msg.data);
rclcpp::spin_some(node);
rclcpp::shutdown();
return 0;
}
4.4 /fd_robot/head_up_move_ex —— 俯仰轴目标角度 + 速度
speed 字段,用于指定运动平滑度 / 速度。消息类型为 fd_msgs/HeadScurveCmd。| 字段 | 类型 | 取值 | 说明 |
|---|---|---|---|
| angle | float32 | 度 | 俯仰轴目标角度;超出软限位时限幅 |
| speed | float32 | 1–20,默认 5 | damiao / gm6020:S 曲线陡度 steepness hh_servo:角速度 deg/s |
示例 ① 命令行(ros2 CLI)
# 俯仰轴转到 10°,speed=5
ros2 topic pub -1 /fd_robot/head_up_move_ex fd_msgs/msg/HeadScurveCmd \
"{angle: 10.0, speed: 5.0}"
# 慢速(陡度/角速度 = 2)
ros2 topic pub -1 /fd_robot/head_up_move_ex fd_msgs/msg/HeadScurveCmd \
"{angle: 10.0, speed: 2.0}"
# 非法 speed(0)会被按默认 5 处理
ros2 topic pub -1 /fd_robot/head_up_move_ex fd_msgs/msg/HeadScurveCmd \
"{angle: 10.0, speed: 0.0}"
示例 ② Python(rclpy)
import rclpy
from fd_msgs.msg import HeadScurveCmd
rclpy.init()
node = rclpy.create_node('head_up_ex_demo')
pub = node.create_publisher(HeadScurveCmd, '/fd_robot/head_up_move_ex', 10)
msg = HeadScurveCmd()
msg.angle = 10.0 # 度
msg.speed = 5.0 # damiao/gm6020: 陡度;hh_servo: deg/s
pub.publish(msg)
node.get_logger().info(f'up_ex: angle={msg.angle} speed={msg.speed}')
node.spin_once(timeout_sec=0.2)
node.destroy_node()
rclpy.shutdown()
示例 ③ C++(rclcpp)
#include <rclcpp/rclcpp.hpp>
#include <fd_msgs/msg/head_scurve_cmd.hpp>
int main(int argc, char** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("head_up_ex_demo");
auto pub = node->create_publisher<fd_msgs::msg::HeadScurveCmd>(
"/fd_robot/head_up_move_ex", 10);
fd_msgs::msg::HeadScurveCmd msg;
msg.angle = 10.0f; // 度
msg.speed = 5.0f; // damiao/gm6020: 陡度;hh_servo: deg/s
pub->publish(msg);
RCLCPP_INFO(node->get_logger(), "up_ex angle=%.1f speed=%.1f",
msg.angle, msg.speed);
rclcpp::spin_some(node);
rclcpp::shutdown();
return 0;
}
4.5 /fd_robot/head_down_move_ex —— 偏航轴目标角度 + 速度
| 字段 | 类型 | 取值 | 说明 |
|---|---|---|---|
| angle | float32 | 度 | 偏航轴目标角度;超出软限位时限幅 |
| speed | float32 | 1–20,默认 5 | 含义同 4.4,随电机类型而变 |
示例 ① 命令行(ros2 CLI)
# 偏航轴转到 -15°,speed=5
ros2 topic pub -1 /fd_robot/head_down_move_ex fd_msgs/msg/HeadScurveCmd \
"{angle: -15.0, speed: 5.0}"
示例 ② Python(rclpy)
import rclpy
from fd_msgs.msg import HeadScurveCmd
rclpy.init()
node = rclpy.create_node('head_down_ex_demo')
pub = node.create_publisher(HeadScurveCmd, '/fd_robot/head_down_move_ex', 10)
msg = HeadScurveCmd()
msg.angle = -15.0 # 度,负值向左
msg.speed = 5.0
pub.publish(msg)
node.get_logger().info(f'down_ex: angle={msg.angle} speed={msg.speed}')
node.spin_once(timeout_sec=0.2)
node.destroy_node()
rclpy.shutdown()
示例 ③ C++(rclcpp)
#include <rclcpp/rclcpp.hpp>
#include <fd_msgs/msg/head_scurve_cmd.hpp>
int main(int argc, char** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("head_down_ex_demo");
auto pub = node->create_publisher<fd_msgs::msg::HeadScurveCmd>(
"/fd_robot/head_down_move_ex", 10);
fd_msgs::msg::HeadScurveCmd msg;
msg.angle = -15.0f; // 度,负值向左
msg.speed = 5.0f;
pub->publish(msg);
RCLCPP_INFO(node->get_logger(), "down_ex angle=%.1f speed=%.1f",
msg.angle, msg.speed);
rclcpp::spin_some(node);
rclcpp::shutdown();
return 0;
}
4.6 /fd_robot/head/current_pose —— 当前头颈姿态
name=["lower","upper"],position 单位为弧度。这是判断头颈是否到位、当前角度的唯一途径。| 字段 | 类型 | 取值 | 说明 |
|---|---|---|---|
| name | string[] | ["lower", "upper"] |
顺序固定,索引 0=偏航,1=俯仰 |
| position | float64[] | 弧度 | 当前关节角 |
| 电机类型 | 发布频率 | 数据性质 |
|---|---|---|
| hh_servo | 默认 20 Hz | 估计值——无真实角度反馈,按已下发命令斜坡推算;未初始化的轴为 NaN |
| gm6020 | 约 200 Hz | 真实反馈 |
| damiao | 以现场配置为准 | 真实反馈 |
/fd_robot/head/initialize 再读取姿态,否则可能拿到 NaN 导致后续计算全部污染。对 NaN 做数学运算不会报错,会安静地传播下去。示例 ① 命令行(ros2 CLI)
# 实时查看当前姿态(弧度)
ros2 topic echo /fd_robot/head/current_pose
# 只看前 3 条
ros2 topic echo /fd_robot/head/current_pose --once
示例 ② Python(rclpy)
import math
import rclpy
from sensor_msgs.msg import JointState
rclpy.init()
node = rclpy.create_node('head_pose_demo')
def on_pose(msg: JointState):
# 顺序固定:lower=偏航, upper=俯仰
yaw = msg.position[0]
pitch = msg.position[1]
if math.isnan(yaw) or math.isnan(pitch):
node.get_logger().warn('姿态为 NaN —— HH 舵机请先调用 initialize')
return
node.get_logger().info(
f'偏航 {math.degrees(yaw):.1f}° / 俯仰 {math.degrees(pitch):.1f}°')
node.create_subscription(
JointState, '/fd_robot/head/current_pose', on_pose, 10)
rclpy.spin(node)
示例 ③ C++(rclcpp)
#include <cmath>
#include <rclcpp/rclcpp.hpp>
#include <sensor_msgs/msg/joint_state.hpp>
int main(int argc, char** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("head_pose_demo");
auto sub = node->create_subscription<sensor_msgs::msg::JointState>(
"/fd_robot/head/current_pose", 10,
[node](const sensor_msgs::msg::JointState::SharedPtr msg) {
if (msg->position.size() < 2) return;
const double yaw = msg->position[0], pitch = msg->position[1];
if (std::isnan(yaw) || std::isnan(pitch)) {
RCLCPP_WARN(node->get_logger(),
"姿态为 NaN —— HH 舵机请先调用 initialize");
return;
}
RCLCPP_INFO(node->get_logger(),
"偏航 %.1f deg / 俯仰 %.1f deg",
yaw * 180.0 / M_PI, pitch * 180.0 / M_PI);
});
rclcpp::spin(node);
rclcpp::shutdown();
return 0;
}
五、服务接口详解
3 个服务全部仅在 head_yaw_motor=hh_servo 时存在。使用 damiao / gm6020 的头颈上,这些服务不会被注册,调用会直接失败(找不到服务)。
5.1 /fd_robot/head/rotate —— 转动到指定舵机位置
| 字段 | 侧 | 类型 | 取值 | 说明 |
|---|---|---|---|---|
| servo_id | 请求 | int32 | 通常 1 = 偏航,2 = 俯仰 | 具体编号以现场接线为准 |
| angle | 请求 | int32 | 0–1000,中位约 500 | 超出范围时限幅,非角度值 |
| success | 响应 | bool | — | 是否成功下发 |
| message | 响应 | string | — | 失败原因 / 状态描述 |
angle=90 当成"转 90 度"——那只是刻度 90,接近一端的极限位置。中位约 500;如需按角度思考,请改用 4.1 / 4.2 / 4.3 的度制话题。示例 ① 命令行(ros2 CLI)
# 偏航轴(servo_id=1)转到中位
ros2 service call /fd_robot/head/rotate fd_msgs/srv/HeadRotate \
"{servo_id: 1, angle: 500}"
# 俯仰轴(servo_id=2)给一个偏上的刻度
ros2 service call /fd_robot/head/rotate fd_msgs/srv/HeadRotate \
"{servo_id: 2, angle: 600}"
# 查看接口定义,确认字段名
ros2 interface show fd_msgs/srv/HeadRotate
示例 ② Python(rclpy)
import rclpy
from fd_msgs.srv import HeadRotate
rclpy.init()
node = rclpy.create_node('head_rotate_demo')
client = node.create_client(HeadRotate, '/fd_robot/head/rotate')
if not client.wait_for_service(timeout_sec=2.0):
node.get_logger().error('服务不可用 —— 请确认 head_yaw_motor=hh_servo')
raise SystemExit(1)
req = HeadRotate.Request()
req.servo_id = 1 # 1=偏航, 2=俯仰
req.angle = 500 # 舵机刻度 0-1000,中位约 500
future = client.call_async(req)
rclpy.spin_until_future_complete(node, future)
res = future.result()
node.get_logger().info(f'success={res.success} message={res.message}')
node.destroy_node()
rclpy.shutdown()
示例 ③ C++(rclcpp)
#include <chrono>
#include <memory>
#include <rclcpp/rclcpp.hpp>
#include <fd_msgs/srv/head_rotate.hpp>
int main(int argc, char** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("head_rotate_demo");
auto client = node->create_client<fd_msgs::srv::HeadRotate>(
"/fd_robot/head/rotate");
if (!client->wait_for_service(std::chrono::seconds(2))) {
RCLCPP_ERROR(node->get_logger(),
"服务不可用 —— 请确认 head_yaw_motor=hh_servo");
return 1;
}
auto req = std::make_shared<fd_msgs::srv::HeadRotate::Request>();
req->servo_id = 1; // 1=偏航, 2=俯仰
req->angle = 500; // 舵机刻度 0-1000
auto future = client->async_send_request(req);
if (rclcpp::spin_until_future_complete(node, future) ==
rclcpp::FutureReturnCode::SUCCESS) {
auto res = future.get();
RCLCPP_INFO(node->get_logger(), "success=%d message=%s",
res->success, res->message.c_str());
}
rclcpp::shutdown();
return 0;
}
5.2 /fd_robot/head/initialize —— 初始化复位
servo_ids 为空时默认初始化 1、2 号舵机(即双轴)。| 字段 | 侧 | 类型 | 取值 | 说明 |
|---|---|---|---|---|
| servo_ids | 请求 | int32[] | 如 [1] / [1,2] / [] |
为空时默认 1、2 |
NaN,直接参与运算会静默污染结果。这是 hh_servo 对接的第一个动作。示例 ① 命令行(ros2 CLI)
# 初始化双轴(servo_ids 为空 = 默认 1、2)
ros2 service call /fd_robot/head/initialize fd_msgs/srv/HeadInitialize "{servo_ids: []}"
# 只初始化偏航轴
ros2 service call /fd_robot/head/initialize fd_msgs/srv/HeadInitialize "{servo_ids: [1]}"
# 初始化双轴(显式写法)
ros2 service call /fd_robot/head/initialize fd_msgs/srv/HeadInitialize "{servo_ids: [1, 2]}"
示例 ② Python(rclpy)
import rclpy
from fd_msgs.srv import HeadInitialize
rclpy.init()
node = rclpy.create_node('head_init_demo')
client = node.create_client(HeadInitialize, '/fd_robot/head/initialize')
if not client.wait_for_service(timeout_sec=2.0):
node.get_logger().error('服务不可用 —— 请确认 head_yaw_motor=hh_servo')
raise SystemExit(1)
req = HeadInitialize.Request()
req.servo_ids = [] # 空 = 默认初始化 1、2
future = client.call_async(req)
rclpy.spin_until_future_complete(node, future)
node.get_logger().info(f'initialize 完成: {future.result()}')
node.destroy_node()
rclpy.shutdown()
示例 ③ C++(rclcpp)
#include <chrono>
#include <rclcpp/rclcpp.hpp>
#include <fd_msgs/srv/head_initialize.hpp>
int main(int argc, char** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("head_init_demo");
auto client = node->create_client<fd_msgs::srv::HeadInitialize>(
"/fd_robot/head/initialize");
if (!client->wait_for_service(std::chrono::seconds(2))) {
RCLCPP_ERROR(node->get_logger(), "服务不可用");
return 1;
}
auto req = std::make_shared<fd_msgs::srv::HeadInitialize::Request>();
req->servo_ids = {}; // 空 = 默认初始化 1、2
auto future = client->async_send_request(req);
if (rclcpp::spin_until_future_complete(node, future) ==
rclcpp::FutureReturnCode::SUCCESS) {
RCLCPP_INFO(node->get_logger(), "initialize 完成");
}
rclcpp::shutdown();
return 0;
}
5.3 /fd_robot/head/is_online —— 查询舵机是否在线
| 字段 | 侧 | 类型 | 说明 |
|---|---|---|---|
| online | 响应 | bool | 舵机是否在线 |
| message | 响应 | string | 状态描述 / 失败原因 |
{} 即可。若连接过串口后仍返回不在线,请检查 /fd_robot/head/connect(系统内部接口)是否已正确连接。示例 ① 命令行(ros2 CLI)
ros2 service call /fd_robot/head/is_online fd_msgs/srv/HeadIsOnline "{}"
示例 ② Python(rclpy)
import rclpy
from fd_msgs.srv import HeadIsOnline
rclpy.init()
node = rclpy.create_node('head_online_demo')
client = node.create_client(HeadIsOnline, '/fd_robot/head/is_online')
if not client.wait_for_service(timeout_sec=2.0):
node.get_logger().error('服务不可用 —— 请确认 head_yaw_motor=hh_servo')
raise SystemExit(1)
future = client.call_async(HeadIsOnline.Request())
rclpy.spin_until_future_complete(node, future)
res = future.result()
if res.online:
node.get_logger().info(f'舵机在线: {res.message}')
else:
node.get_logger().error(f'舵机离线: {res.message}')
node.destroy_node()
rclpy.shutdown()
示例 ③ C++(rclcpp)
#include <chrono>
#include <rclcpp/rclcpp.hpp>
#include <fd_msgs/srv/head_is_online.hpp>
int main(int argc, char** argv) {
rclcpp::init(argc, argv);
auto node = std::make_shared<rclcpp::Node>("head_online_demo");
auto client = node->create_client<fd_msgs::srv::HeadIsOnline>(
"/fd_robot/head/is_online");
if (!client->wait_for_service(std::chrono::seconds(2))) {
RCLCPP_ERROR(node->get_logger(), "服务不可用");
return 1;
}
auto future = client->async_send_request(
std::make_shared<fd_msgs::srv::HeadIsOnline::Request>());
if (rclcpp::spin_until_future_complete(node, future) ==
rclcpp::FutureReturnCode::SUCCESS) {
auto res = future.get();
if (res->online) {
RCLCPP_INFO(node->get_logger(), "舵机在线: %s", res->message.c_str());
} else {
RCLCPP_ERROR(node->get_logger(), "舵机离线: %s", res->message.c_str());
}
}
rclcpp::shutdown();
return 0;
}
六、典型流程与完整示例
6.1 对接步骤
先按 1.3 判断你的头颈属于哪一类,再走对应分支:
| 步骤 | hh_servo 分支 | damiao / gm6020 分支 |
|---|---|---|
| 1. 自检 | is_online 查舵机在线 |
(可选)自检服务,属系统内部接口 |
| 2. 初始化 | 必须调 initialize,否则姿态为 NaN |
无需 |
| 3. 读初值 | 订阅 current_pose,确认非 NaN |
订阅 current_pose 拿当前角 |
| 4. 下发运动 | 度制话题 或 head/rotate |
度制话题 或 head_motor/cmd |
| 5. 到位判定 | 读 current_pose(推算值,20 Hz) |
读 current_pose(真实反馈) |
6.2 完整闭环示例(Python)
下面这段程序走完 自检 → 初始化 → 读初值 → 运动 → 到位判定 全流程。开头通过一次 is_online 探测自动分流 hh_servo 与其它类型:
import math
import rclpy
from rclpy.node import Node
from sensor_msgs.msg import JointState
from std_msgs.msg import Float32
from fd_msgs.srv import HeadIsOnline, HeadInitialize
class HeadController(Node):
def __init__(self):
super().__init__('head_controller')
self.latest = None
self.create_subscription(
JointState, '/fd_robot/head/current_pose', self._on_pose, 10)
self.pub_up = self.create_publisher(
Float32, '/fd_robot/head_up_move', 10)
self.pub_down = self.create_publisher(
Float32, '/fd_robot/head_down_move', 10)
self.cli_online = self.create_client(
HeadIsOnline, '/fd_robot/head/is_online')
self.cli_init = self.create_client(
HeadInitialize, '/fd_robot/head/initialize')
def _on_pose(self, msg: JointState):
self.latest = msg
def _call(self, client, req):
if not client.wait_for_service(timeout_sec=2.0):
return None
fut = client.call_async(req)
rclpy.spin_until_future_complete(self, fut)
return fut.result()
def setup(self):
"""hh_servo 分支:在线检查 + 初始化;其它类型直接跳过。"""
res = self._call(self.cli_online, HeadIsOnline.Request())
if res is None:
self.get_logger().info('无 is_online 服务 -> 认定为 damiao / gm6020')
return
if not res.online:
raise RuntimeError(f'舵机离线: {res.message}')
req = HeadInitialize.Request()
req.servo_ids = [] # 空 = 默认 1、2
self._call(self.cli_init, req)
self.get_logger().info('hh_servo 已初始化')
def read_pose(self, timeout=2.0):
"""读取姿态,返回 (偏航deg, 俯仰deg);NaN 视为无效。"""
deadline = self.get_clock().now().nanoseconds + int(timeout * 1e9)
while rclpy.ok():
rclpy.spin_once(self, timeout_sec=0.1)
if self.latest is not None and len(self.latest.position) >= 2:
yaw_r, pitch_r = self.latest.position[0], self.latest.position[1]
if not (math.isnan(yaw_r) or math.isnan(pitch_r)):
return math.degrees(yaw_r), math.degrees(pitch_r)
if self.get_clock().now().nanoseconds > deadline:
raise TimeoutError('等待 current_pose 超时')
raise RuntimeError('rclpy 已关闭')
def move(self, yaw_deg, pitch_deg, tol_deg=2.0, timeout=5.0):
"""下发双轴目标并等待到位。"""
self.pub_down.publish(Float32(data=float(yaw_deg)))
self.pub_up.publish(Float32(data=float(pitch_deg)))
self.get_logger().info(f'目标: 偏航 {yaw_deg} / 俯仰 {pitch_deg}')
deadline = self.get_clock().now().nanoseconds + int(timeout * 1e9)
while rclpy.ok():
y, p = self.read_pose()
if abs(y - yaw_deg) <= tol_deg and abs(p - pitch_deg) <= tol_deg:
self.get_logger().info(f'到位: 偏航 {y:.1f} / 俯仰 {p:.1f}')
return True
if self.get_clock().now().nanoseconds > deadline:
self.get_logger().warn(
f'超时未到位: 当前 偏航 {y:.1f} / 俯仰 {p:.1f}')
return False
return False
def main():
rclpy.init()
ctrl = HeadController()
try:
ctrl.setup()
ctrl.read_pose()
ctrl.move(yaw_deg=0.0, pitch_deg=10.0) # 抬头看前方
ctrl.move(yaw_deg=-15.0, pitch_deg=0.0) # 左转回平
finally:
ctrl.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
ros2 命令行示例,程序化调用是按参数表推导的标准 ROS 2 写法。落地前请用 ros2 interface show fd_msgs/srv/HeadIsOnline 等命令,核对本机生成的字段名与服务是否存在。6.3 到位判定怎么写
| 做法 | 评价 |
|---|---|
读 current_pose,比较与目标角的差值 |
推荐。头颈没有 Action,这是唯一可靠的到位依据 |
固定 sleep(1) 后认为到位 |
不要用。运动时长随角度、speed、电机类型变化,必然误判 |
| 发完命令就进入下一步 | 仅在纯展示场景可接受;联动场景会导致动作叠加、姿态漂移 |
tol_deg=2.0 是工程建议值,请按你的实际精度要求调整。对 hh_servo 而言,current_pose 本身是推算值,容差不宜设得过小。七、注意事项与已知限制
7.1 硬性约束
head_motor/cmd 与 current_pose 是弧度;head_up_move、head_down_move、*_ex.angle 是度;head/rotate.angle 是0–1000 舵机刻度。三种单位混在一章里,建议变量名带单位后缀。head_up_move=俯仰轴(upper),head_down_move=偏航轴(lower)。按字面理解会导致完全相反的运动。current_pose 为 NaN;NaN 参与运算不报错但会静默污染下游。7.2 已知限制(手册附录)
| 接口 | 限制 | 建议用法 | 影响 |
|---|---|---|---|
| head_*_move_ex.speed | damiao / gm6020 下为 S 曲线陡度,hh_servo 下为 deg/s,范围均为 1–20 | 根据头颈电机类型确定取值 | 高 |
| /fd_robot/head/current_pose (HH) |
为估计值,未初始化的轴为 NaN | 先调用 initialize 再读取 |
高 |
| /fd_robot/sound_module/ sound_source_location |
手册第四章列为 fd_head_motor_driver_pkg、第十三章列为 fd_iflytek_module,两处描述不一致 |
二次开发请勿依赖;需要声源跟随直接对接语音模块接口 | 中 |
| /fd_robot/head/ list_ports · connect · disconnect |
属系统内部接口(串口管理) | 不要与二次开发并行使用 | 中 |
| /fd_robot/dm_motor/* | 驱动层直接控制,绕过规划器;/fd_robot/dm_motor/pvt_cmd 已停用,运行时不存在 |
应用层统一走 head_motor/cmd 等二次开发接口 |
低 |
7.3 排查速查
| 现象 | 可能原因 |
|---|---|
| 调了 3 秒没反应 | 头颈驱动未启动;或 ROS_DOMAIN_ID 与整机不一致 |
| 运动方向与预期相反 | 把 up/down 按方向理解了,见 2.2 |
| 角度只动了很小一段 | 目标超软限位被静默截断,见 2.4;或把弧度当度发送 |
| 读到的姿态是 NaN | hh_servo 未调用 initialize,见 6.1 / 7.1 |
| 服务调用失败「找不到服务」 | 当前不是 hh_servo,3 个服务仅在 hh_servo 上注册 |
| 改了 speed 但速度没变化 | 传了 0 或负数,被按默认 5 处理,见 4.4 |
| 头颈抖动 / 动作被截断 | fd_api 与 ROS 话题两套混用,抢同一条命令通道 |
