SDK 二开的最后一步,是从"调通单个接口"走到"写一个完整应用"。本文记录给启元 Q1 写一个指令中控台的全过程:浏览器输入"笑一个",中控台解析关键词、规划动作状态机路径、按 500ms 限频节奏调用 8 个 SDK 服务(语音、表情、灯带、预设动作、速度控制),再把每一步的响应码实时回显到面板。七个演示指令全部实测通过,其中"挥挥手"触发了接口文档里最隐蔽的一个行为:直连切换被 -1001 拒绝后按官方迁移规则 BFS 改道,全程 7.1 秒,本文涉及代码全部开源。 仓库地址:https://atomgit.com/user3401/q1_command_center

一、从调通接口到写完整应用
启元 Q1 的 SDK 提供 16 个 ROS2 服务端和一批状态话题,官方示例把每个接口都演示了一遍:play_emotion.py 播表情、mc_locomotion_velocity.py 控制行走、play_lights.py 驱动灯带。跟着示例走,每个接口都能跑通。
但示例之间是孤立的。真实的应用不会只调一个接口:用户说"笑一个",机器人要做的不止是播一个表情,而是"回一句话、做个表情、亮个灯"的组合。把多个接口组合起来,问题就从一个变成了三个:
- 模式问题:预设动作要求机器人处于
BIPED_WHOLE_BODY_CTRL模式,行走要求BIPED_WALK_RUN。当前模式不满足时怎么过去?直接调SetMcAction可能被拒绝(-1001,无迁移规则)。 - 节奏问题:
SetMcAction有 500ms 限频(-10001),动作切换有物理过渡期。连发指令会撞墙。 - 反馈问题:每一步调用的结果(受理、拒绝、限频重试)如果不回显,出了问题无从排查。
这三个问题正是单接口示例和完整应用之间的距离。本文的指令中控台 q1_command_center 就是为了补上这段路:一条指令进来,编排器负责模式、节奏、反馈三件事,浏览器面板实时可见。

二、环境搭建与部署
整套工程对环境的要求只有一张表:
| 组件 | 版本 | 说明 |
|---|---|---|
| Ubuntu | 22.04.5 | WSL2 亦可 |
| ROS 2 | Humble | |
| Q1 SDK | v1.0.0.0 | 开发者社区下载页获取,aimdk_msgs 需编译 |
| 机器人侧 | q1_simulator 或真机 | 无真机用开源模拟器扮演服务端 |
| setuptools | 58.2.0 | 必须 ≤58.x,新版会破坏 ament 构建 |
| fastapi / uvicorn | 0.162.0 / 0.54.0 | 面板依赖,纯 wheel 安装 |
2.1 前置环境(配置过可直接跳过)
从一台干净的 WSL2 到"能编译 SDK",完整路线:
# 1 ROS2 Humble(apt 源走 TUNA 镜像;密钥从 keyserver 的 80 端口取,
# raw.githubusercontent.com 不可达也不影响)
sudo apt update && sudo apt install -y software-properties-common curl
sudo add-apt-repository -y universe
sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys \
C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654
echo "deb [arch=$(dpkg --print-architecture)] \
https://mirrors.tuna.tsinghua.edu.cn/ros2/ubuntu jammy main" \
| sudo tee /etc/apt/sources.list.d/ros2.list
sudo apt update && sudo apt install -y ros-humble-desktop ros-dev-tools
echo "source /opt/ros/humble/setup.bash" >> ~/.bashrc
# 2 SDK 要求的内核网络参数(DDS 大传输的缓冲上限,官方 FastDDS 配置依赖)
printf "net.core.rmem_max=25165824\nnet.core.wmem_max=25165824\n" \
| sudo tee /etc/sysctl.d/99-primebot-sdk.conf
sudo sysctl --system
# 3 Q1 SDK:开发者社区下载页取 zip,解压后编译消息包(实测 21.7s)
cd ~/workspace && unzip sdk_q1*.zip && mv sdk_q1-v1.0.0.0 sdk_q1
cd ~/workspace/sdk_q1 && colcon build --packages-select aimdk_msgs
# 4 机器人侧替身 q1_simulator(无真机时的仿真服务端,依赖 aimdk_msgs)
cd ~/workspace && git clone https://atomgit.com/user3401/q1_simulator.git
source ~/workspace/sdk_q1/install/setup.bash
cd ~/workspace/q1_simulator && colcon build
第 1 步执行 apt-key 时会有一条"apt-key is deprecated"警告,Humble 时代可忽略。
2.2 部署中控台:三步
部署三步,可直接照做:
# 1. 依赖(注意 setuptools 版本,pip 装东西前先看一眼)
pip3 install --user fastapi uvicorn
pip3 show setuptools | grep Version # 不是 58.x 就降级

