头颈接口说明

一、概述

头颈是机器人的双轴云台机构: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 上位机 脚本调试、任意语言、系统集成
不要同时用两套。fd_api 与本文接口最终都落到同一条命令话题上,混用会出现"两个发布者抢流"、动作截断或抖动。选定一套后全程只用那一套。

1.2 核心特性

双轴独立
偏航 / 俯仰各一套话题与服务
两套写法
单轴话题(度)与双轴 JointState(弧度)
三电机兼容
damiao / hh_servo / gm6020
自动限幅
超出软限位的目标被规划器限幅
S 曲线 / 斜坡
头部运动平滑,不硬起停
声源跟随
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
头颈没有动作(Action)接口。手册 4.3 节明确写「无」。头颈全部运动都是"下发即走"的开放环命令,判断是否到位需要靠读 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 —— 双轴关节位置命令

/fd_robot/head_motor/cmd 话题 发布
一次下发两个轴的绝对目标位置,单位为弧度。是头颈最底层、最通用的命令接口。
字段 类型 取值 说明
name string[] ['lower', 'upper'] 关节名;lower=偏航,upper=俯仰
position float64[] 弧度,长度与 name 一致 目标关节角,超出软限位会被限幅
HH 舵机特有行为:使用 hh_servo 时,给某轴发送 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 —— 俯仰轴目标角度

/fd_robot/head_up_move 话题 发布
只控制俯仰轴(upper),单位为度。damiao / gm6020 按 S 曲线运动(steepness=5),hh_servo 按角速度插值运动。
字段 类型 取值 说明
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 —— 偏航轴目标角度

/fd_robot/head_down_move 话题 发布
只控制偏航轴(lower),单位为度。运动方式同 4.2,取决于电机类型。
字段 类型 取值 说明
data float32 度,受软限位约束 偏航轴目标角度;超限被限幅
双轴联动时用 4.1 的 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 —— 俯仰轴目标角度 + 速度

/fd_robot/head_up_move_ex 话题 发布
在 4.2 基础上增加 speed 字段,用于指定运动平滑度 / 速度。消息类型为 fd_msgs/HeadScurveCmd。
字段 类型 取值 说明
angle float32 度 俯仰轴目标角度;超出软限位时限幅
speed float32 1–20,默认 5 damiao / gm6020:S 曲线陡度 steepness
hh_servo:角速度 deg/s
speed 传入非正数或非有限值时按 5 处理,不会报错。若发现"调了 speed 没反应",先检查是不是传了 0 或负数。

示例 ① 命令行(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 —— 偏航轴目标角度 + 速度

/fd_robot/head_down_move_ex 话题 发布
字段与 4.4 完全相同,作用于偏航轴(lower)。
字段 类型 取值 说明
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 —— 当前头颈姿态

/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 以现场配置为准 真实反馈
HH 舵机的 current_pose 是推算值,未初始化时为 NaN。对接 hh_servo 时务必先调用 /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 —— 转动到指定舵机位置

/fd_robot/head/rotate 服务 hh_servo
按舵机原始位置值(0–1000)转动指定轴。注意这不是角度——这是舵机内部的位置刻度。
字段 侧 类型 取值 说明
servo_id 请求 int32 通常 1 = 偏航,2 = 俯仰 具体编号以现场接线为准
angle 请求 int32 0–1000,中位约 500 超出范围时限幅,非角度值
success 响应 bool — 是否成功下发
message 响应 string — 失败原因 / 状态描述
0–1000 是舵机刻度,不是角度。不要把 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 —— 初始化复位

/fd_robot/head/initialize 服务 hh_servo
对指定舵机执行初始化复位。servo_ids 为空时默认初始化 1、2 号舵机(即双轴)。
字段 侧 类型 取值 说明
servo_ids 请求 int32[] 如 [1] / [1,2] / [] 为空时默认 1、2
hh_servo 读取 current_pose 前必须先调用本服务。未初始化的轴姿态为 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 —— 查询舵机是否在线

/fd_robot/head/is_online 服务 hh_servo
无请求字段,返回舵机在线状态。对接 hh_servo 时的第一步自检,用于确认串口与舵机通信正常。
字段 侧 类型 说明
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、电机类型变化,必然误判
发完命令就进入下一步 仅在纯展示场景可接受;联动场景会导致动作叠加、姿态漂移
到面容差手册未给出头颈的明确数值(机械臂侧为约 0.035 rad,头颈未标注)。示例中的 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 舵机刻度。三种单位混在一章里,建议变量名带单位后缀。
up/down 不是方向。head_up_move=俯仰轴(upper),head_down_move=偏航轴(lower)。按字面理解会导致完全相反的运动。
hh_servo 读取姿态前必须先 initialize。未初始化的轴 current_pose 为 NaN;NaN 参与运算不报错但会静默污染下游。
hh_servo 的 current_pose 是推算值。无真实角度反馈,按已下发命令斜坡推算(默认 20 Hz)。不要把它当作高精度反馈用于闭环控制。
限幅是静默的。超出软限位的目标会被截断到边界后继续执行,通常没有任何错误返回。需要"超限即报错"的语义,只能在上位机自行判断。
speed 语义随电机类型而变,非法值静默降级。damiao / gm6020 下是 S 曲线陡度,hh_servo 下是 deg/s;传入非正数或非有限值时按默认 5 处理。跨机型不能照抄参数。
不要与 fd_api 混用。fd_api 的头部接口与本文话题最终落到同一条命令通道,混用会互相抢流、导致动作截断或抖动。选定一套后全程只用那一套。

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 话题两套混用,抢同一条命令通道
头颈 ROS 接口说明 — 天链机器人 © 2026