# 2. 编译中控台(SDK 的 install 要先进环境,aimdk_msgs 从这来)
source /opt/ros/humble/setup.bash
source ~/workspace/sdk_q1/install/setup.bash
cd q1_command_center && colcon build && source install/setup.bash

# 3. 两个终端分别起机器人侧与中控台
# 终端 1:
source /opt/ros/humble/setup.bash
source ~/workspace/sdk_q1/install/setup.bash
source ~/workspace/q1_simulator/install/setup.bash
ros2 run q1_simulator simulator # 终端 1:机器人侧(真机则跳过)
# 终端 2:
# 同上三条 source,再加
source ~/workspace/q1_command_center/install/setup.bash
ros2 run q1_command_center center # 终端 2:中控台


浏览器打开 http://localhost:8080 就是面板。这里有一个环境细节值得单独说:q1_simulator 与 aimdk_msgs 在两个不同的工作区里,两条 install 都要 source。只 source SDK 那条,ros2 run 会报 Package 'q1_simulator' not found,这是本文实测撞到的坑(根因是 ROS2 的包查找只认 AMENT_PREFIX_PATH 里已有的前缀)。


三、总体设计:一个进程,两条流水线

Q1 指令中控台总体架构:下行指令流(实线)从浏览器经解析、编排到服务调用,上行状态流(虚线)把动作态、输入源、电量、位移实时送回面板。
中控台是一个 ROS2 节点 + 一个 Web 服务的单进程组合:
- 下行(指令流):浏览器
POST /api/command→ FastAPI → 意图解析(关键词匹配commands.json配置表)→ 编排器按步骤序列依次调用 SDK 服务; - 上行(状态流):
GetMcAction动作态、GetCurrentInputSource输入源、Bms 电量订阅、odom→pelvis TF 位移,汇成/api/state,面板每 500ms 轮询刷新。
单进程里跑两个"世界",线程模型值得交代:rclpy 的 executor 在后台线程转,负责收发 DDS 消息;uvicorn 在主线程转,负责 HTTP。两个世界交接的地方有一个容易踩的坑:rclpy 的 Future.result() 不会阻塞等待,直接在工作线程里调它,拿到的多半是 None。正确做法是挂回调、等事件:
def call(self, client, request, timeout=3.0):
"""后台 executor 处理响应,本线程用 Event 等待完成。"""
done = threading.Event()
future = client.call_async(request)
future.add_done_callback(lambda _f: done.set())
if not done.wait(timeout):
return None # 服务超时
return future.result()
独立写小脚本验证接口时则是另一套写法:没有常驻 executor,用 rclpy.spin_until_future_complete(node, future) 补转。两种场景两种写法,混用就会写出"永远等不到响应"的程序(本文实测踩过,详见仓库 notes)。
四、接口清单: 8 个服务与 1 个话题
中控台消费了机器人侧 16 个服务端中的 8 个,外加 1 个速度话题。全部接口的关键字段与坑位如下(版本 v1.0.0.0,写码前逐个用 ros2 interface show 预检过):
| 接口 | 用途 | 关键字段 / 坑 |
|---|---|---|
| GetMcAction | 查当前动作态 | 空闲判断必须读 action_desc,current_action.value=0 不可判空闲 |
| SetMcAction | 切换动作态 | 业务码在 response.state.value:1 受理 / -1001 无迁移规则 / -10001 限频 / -1003 未定义 |
| SetMcPresetMotion | 预设动作 | 需先处 BIPED_WHOLE_BODY_CTRL;3001 招手、3002 握手、3004 挥手、3046 跳舞A |
| SetMcInputSource | 输入源增删 | 1001=ADD、1003=DELETE;先删残留再注册(官方示例同款流程) |
| GetCurrentInputSource | 查速度控制权 | 未受控时 input_source.name 为空串 |
| PlayTts | 语音播报 | 优先级用 TtsPriorityLevel.INTERACTION_L6;成功看 tts_resp.is_success |
| PlayEmotion | 播表情 | Q1 不支持 ID 10,官方示例将其补丁为 3003;成功判定见下文坑位 |
| LedStripCommand | 灯带 | 模式常量挂在 Request 类上(LED_RAINBOW=2、LED_CUSTOM_BREATH=254) |
| /aima/mc/locomotion/velocity | 速度指令 | source 必须与注册的输入源同名;50Hz 心跳;下限 |v|≥0.2 或 =0 |
其中 PlayEmotion 和 LedStripCommand 的成功判定有一个共同的坑:响应外层是 CommonResponse,里面还套着一层 header。写成 resp.header.code 会直接 AttributeError,正确的是 resp.header.header.code == 0(或 resp.header.status.value == 1)。本文第一版就栽在这里,表情服务调用在面板上直接看到异常回显,两分钟定位。
这类字段级细节,接口文档里有但散在各章。写应用前逐个预检一遍服务定义(一条命令的事),比运行时排查快得多。
五、核心实现一:解析层用配置驱动
意图解析刻意做成零 ROS 依赖的纯数据模块:commands.json 一张配置表,读者加指令不改代码。
{
"name": "smile",
"keywords": ["笑一个", "笑一下"],
"summary": "表情 3003 + 蓝色呼吸灯",
"steps": [
{ "type": "tts", "text": "哈哈,被你逗笑了" },
{ "type": "emotion", "id": 3003 },
{ "type": "led", "mode": "custom_breath", "r": 0, "g": 100, "b": 255, "period": 1500 }
]
}
匹配逻辑:精确命中优先,否则取文本中包含的最长关键词(“帮我挥个手吧"命中"挥个手”)。八种步骤类型覆盖了本文用到的全部能力:tts / emotion / led / motion(预设动作)/ walk(速度控制)/ stand / halt / wait。
设计取向说明:这里用的是规则表而不是大模型意图识别,是因为本篇的论证重点是接口编排而不是语义理解。配置表结构里 intent 到 steps 的映射是稳定的,前面接 ASR 结果还是接大模型输出,解析层接口不变——这是留给读者的扩展口。
一个真实的迭代细节:初版关键词表里没有"挥个手",实测"帮我挥个手吧"漏识别——包含匹配的前提是关键词真的出现在文本里,补词后通过。规则表方案的可维护性正体现在这类修补只需改一行 JSON,不用动代码。
六、核心实现二:编排器的三个工程细节
编排器是中控台的核心,SequenceRunner 把每条指令的步骤序列翻译成安全的 SDK 调用。先看全景:一条「挥挥手」从受理到完成,7.1 秒里发生了一次被拒、两次改道跳、四轮过渡确认

6.1 模式保障:直连被拒不报错,BFS 改道
预设动作前必须进入 BIPED_WHOLE_BODY_CTRL。实测的机器人初始态是 BIPED_STAND_DEFAULT,查迁移规则表会发现:这两个状态之间没有直连边。直接调:
code = node.set_action('BIPED_WHOLE_BODY_CTRL')
# 实测返回:-1001(no transition rule from BIPED_STAND_DEFAULT ...)
官方客户端 mc_action_switcher.py 的做法是按规则表 BFS 规划路径。中控台同款处理,区别在于把过程打了出来:
def _ensure_action(self, target, no):
desc, status = self.node.query_action()
if desc == target and status in (MC_RUNNING, MC_IDLE):
self.log('状态', f'已在 {target},跳过切换')
return
code = self._set_action_checked(target, no) # 直连
if code == 1:
self._wait_transition(target, no)
return
if code != CODE_NO_ROUTE: # -1001 才改道
raise _StepError(no, f'切换 {target} 被拒(code={code})')
route = plan_route(desc, target) # BFS 最短路径
self.log('改道', f'直连被拒(-1001),BFS 改道:{" → ".join(route)}')
for hop in route[1:]:
self._set_action_checked(hop, no)
self._wait_transition(hop, no) # 每跳确认过渡完成

编排器的模式保障逻辑:先查态、再直连、被 -1001 拒绝后按官方规则表 BFS 改道,每跳都等过渡完成再走下一步。
实测("挥挥手"指令,从初始态开始):直连被拒 → BFS 给出 STAND_DEFAULT → WALK_RUN → WHOLE_BODY_CTRL → 两跳各等过渡完成 → 预设动作 3004 受理,指令全程 7.1 秒。其中两跳切换各自花费约 2 秒,是动作过渡的物理时间,省不掉,只能等。
把规则表本身画出来,改道的几何一目了然:

McAction 迁移图(核心子图):三个高亮态是指令链路的工作模式;红色虚线是"规则表无此边"的直连(实测 -1001),橙色实线是 BFS 给出的改道路径——改道不是猜测,是按官方迁移规则表算出的最短路径。
6.2 限频合规:主动间隔 550ms,被动重试兜底
SetMcAction 限频 500ms。编排器做了两层:主动层,每次调用前检查距上次的间隔,不足 550ms 就等足;被动层,仍然收到 -10001 时等 600ms 重试(至多 3 次)。主动层让限频几乎不会发生,被动层保证极端情况不崩。
限频行为用仓库里的最小探针(notes/rate_limit_probe.py)实测,两次运行两种结果:
# 运行 1:起始 STAND_DEFAULT,第一次调用真实触发迁移
当前动作态: BIPED_STAND_DEFAULT
第 1 次 SetMcAction(BIPED_WALK_RUN) → 1
立刻第 2 次(BIPED_STAND_DEFAULT) → -10001 ← 500ms 限频,确定性复现
等 700ms 后再切 → -1001 ← 过渡期中连原状态也不是合法起点
过渡完成后再切 → 1
# 运行 2:起始已是 WALK_RUN(第一次为幂等受理,无过渡产生)
第 1 次 → 1(幂等)
立刻第 2 次 → -10001
等 700ms 后切 STAND → 1 ← 无过渡进行中,直连合法
运行 1 的第三行是本文最有价值的意外收获:过渡期里连"切回原状态"都会被拒。根因在状态机语义:TRANSITION 期间当前态还是旧值、迁移目标挂起中,此时任何非规则边的目标(包括旧状态自己)都不是合法起点。这解释了为什么"等过渡完成再走下一步"不是可选的优化,而是正确性的必要条件。
6.3 过渡确认与输入源生命周期
过渡确认的实现是轮询:每 0.5s 调一次 GetMcAction,直到 action_desc == 目标 且 status == RUNNING,超时 20s。真机的物理过渡可达数秒,这个等待省略了就是下一发指令撞墙。
速度控制则完整对齐官方 mc_locomotion_velocity.py 的五段生命周期:DELETE 清残留 → ADD 注册(name=command_center, priority=80, timeout=1000ms)→ 50Hz 心跳发布 → 归零 → DELETE 释放。timeout=1000ms 是看门狗:心跳一旦断供,速度控制权 1 秒内自动回收,所以发布循环里 sleep 的精度直接关系到安全。"过来"指令实测:
21:42:54 状态 | 直连切换 BIPED_WALK_RUN 受理
21:42:57 状态 | BIPED_WALK_RUN 已 RUNNING(过渡完成)
21:42:59 速度 | 0.3 m/s × 2.0s 已执行,期望位移 ≈0.6m
21:42:59 步骤 | (3/3) stand
21:43:01 状态 | BIPED_STAND_DEFAULT 已 RUNNING(过渡完成)
面板上的 odom 位移读数:x=0.615m。期望 0.6m,多出的 1.5cm 来自 50 次循环里 sleep 的调度开销(每步 0.02s 实际略长)。误差 2.5%,对位姿演示足够;较真的读者可以把发布改成 ROS timer 驱动,精度会更好。

「过来」指令的 RViz 画面:模型沿 x 轴前移约 0.6m,与面板 odom 读数一致;行走期间输入源卡片显示 command_center 持有速度控制权,结束后自动释放。
七、运行效果:面板、指令与全链路实测
七个演示指令全部实测通过:
| 说什么 | 干什么 | 实测耗时 |
|---|---|---|
| 你是谁 | TTS 自我介绍 | 0.0s |
| 笑一个 | TTS + 表情 3003 + 蓝色呼吸灯 | 0.0s |
| 挥挥手 | 挥手 3004(含 -1001 自动改道) | 7.1s |
| 握个手 | 握手 3002(已在目标模式则跳过切换) | 2.0s |
| 过来 | 前进 0.3m/s × 2s(实测位移 0.615m)→ 站定 | 6.6s |
| 跳个舞 | 彩虹灯 + 跳舞 3046 + 表情 | 7.6s |
| 停 | 急停:清速度 + 释放输入源 + 回站立 | 2.5s |

左侧四张状态卡实时显示动作态、输入源、电量(BMS 实采 64%)与位移;右侧日志区逐步回显每条指令的解析、改道、限频与完成事件。
面板之外,全部能力等价于 REST 调用,脚本可以直接集成:
curl -X POST localhost:8080/api/command \
-H "Content-Type: application/json" -d '{"text":"笑一个"}'
# {"ok":true,"message":"已受理:smile"}
curl localhost:8080/api/state | python3 -m json.tool
两个防御性行为也实测确认:未配置的指令(如"去洗碗")会被拒绝并提示支持列表;一条指令执行中再发新指令,返回"上一条指令还在执行"(编排器单飞行模式,避免两条指令的状态机操作交错)。
八、总结与边界
把中控台从设计写到实测,对"二开"这件事的体感可以归结为一句话:单个接口是文档问题,接口组合才是工程问题。模式保障、限频合规、过渡确认这三件事,接口文档都写了,但只有真把它们串进一条指令链里,才知道每一条都是必答题。七个指令的实测数据都在上面,仓库里还有完整的执行日志与限频探针,可复现。
边界也要说清楚:本文机器人侧用的开源模拟器在接口语义上与真机对齐(服务名、业务码、状态机、限频、看门狗行为一致),但物理反馈是模拟的(预设动作播放的是关键帧动画,位移是速度积分)。换真机时中控台代码零修改,切换点只有一条启动命令;速度控制的底盘表现(打滑、惯性)需要在真机上重新验证,这是仿真验证与真机部署之间永远存在的一步。
下一个自然的扩展是把 asr_result 话题接进解析层,让"说话"替代"打字",指令链路的其余部分不用动。得益于解析层与执行层的解耦,接入只需十几行——订阅语音识别结果,把文本交给与 Web 面板同一个入口:
# 扩展骨架:语音指令接入(加入 RobotClient 即可,编排链路零修改)
from rclpy.qos import qos_profile_sensor_data
from aimdk_msgs.msg import AsrResult
self.create_subscription(AsrResult, '/aima/agent/asr_result',
self.on_asr, qos_profile_sensor_data)
def on_asr(self, msg):
if msg.is_final and msg.confidence > 0.6: # 只取定稿、置信度达标
ok, message = self.runner.submit(msg.text) # 与 Web 同一入口
self.get_logger().info(f'[ASR] "{msg.text}" → {message}')
本篇的 Q1 SDK 与接口文档来自启元机器人开发者社区,SDK 下载、接口文档与开发者交流都在社区完成,配套的开源模拟器同样来自社区的官方开源组织。
参考资料
- 启元机器人开发者社区(Q1 SDK 与文档下载):https://dev.primebot.com/
- 本文配套仓库 q1_command_center:https://atomgit.com/user3401/q1_command_center
- 官方开源组织(AtomGit,含 q1_simulator):https://atomgit.com/primebot
- ROS 2 Humble 文档(executor 与服务调用模型):https://docs.ros.org/en/humble/
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/user340/article/details/169168459




