feat(hardware): 接入 SocketCAN 与智身模组协议,补温度诊断和指令看门狗

- hardware: 新增 can_bus.h / joint_module_protocol.h,实现使能/失能/运行帧与
  12 字节反馈帧编解码;ReadHardware/WriteHardware 落地,控制字按协议语义映射
  (kErrorReset 按重新使能处理,kGetStatus 无需请求)
- hardware: 温度/驱动状态/电压经 /joint_diagnostics 发布(默认 10 Hz,可配置)
- hardware: 指令看门狗,100 ms 无指令转阻尼,避免上游挂掉后重发最后一帧
- hardware: 限幅与归一化边界分离——[joint_limits] 是唯一实际限幅,加载期校验
  其不超过 [motor.*] 的归一化边界;力矩按峰值扭矩夹紧,kp/kd 越界夹紧并告警
- hardware: require_can 开关,无 CAN 时可只发状态(冒烟测试用)
- remote: 1002/4 关节按 M20 的 16 槽上报(swing→HipY、wheel→Wheel,其余占位 0),
  1002/5 温度改由 /joint_diagnostics 提供
- deploy: 启动时序等订阅者出现再发(ROS1 无 latch,否则整段丢失)
- msgs: 新增 JointDiagnostics
- docs: 智身模组 CANFD 协议整理、待办清单
This commit is contained in:
2026-10-22 11:05:49 +08:00
parent a173383756
commit 594885b9e8
22 changed files with 1457 additions and 162 deletions
+1
View File
@@ -5,6 +5,7 @@
- 上游参考代码:`sdk_deploy/src/M20_sdk_deploy`(ROS2 / ament / drdds) - 上游参考代码:`sdk_deploy/src/M20_sdk_deploy`(ROS2 / ament / drdds)
- 本工程:ROS1 / catkin / Noetic / Ubuntu 20.04 - 本工程:ROS1 / catkin / Noetic / Ubuntu 20.04
- 协议依据:`proto.md`(山猫 M20 basic_server V1.2.0,UDP + JSON) - 协议依据:`proto.md`(山猫 M20 basic_server V1.2.0,UDP + JSON)
- 关节模组 CANFD 协议:[`docs/zhishen_joint_module_canfd.md`](docs/zhishen_joint_module_canfd.md)(智身模组手册 V1.2);文档索引见 [`docs/README.md`](docs/README.md)
> 代码源自 [DeepRoboticsLab/sdk_deploy](https://github.com/DeepRoboticsLab/sdk_deploy) 的 M20 sim2real 部分,遵循 BSD-3-Clause(见 `LICENSE`);已按 ROS1、厂家完整协议与本机器人形态重构。 > 代码源自 [DeepRoboticsLab/sdk_deploy](https://github.com/DeepRoboticsLab/sdk_deploy) 的 M20 sim2real 部分,遵循 BSD-3-Clause(见 `LICENSE`);已按 ROS1、厂家完整协议与本机器人形态重构。
+8
View File
@@ -0,0 +1,8 @@
# 文档索引
| 文档 | 内容 |
| --- | --- |
| [zhishen_joint_module_canfd.md](zhishen_joint_module_canfd.md) | 智身关节模组 CANFD 通信协议(整理自厂家手册 V1.2):通信参数、帧格式、使能/失能/运行/置零指令、状态与故障位、数值编码、各型号阈值 |
| [TODO.md](TODO.md) | 待办清单:每项写清原因与验收方式,已完成项在文末 |
厂家原始资料:《智身关节模组使用手册 V1.2(CANFD 版)》,2026-04-22。
+88
View File
@@ -0,0 +1,88 @@
# TODO
本文件记录**当前会话中确认但本期不做**的事项。按优先级分组,每项尽量写清「为什么」和「验收方式」,
避免下次重新推导。已完成的部分见各包 README。
## P0:安全链路(真机跑 RL 前必须处理)
- [ ] **异常数据接入 deploy 的保护链路**
硬件已在 `/joint_diagnostics` 提供温度/状态字/电压(10 Hz),但 deploy 侧仍是常量:
`robot_interface.h` 的 `GetMotorTemperture()` 返回 30 ℃、`GetDriverStatusWord()` 返回全 1,
所以 `SafeController` 的过温检查和驱动报错检查恒不触发。
做法:`InvictaInterface` 订阅 `/joint_diagnostics` 并覆盖这两个虚函数。
注意:协议 Byte9 的 bit0 是「使能中」,而 `IsDriverStatusNormal()` 判断 `status_word != 1` 报错,
失能但健康的电机回 `0x00` 会误判;建议在接口层归一化成「无故障位就填 1」。
验收:人为把某个关节温度改成 101 ℃,deploy 进入 `safe_control_mode=2` 并切 `kJointDamping`。
- [ ] **诊断超时判断**
诊断话题 10 Hz,`SafeController` 读缓存值。若 hardware 进程死掉,温度会停在最后一帧的「正常」。
需要在接口层记录诊断时间戳,超时(如 1 s)按异常处理。
验收:杀掉 hardware 节点后 deploy 进入安全状态。
- [ ] **异常上报 1002/3 的数据源**
remote 的 `ErrorDoc` 恒为空列表,因为 `MotionState.msg` 没有错误字段。
建议给 `MotionState` 加 `uint32 error_code` + `uint16 error_component`(位域与
`SafeController::robot_error_state_` 对齐,component 用 M20 槽号),remote 再映射成
`0x8001/0x8002/0x8020/0x8012` 等协议错误码。
验收:模拟驱动器故障,App 收到非空 `ErrorList` 且 component 指向正确关节。
- [ ] **IMU 数据源缺失**
`/imu/data` 目前发的是单位四元数、`orientation_covariance[0] = 0`(声称有效),
但协议里没有任何 IMU 字段,硬件桥也没有读 IMU。
按本项目自己的约定,拿不到姿态时应把 `orientation_covariance[0]` 置 `-1`;
改之前要确认 deploy 的 `[rl].require_imu` 会不会因此拒绝进 RL。
## P1:标定与配置一致性(上真机前)
- [ ] **`dir`/`offset` 的施加方未定**
hardware 现在收发的都是**原始电机量**,`dir`/`offset_deg` 由 deploy 的 `InvictaInterface` 施加;
但 hardware 的 README 原设计与 `hardware_bridge.h` 注释写的是「由本节点施加」。
两边都施加时 `dir=±1` 会二次相乘抵消、`offset` 会翻倍(当前 offset 全 0,尚未暴露)。
二选一并同步修改另一侧配置与文档;同时注意 `[joint_limits]` 在两份 TOML 里重复维护。
- [ ] **反馈帧仲裁 ID 需真机确认**
`ReadHardware` 按「反馈使用关节自身 CANID」实现(手册只写了从站 CANID 1~6)。
上电后 `candump can0` 核对;若不是 1~4,需要改匹配逻辑。
- [ ] **`lie_height = 0` 与位置限位矛盾**
`invicta_deploy.toml` 里 `lie_height = 0.00` 反解 `θ = acos(0) = 90° = 1.571 rad`,
超过同文件 `[joint_limits].upper = 1.40`。确认是笔误还是限位需要放宽。
- [ ] **轮的位置限位占位值**
`[joint_limits]` 中轮的 `lower/upper = ±100` 既不在归一化边界内,也不表示限幅
(轮不支持位置控制,手册 §4.4)。加载校验时已跳过,但语义上应改成 ±12.5 或显式标注「仅占位」。
- [ ] **摆臂速度限幅复核**
`vel_limit = 22.40 rad/s` 是额定 15.5 的 1.45 倍,比轮的 100/81.2(1.23 倍)更激进。
如果当初是估的,建议按实测重新定。
- [ ] **位置指令越界目前静默夹紧**
力矩和 kp/kd 越界会告警,位置不会。策略动作顶到限位时可能值得提示一次(注意别刷屏)。
## P2:协议状态上报的剩余占位
- [ ] `MotionStatus` 的 `LinearX / LinearY / Height / RemainMile`:需要里程计、运动学估计与电池数据。
- [ ] `1002/5` 的电池(电压/电量/温度/serial)、CPU、GPS:无数据源。
- [ ] `Charge / HES / Sleep`:没有充电状态、硬急停信号与休眠管理。
- [ ] `HipY` 的正负号未与真实 App/M20 对照:如果 App 只是显示数值没问题,若渲染姿态需要核对。
- [ ] 未实现指令:照明灯、休眠设置/查询、自主充电、速度指令(导航模式)、导航任务(Pro)。
## P3:工程与清理
- [ ] **启动时序的 `kEnable` 可能被采样合并**
deploy 已改为「等订阅者再发」且 hardware 在 `kMotorControl` 未使能时会补发使能,功能上已安全;
但 hardware 循环线程处理 `kErrorReset` 的使能连发会阻塞约 80 ms,期间可能观察不到 `kEnable`。
若要严格逐帧落地,可把连发改成分段非阻塞发送。
- [ ] `[motor.gains].bench_*` 已被指令里的 kp/kd 取代,确认是否删除。
- [ ] 构建镜像 `localhost/custom-ros-noetic` 缺 toml++,目前靠挂载外部安装树 + `CMAKE_PREFIX_PATH`。
可考虑在镜像里装 toml++,或在仓库内 vendored。
- [ ] 真机联调清单:`ip link set canX ... fd on` 权限、`roslaunch` 启动顺序、双路差分驱动方向。
## 已完成(本会话)
- [x] SocketCAN FD 收发 + 智身模组协议编解码(使能/失能/运行、12 字节反馈解码)
- [x] 温度读回 → `/joint_diagnostics`(默认 10 Hz,可配置)→ remote 填 1002/5
- [x] 1002/4 的 8 → 16 槽映射(swing→HipY、wheel→Wheel,HipX/Knee 占位 0)
- [x] 力矩/速度/位置限幅,kp/kd 越界夹紧并告警;限幅与归一化边界分离 + 加载期校验
- [x] 指令看门狗(100 ms 无指令转阻尼)
- [x] 容器内构建 + 冒烟测试;修复启动时序丢失与订阅回调阻塞
+282
View File
@@ -0,0 +1,282 @@
# 智身关节模组 CANFD 通信协议
> 整理自《智身关节模组使用手册 V1.2(CANFD 版)》,智身新创(苏州)智能科技有限公司,2026-04-22。
> 本文覆盖手册第 3 章协议主体,并补充第 1 章阻抗控制模型与第 2 章中影响协议使用的约束。
> 手册 V1.2 的变更:修改 P65、P85MAX-S 转矩阈值;新增 W110-S、P100MAX-S 关节通信阈值。
## 1. 适用范围
- 硬件:支持 CAN FD 的关节模组控制器与主控设备。
- 场景:电机控制、状态监测、参数配置。
- 控制模式:**阻抗控制模式**。电机使能后默认进入该模式,内部最终执行力矩闭环:
```
力矩指令 = Kp × (位置指令 − 反馈位置)
+ Kd × (速度指令 − 反馈速度)
+ 前馈力矩指令
```
该模型无积分项。Kp 越大位置刚度越高;Kd 越大阻尼越强、动态响应越慢。位置、速度、转矩、阻尼四种“模式”只是同一模型在不同参数配置下的表现:
| 控制方式 | 参数配置 | 说明 |
| --- | --- | --- |
| 位置控制 | 给定 `P_cmd`,`Kp > 0`,建议 `Kd > 0` | 围绕目标位置的弹性约束,允许位置偏差;轮毂电机不支持 |
| 速度控制 | 给定 `V_cmd`,`Kd > 0`,`Kp = 0` | Kd 相当于比例系数,存在稳态速度误差 |
| 转矩控制 | 给定 `T_ff`,其余为 0 | 直接输出恒定转矩;负载小于给定转矩时会持续加速 |
| 阻尼控制 | 仅 `Kd > 0`,其余为 0 | 输出与速度反向、大小成比例的阻力,不约束位置 |
## 2. CANFD 通信参数
| 类别 | 参数项 | 参数说明 |
| --- | --- | --- |
| 参数配置 | 波特率(仲裁段) | 1 Mbps |
| 参数配置 | 波特率(数据段) | 5 Mbps |
| 参数配置 | 帧格式 | 标准帧 |
| 参数配置 | 数据长度(主站发送) | 64 字节(可配置) |
| 参数配置 | 数据长度(关节返回) | 12 字节 |
| CANID | 主站 CANID | `0x00` |
| CANID | 从站关节 CANID | `0x0X`(X = 1~6) |
| CANID | 关节出厂默认 CANID | `0x03` |
| 通信速率 | 主机与关节通信速率 | 最大支持 1 kHz |
多字节字段全部为 **小端(低字节在前)**。下文示例中的 `00 80` 即 uint16 的 `0x8000`。
## 3. 帧格式
### 3.1 主机下发帧
一帧最多控制 6 个关节,每个关节占 10 字节,按 CANID 顺序排列:
| Byte | 功能 | 说明 |
| --- | --- | --- |
| Byte0–Byte1 | MSGID | 主机发送,从机原样返回;用于匹配请求帧,不用可填 0 |
| Byte2–Byte11 | CANID=1 指令数据 | 见第 4 节 |
| Byte12–Byte21 | CANID=2 指令数据 | 同上 |
| Byte22–Byte31 | CANID=3 指令数据 | 同上 |
| Byte32–Byte41 | CANID=4 指令数据 | 同上 |
| Byte42–Byte51 | CANID=5 指令数据 | 同上 |
| Byte52–Byte61 | CANID=6 指令数据 | 同上 |
| Byte62–Byte63 | 保留 | 手册未定义 |
每个关节块的起始字节为 `2 + 10 × (CANID − 1)`。
**下发长度**可按最大 CANID 裁剪:
| 最大关节 CANID | 下发最小字节数 |
| ---: | ---: |
| 1 | 12 |
| 2 | 24 |
| 3 | 32 |
| 4 | 48 |
| 5 / 6 | 64 |
- 最大 CANID=3 时下发 Byte0–Byte31(32 字节);最大 CANID=1 时下发 Byte0–Byte11(12 字节)。
- 只控制不连续关节时,仍需按最大 CANID 的长度下发:例如只控 CANID=3,则发 64 字节,仅给 Byte22–Byte31 赋值,其余填 0 或保持上次值;控 CANID=1 和 4 时,给 Byte2–Byte11 与 Byte32–Byte41 赋值。
- CANID=4 时手册要求 48 字节,而实际数据只用到 Byte41(42 字节),以手册要求为准。
### 3.2 关节反馈帧
对所有指令,关节均返回固定的 12 字节状态帧:
| Byte | 功能 | 说明 |
| --- | --- | --- |
| Byte0–Byte1 | MSGID | 等于主机发送值 |
| Byte2–Byte3 | 关节输出端当前位置 | uint16,见第 5 节映射 |
| Byte4–Byte5 | 关节输出端当前速度 | uint16,rad/s |
| Byte6–Byte7 | 关节输出端当前扭矩 | uint16,N·m |
| Byte8 | 电机当前温度 | uint8,`0~255` 对应 `−40~215 °C`(即 `T = raw − 40`) |
| Byte9 | 电机当前状态码 | 位定义见第 6 节 |
| Byte10 | 电机当前电压 | uint8,`0~255` 直接对应 `0~255 V` |
| Byte11 | 预留 | — |
## 4. 指令数据(每关节 10 字节)
### 4.1 电机使能
| Byte | 说明 | 取值 |
| --- | --- | --- |
| Byte2–Byte10 | 固定指令码(9 字节) | `0xFF` × 9 |
| Byte11 | 使能指令码 | `0xFC` |
示例(使能所有关节,MSGID = `0x0100`):
```
Byte0-1 : 00 01
Byte2-11 : FF FF FF FF FF FF FF FF FF FC
Byte12-21 : FF FF FF FF FF FF FF FF FF FC
...
Byte52-61 : FF FF FF FF FF FF FF FF FF FC
Byte62-63 : xx xx
```
### 4.2 电机失能
| Byte | 说明 | 取值 |
| --- | --- | --- |
| Byte2–Byte10 | 固定指令码(9 字节) | `0xFF` × 9 |
| Byte11 | 使能指令码 | `0xFD` |
失能后所有 MOS 管关闭,电机线圈处于断开状态。
### 4.3 电机运行
必须在使能之后发送。运行指令块内容:
| Byte | 说明 | 取值 |
| --- | --- | --- |
| Byte2–Byte3 | 位置指令 | uint16,`0~65535` 映射到 `P_MIN~P_MAX`(rad),`0x8000` = 0 rad |
| Byte4–Byte5 | 速度指令 | uint16,`0~65535` 映射到 `V_MIN~V_MAX`(rad/s),`0x8000` = 0 rad/s |
| Byte6–Byte7 | 刚度 Kp 指令 | uint16,`0~65535` 映射到 `0~KP_MAX`,`0x0000` = 0 |
| Byte8–Byte9 | 阻尼 Kd 指令 | uint16,`0~65535` 映射到 `0~KD_MAX`,`0x0000` = 0 |
| Byte10–Byte11 | 前馈力矩指令 | uint16,`0~65535` 映射到 `T_MIN~T_MAX`(N·m),`0x8000` = 0 N·m |
示例(所有关节 0 位置、0 速度、0 Kp、0 Kd、0 力矩):
```
Byte0-1 : 00 01
Byte2-11 : 00 80 00 80 00 00 00 00 00 80
Byte12-21 : 00 80 00 80 00 00 00 00 00 80
...
Byte52-61 : 00 80 00 80 00 00 00 00 00 80
```
### 4.4 设置当前位置为机械零位
| Byte | 说明 | 取值 |
| --- | --- | --- |
| Byte2–Byte10 | 固定指令码(9 字节) | `0xFF` × 9 |
| Byte11 | 零位指令码 | `0xEF` |
设置成功且关节角度未变化时,当前反馈位置为 0。电机在任意位置的上电角度上报范围为 `(−π, π)` rad。
> 完成机械零点标定后,关节具备单圈绝对位置记忆:断电及人为转动输出端后重新上电,仍可恢复当前单圈绝对位置。**轮毂电机不支持该功能,也不支持位置控制。**
## 5. 数值编码
所有物理量以 uint16 线性映射。手册写作 `0~65535 → MIN~MAX`。
编码(物理量 → 原始值):
```
raw = round((x - MIN) / (MAX - MIN) × 65535)
```
解码(原始值 → 物理量):
```
x = MIN + raw / 65535 × (MAX - MIN)
```
说明:
- 位置、速度、前馈力矩为对称范围,因此 `0x8000`(32768)对应 0。按上式,`raw = 32768` 时 `x ≈ MIN + 0.5000076 × (MAX − MIN)`,与 0 的偏差来自 65536 个码值无法整除;工程上通常直接按 `32768` 作为零点,或用 `(raw − 32768) / 32768 × MAX` 计算对称量。
- Kp、Kd 为非负范围,`0x0000` 对应 0。
- 下发前必须按关节型号的 `P/V/T/KP/KD` 阈值限幅,见第 8 节。
Python 参考实现:
```python
def encode(x, lo, hi):
r = round((x - lo) / (hi - lo) * 65535)
return max(0, min(65535, r))
def decode(raw, lo, hi):
return lo + raw / 65535 * (hi - lo)
def pack_u16(v):
return bytes((v & 0xFF, (v >> 8) & 0xFF)) # 小端
def run_block(p, v, kp, kd, t, lim):
return b"".join((
pack_u16(encode(p, lim["p_min"], lim["p_max"])),
pack_u16(encode(v, lim["v_min"], lim["v_max"])),
pack_u16(encode(kp, 0.0, lim["kp_max"])),
pack_u16(encode(kd, 0.0, lim["kd_max"])),
pack_u16(encode(t, lim["t_min"], lim["t_max"])),
))
```
## 6. 状态码与故障保护
Byte9 状态码按位定义(1 有效):
| bit | 功能 | 触发条件 | 恢复措施 |
| ---: | --- | --- | --- |
| 0 | 电机使能 | 使能为 1,失能为 0 | — |
| 1 | 过压故障 | 母线电压超过阈值 | 重启或重新使能 |
| 2 | 过流故障 | 电流超过阈值 | 重启或重新使能 |
| 3 | 过温故障 | 电机温度超过阈值 | 重启或重新使能;状态位低于 85 °C 自动恢复 |
| 4 | 过速故障 | 输出端速度超过阈值 | 重启或重新使能 |
| 5 | 编码器故障 | 编码器测量失效 | 重启或重新使能 |
| 6 | 欠压故障 / 预驱故障 | 母线电压低于阈值,或检测到预驱故障 | 重启或重新使能 |
| 7 | 预留 | — | — |
发生故障时电机会自动失能,同时对应状态位置 1。主站应持续检查 Byte9 的 bit1–bit6,非零即进入故障处理。
## 7. 关节型号与阈值
| 关节型号 | P_MAX | P_MIN | V_MAX | V_MIN | T_MAX | T_MIN | KP_MAX | KD_MAX |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| P55 | 12.5 | −12.5 | 65 | −65 | 30 | −30 | 500 | 5 |
| P65 | 12.5 | −12.5 | 65 | −65 | 50 | −50 | 500 | 5 |
| P65MAX-S | 12.5 | −12.5 | 65 | −65 | 100 | −100 | 500 | 5 |
| P85MAX-S | 12.5 | −12.5 | 65 | −65 | 300 | −300 | 500 | 50 |
| W150-S | 12.5 | −12.5 | 300 | −300 | 30 | −30 | 500 | 5 |
| W190-S | 12.5 | −12.5 | 200 | −200 | 50 | −50 | 500 | 5 |
| W110-S | 12.5 | −12.5 | 300 | −300 | 30 | −30 | 500 | 5 |
| P100MAX-S | 12.5 | −12.5 | 65 | −65 | 650 | −650 | 500 | 50 |
单位:位置 rad,速度 rad/s,转矩 N·m;Kp、Kd 无量纲。轮毂电机(W 系列)速度阈值明显高于摆臂电机。
### 7.1 额定参数与额定速度(厂家参数页补充)
| 型号 | 额定功率 (W) | 额定扭矩 (N·m) | 峰值扭矩 (N·m) | 推算额定速度 (rad/s) |
| --- | ---: | ---: | ---: | ---: |
| P55 | 78.5 | 6.5 | 21 | 12.1 |
| P65 | 330 | 11.5 | 48 | 28.7 |
| P65MAX-S | 330 | 21.3 | 75 | 15.5 |
| P85MAX-S | 706 | 43 | 180 | 16.4 |
| W150-S | 495 | 3.5 | 11 | 141.4 |
| W190-S | 706 | 8.7 | 28.68 | 81.2 |
额定速度 = 额定功率 / 额定扭矩。它和上表的 `V_MAX` 不是一回事:`V_MAX` 是**通信编码阈值**(决定 uint16 的满量程),额定速度是持续工作能力。本机取的三层关系:
| 关节 | 额定速度 | 人为限幅(`[joint_limits].vel_limit`) | 通信阈值(`[motor.*].v_max`) |
| --- | ---: | ---: | ---: |
| 摆臂 P65MAX-S | 15.5 | 22.4 | 65 |
| 轮 W190-S | 81.2 | 100 | 200 |
人为限幅略高于额定、远低于阈值;实际下发按 `min(阈值, 限幅)` 饱和。注意阈值/额定的比值并不统一(P65MAX-S ≈ 4.2×,W190-S ≈ 2.5×),不能用其中一个推另一个。
## 8. 调试给定值参考
手册给出的典型增益(`P_cmd`, `V_cmd`, `T_ff`, `Kp`, `Kd`):
| 关节型号 | 位置控制 | 速度控制 | 转矩控制 |
| --- | --- | --- | --- |
| P55 | `3.14, 0, 0, 2, 1` | `0, 5, 0, 0, 2` | `0, 0, 1, 0, 0` |
| P65 | `3.14, 0, 0, 2, 1` | `0, 5, 0, 0, 2` | `0, 0, 1, 0, 0` |
| P65MAX-S | `3.14, 0, 0, 3, 1` | `0, 5, 0, 0, 2` | `0, 0, 1, 0, 0` |
| P85MAX-S | `3.14, 0, 0, 5, 1` | `0, 5, 0, 0, 2` | `0, 0, 3, 0, 0` |
| W150-S | — | `0, 5, 0, 0, 0.1` | `0, 0, 0.3, 0, 0` |
| W190-S | — | `0, 5, 0, 0, 0.5` | `0, 0, 0.2, 0, 0` |
阻尼控制只需 `Kd > 0`,其余参数置 0。
使用注意:
1. 目标位置与当前位置差值不宜过大,否则会产生瞬时大转矩和大电流;建议给定值缓慢增加。
2. `Kp`、`Kd` 的比值不宜过大;速度控制时 `Kd` 不宜过大。
3. 速度项启动转矩由速度给定与 `Kd` 决定,突变会产生电流峰值或振荡。
4. 转矩控制下若负载小于给定转矩,转速会一直上升到最大转速甚至失控。
5. 写入参数后需断电重启才能生效;上位机装入参数后需再次“写入参数”。
## 9. 通信时序要点
1. 上电后先发使能(`0xFC`),再发运行指令;失能(`0xFD`)后线圈断开。
2. 需要绝对零位时,在装配完成后发一次置零(`0xEF`),之后反馈位置以该点为 0。
3. 主机发送周期最大支持 1 kHz;实际控制周期按上层需求设定,并保证每帧携带同一 MSGID 以匹配反馈。
4. 一帧可同时控制最多 6 个关节;不同 CANID 的关节共用一个 64 字节帧,按 `2 + 10 × (CANID − 1)` 定位各自数据块。
5. 每个关节独立返回 12 字节状态帧,主站需按 MSGID 关联请求与反馈。
6. 反馈中的位置、速度、转矩均使用与下发相同的 `MIN~MAX` 映射,解码时必须使用对应关节型号的阈值。
7. ⚠️ 编码满量程只能取第 7 节的型号阈值,不能取摇杆/工作范围。台架脚本曾用 `float_to_uint(v, V_MAX=10, ...)` 下发轮速,而 W190-S 的阈值是 ±200:同一 raw 值固件按 ±200 解码,实际轮速被放大 20 倍。工作上限(如 100)应作为独立限幅先夹紧,再按阈值编码。
@@ -121,7 +121,16 @@ protected:
} }
/// 使能时序:disable → error reset → enable;此后运行期恒为 motor control /// 使能时序:disable → error reset → enable;此后运行期恒为 motor control
/// ⚠ ROS1 的发布没有 latch:订阅端连接建立前发出去的消息会被直接丢掉。
/// 若 hardware 节点后启动(或本节点重启),一次性时序帧会整段丢失,
/// 所以这里先等订阅者出现再发。
void SendStartupSequence() { void SendStartupSequence() {
for (int i = 0; i < 200 && cmd_pub_.getNumSubscribers() == 0 && ros::ok(); ++i) {
ros::Duration(0.01).sleep();
}
if (cmd_pub_.getNumSubscribers() == 0) {
std::cout << "[InvictaInterface] 警告:joint_commands 还没有订阅者,启动时序可能丢失" << std::endl;
}
const uint16_t seq[] = {invicta_msgs::kDisable, invicta_msgs::kErrorReset, invicta_msgs::kEnable}; const uint16_t seq[] = {invicta_msgs::kDisable, invicta_msgs::kErrorReset, invicta_msgs::kEnable};
for (uint16_t word : seq) { for (uint16_t word : seq) {
PublishRawCommand(word); PublishRawCommand(word);
+2 -2
View File
@@ -16,8 +16,8 @@ echo "=== 2. 启动 roscore ==="
nohup roscore >/tmp/roscore.log 2>&1 & nohup roscore >/tmp/roscore.log 2>&1 &
sleep 8 sleep 8
echo "=== 3. 启动 invicta_hardware(骨架:发零位状态)===" echo "=== 3. 启动 invicta_hardware(require_can:=false:无 CAN 时只发零位状态)==="
nohup roslaunch invicta_hardware invicta_hardware.launch >/tmp/hw.log 2>&1 & nohup roslaunch invicta_hardware invicta_hardware.launch require_can:=false >/tmp/hw.log 2>&1 &
sleep 4 sleep 4
echo "--- 话题 ---" echo "--- 话题 ---"
+3
View File
@@ -42,6 +42,9 @@ endif()
add_executable(invicta_hardware_node add_executable(invicta_hardware_node
src/invicta_hardware_node.cpp src/invicta_hardware_node.cpp
include/invicta_hardware/can_bus.h
include/invicta_hardware/joint_module_protocol.h
include/invicta_hardware/hardware_bridge.h
${NODE_CONFIG_HEADER} ${NODE_CONFIG_HEADER}
) )
+59 -25
View File
@@ -1,58 +1,92 @@
# invicta_hardware # invicta_hardware
关节/IMU 数据源与关节指令执行端。**当前是骨架**:结构、话题、参数都齐了,只有 `ReadHardware()` / `WriteHardware()` 是待实现的 TODO。 关节/IMU 数据源与关节指令执行端。已接 **SocketCAN + 智身关节模组 CANFD 协议**(协议见
[docs/zhishen_joint_module_canfd.md](../../docs/zhishen_joint_module_canfd.md))。
## 它负责什么 ## 它负责什么
以 `publish_rate_hz`(默认 200 Hz)为一个周期,循环做三件事: 以 `publish_rate_hz`(默认 200 Hz)为一个周期,循环做三件事:
1. `ReadHardware()` — 从电机/IMU 读回状态(TODO) 1. `ReadHardware()` — 非阻塞收各路总线上的 12 字节反馈帧,更新关节位置/速度/力矩、温度、状态码、电压
2. `PublishState()` — 发布 `sensor_msgs/JointState` 与 `sensor_msgs/Imu`(已实现) 2. `PublishState()` — 发 `sensor_msgs/JointState` 与 `sensor_msgs/Imu`
3. `WriteHardware()` — 把最新指令下发到电机(TODO,含长度与有限性校验、控制字处理) 3. `WriteHardware()` — 使能后每周期发 MIT 运行帧(p / v / kp / kd / tau)
上游指令断流时(`command_timeout_s`,默认 100 ms)自动转**阻尼帧**(kp=0、速度/力矩 0、只留 `timeout_*_kd`),
避免 deploy 挂掉/卡住后本节点一直重发最后一帧。指令恢复后自动退出阻尼。
另按 `diagnostics_rate_hz`(默认 10 Hz)发 `invicta_msgs/JointDiagnostics`(温度/状态/电压)。
**反馈不需要主动查询**:模组对每条指令都回一帧状态(手册 §3.2),所以"发了指令就有反馈",读回和写入天然同频。
| 话题 | 方向 | 消息 | | 话题 | 方向 | 消息 |
|---|---|---| |---|---|---|
| `/joint_states` | 发布 | `sensor_msgs/JointState` | | `/joint_states` | 发布 | `sensor_msgs/JointState` |
| `/imu/data` | 发布 | `sensor_msgs/Imu` | | `/imu/data` | 发布 | `sensor_msgs/Imu` |
| `/joint_diagnostics` | 发布 | `invicta_msgs/JointDiagnostics`(默认 10 Hz) |
| `/joint_commands` | 订阅 | `invicta_msgs/JointCommandArray` | | `/joint_commands` | 订阅 | `invicta_msgs/JointCommandArray` |
## 控制字 → CAN 帧
| `control_word` | 实际动作 |
|---|---|
| `kDisable`(1) | 每关节块 `0xFF×9 + 0xFD`,线圈断开,之后不再发运行帧 |
| `kEnable`(2) | `0xFF×9 + 0xFC`,按 `enable_frames`/`enable_interval_s` 连续刷几帧 |
| `kMotorControl`(3) | 发运行块;**未使能时拒发**(手册 §9.1 要求先使能再运行) |
| `kErrorReset`(4) | 本协议没有独立清错指令(手册 §6:故障后靠重新使能/重启),按"重发使能"处理 |
| `kGetStatus`(5) | 不需要发帧:状态随每条指令自动返回 |
同一路总线上最多 4 个关节(CANID 1~4),合成一帧下发:Byte0-1 是 MSGID `0x0100`,每个 CANID 占
10 字节,块起点 `2 + 10×(CANID−1)`,长度按手册裁剪表(本机 4 个 ID → 48 字节)。
## 约定(最重要的部分) ## 约定(最重要的部分)
**硬件约定(方向、零点、单位、CAN 帧格式)在本节点内消化,话题上流动的必须是 policy 约定下的量**:
- 关节角 `[rad]`、角速度 `[rad/s]`、力矩 `[Nm]`,按 `joint_names` 的固定顺序。 - 关节角 `[rad]`、角速度 `[rad/s]`、力矩 `[Nm]`,按 `joint_names` 的固定顺序。
- IMU:姿态四元数(**弧度**)、角速度 `[rad/s]`(机体系)、线加速度 `[m/s²]`(机体系)。 - **边界和限幅是两件事**:`[motor.*]` 的 p/v/t 是**归一化边界**——只决定 CAN 帧里 `0~65535` 映射到多少物理量(编码/解码必须用同一套,手册 §5/§9.6),不是运行限幅;`[joint_limits]` 才是唯一实际生效的**限幅**。下发顺序是「先按限幅夹紧 → 再归一化编码」。加载时会校验限幅不超过边界,超了直接报错,不靠编码饱和兜着。
- ⚠️ 上游 M20 的 `/IMU_DATA` 里 `rpy` 是**度**,不要照搬;本工程统一弧度。四元数能避免单位歧义,这也是选 `sensor_msgs/Imu` 的原因之一。 - **kp/kd 不是策略算的**,来自 deploy 的 `[policy].kp/kd` 与 `[leg.gains]`(用户配置)。越界时本节点会夹到 `[0, kp_max]` / `[0, kd_max]` 并打警告——那种日志说明配置写错了,不要靠饱和兜着。
- 姿态确实拿不到时,必须把 `orientation_covariance[0]` 置 `-1`(而不是发单位四元数假装有效),deploy 侧会据此丢弃该帧。 - 前馈力矩按 `[joint_limits].torque_limit`(默认取厂家峰值扭矩)夹紧,超过也告警。
- **本节点收发的都是原始电机量**(电机坐标系/电机单位),`dir`/`offset_deg` 由 `invicta_deploy` 的 `InvictaInterface` 施加。如果改成由本节点施加,必须同时把 deploy 的 `[legs].dir/offset` 置为恒等——否则 `dir=±1` 会二次相乘抵消、`offset_deg` 会翻倍。
- IMU:姿态四元数(**弧度**)、角速度 `[rad/s]`(机体系)、线加速度 `[m/s²]`(机体系)。⚠️ 目前**还没有 IMU 数据源**,本节点发的是单位四元数,等硬件接上再改。
- 温度取自反馈帧 Byte8:`T = raw − 40`(0~255 ↔ −40~215 ℃);电压 Byte10 直接是伏。
- 状态码 Byte9:bit0 使能,bit1~6 故障(过压/过流/过温/过速/编码器/欠压)。故障位出现时本节点会打 ERROR 日志,并原样放进 `/joint_diagnostics`。
方向/零点换算参考(M20 原车,角度制):`joint_dir = [1,1,-1,1, 1,-1,1,-1, -1,1,-1,1, -1,-1,1,-1]`,`joint_offset_deg = [-25,-131,160,0, 25,-131,160,0, -25,131,-160,0, 25,131,-160,0]`。新机器人用自己的值。 ## 总线前置条件
**同一套换算只能在一边施加**:本节点做了,`invicta_deploy` 的 `calibration/*` 就保持恒等(默认如此);反之则要把参数填上并打开 `auto_calibrate`。 位率由系统配置,本节点只负责 socket 收发:
## 待实现清单 ```bash
sudo ip link set can0 up type can bitrate 1000000 dbitrate 5000000 fd on
sudo ip link set can1 up type can bitrate 1000000 dbitrate 5000000 fd on
```
- [ ] `ReadHardware()`:SocketCAN(或你的总线)读回 16 关节的 position/velocity/effort + IMU,换算到 policy 约定。读失败返回 `false`,保留上一帧值,**不要发 NaN**。 `[motor.can].bitrate/data_bitrate` 只用于出错时的提示,不能透过 SocketCAN 设置。
- [ ] `WriteHardware()`:按 `control_word` 落实使能/失能/清错,把目标角/角速度/前馈力矩/`kp`/`kd` 组装成帧下发;`tau = kp*(q_des-q) + kd*(dq_des-dq) + tau_ff` 由驱动器算还是本节点算,取决于关节模组协议,选一种并写清楚。
- [ ] 上电顺序与安全:使能前的自检、通信超时(多久没指令就卸力)、过温/过流保护。注意 deploy 在 `idle` 状态也会以 200 Hz 发零增益指令,所以「按控制字判断是否使能」比「按有无指令判断」更稳。 ## 无硬件调试开关
- [ ] 关节顺序与 `invicta_deploy` 共用同一份配置里的 `joint_names`,不会不一致。
| 位置 | 默认 | 作用 |
|---|---|---|
| `[hardware].require_can` | `true` | CAN 打不开就退出,避免"以为在控车其实没连上" |
| `roslaunch ... require_can:=false` | — | 无 CAN 也启动:照常发零位状态、缓存指令,但不下发电机指令 |
`src/invicta_deploy/scripts/smoke_test.sh` 用的就是 `require_can:=false`,因此容器/podman 里没有 CAN 也能跑通。
## 参数(本节点自己的 `config/invicta_hardware.toml`) ## 参数(本节点自己的 `config/invicta_hardware.toml`)
| TOML 段 | 内容 | | TOML 段 | 内容 |
|---|---| |---|---|
| `[robot].joint_names` | 关节名与顺序(8 个:`fl/fr/hl/hr × swing/wheel`) | | `[robot].joint_names` | 关节名与顺序(8 个:`fl/fr/hl/hr × swing/wheel`) |
| `[joint_limits]` | 逐关节限位(下发前自检用) | | `[joint_limits]` | 人为限幅:位置 `[rad]`、速度 `[rad/s]`、前馈力矩 `[N·m]`(下发前夹紧) |
| `[[legs]]` ×4 | 每腿 `swing`/`wheel` 的 `dir`、`offset_deg`、`bus`、`canid` | | `[[legs]]` ×4 | 每腿 `swing`/`wheel` 的 `bus`、`canid`(`dir`/`offset_deg` 目前由 deploy 施加) |
| `[motor.joint]` / `[motor.wheel]` | 电机量程:`p/v/t/kp/kd` 上下限(P65MAXS / W190S) | | `[motor.joint]` / `[motor.wheel]` | 归一化边界(0~65535 ↔ 物理量),编码/解码必须一致(P65MAX-S / W190-S) |
| `[motor.can]` | 总线名、仲裁 ID、1M/5M 比特率、上电使能帧数与间隔 | | `[motor.can]` | 总线名、仲裁 ID、1M/5M、上电使能帧数与间隔 |
| `[motor.gains]` | 台架脚本当前增益(进 RL 前以 `invicta_deploy` 的 `[leg.gains]`/策略为准) | | `[motor.gains]` | `bench_*` 仅作参考(实际 kp/kd 由指令给);`timeout_*_kd` 是看门狗阻尼 |
| `[topics]` `[hardware]` | 本节点话题、发布频率、IMU frame | | `[topics]` `[hardware]` | 本节点话题、200 Hz 状态频率、10 Hz 诊断频率、`require_can`、`command_timeout_s` |
启动时打印关节表、每条腿的总线/CANID 与电机量程,便于接线核对。
## 运行 ## 运行
```bash ```bash
roslaunch invicta_hardware invicta_hardware.launch roslaunch invicta_hardware invicta_hardware.launch # 真机
roslaunch invicta_hardware invicta_hardware.launch require_can:=false # 无 CAN 调试
``` ```
骨架状态下会打印告警,并且只发布零位状态、只缓存指令,**不会驱动电机**。 自查:`rostopic echo /joint_diagnostics` 看温度/状态;`candump can0` 看反馈帧的仲裁 ID(当前按
"反馈使用关节自身 CANID" 实现,若模组回帧 ID 不同需改 `ReadHardware` 的匹配)。
@@ -13,13 +13,19 @@ joint_names = [
"hr_swing", "hr_wheel", "hr_swing", "hr_wheel",
] ]
# ------------------------------------------------------------------ 关节限位 # ------------------------------------------------------------------ 关节限位(唯一实际生效的限幅)
# 顺序与 joint_names 一致;轮关节的 lower/upper 填 ±100 表示不限(用 vel_limit 约束) # 顺序与 joint_names 一致。这里才是「限幅」:机械/使用上的可用范围,下发前按它夹紧。
# 本节点实现 I/O 时用它做下发前的自检 # 与 [motor.*] 的关系:那里是「归一化边界」,只决定 0~65535 映射到多少物理量,不是限幅。
# 加载时会校验本节限幅不超过归一化边界;超了直接报错,不靠编码饱和悄悄压回去。
# 轮关节:轮毂不支持位置控制(手册 §4.4),lower/upper 是占位值,只用 vel_limit;
# 轮 vel_limit = 100 [rad/s] 是人为限幅:略高于额定速度(额定功率/扭矩推得 81.2 rad/s)。
[joint_limits] [joint_limits]
lower = [-1.40, -100.0, -1.40, -100.0, -1.40, -100.0, -1.40, -100.0] # [rad] lower = [-1.40, -100.0, -1.40, -100.0, -1.40, -100.0, -1.40, -100.0] # [rad]
upper = [ 1.40, 100.0, 1.40, 100.0, 1.40, 100.0, 1.40, 100.0] # [rad] upper = [ 1.40, 100.0, 1.40, 100.0, 1.40, 100.0, 1.40, 100.0] # [rad]
vel_limit = [ 22.40, 100.0, 22.40, 100.0, 22.40, 100.0, 22.40, 100.0] # [rad/s] vel_limit = [ 22.40, 100.0, 22.40, 100.0, 22.40, 100.0, 22.40, 100.0] # [rad/s]
# 前馈力矩限幅 [N·m]:取厂家参数页的峰值扭矩(摆臂 P65MAX-S 75、轮 W190-S 28.68),
# 比驱动器 T_MAX(100 / 50)更保守。超了就夹紧并告警。
torque_limit = [75.0, 28.68, 75.0, 28.68, 75.0, 28.68, 75.0, 28.68]
# ------------------------------------------------------------------ 每腿的硬件标定与总线 # ------------------------------------------------------------------ 每腿的硬件标定与总线
# 指令换算:q_motor = (q_model − offset_deg) · dir;反馈反向。 # 指令换算:q_motor = (q_model − offset_deg) · dir;反馈反向。
@@ -56,7 +62,10 @@ swing = { dir = -1.0, offset_deg = 0.0, bus = "can1", canid = 2 }
wheel = { dir = -1.0, offset_deg = 0.0, bus = "can1", canid = 4 } wheel = { dir = -1.0, offset_deg = 0.0, bus = "can1", canid = 4 }
# ------------------------------------------------------------------ 电机与总线 # ------------------------------------------------------------------ 电机与总线
# 数值取自当前 CANFD 台架脚本(遥控 → can0/can1 双路),实现 ReadHardware/WriteHardware 时按此驱动 # p/v/t/kp/kd 是「归一化边界」(厂家手册 V1.2 §7,见 docs/zhishen_joint_module_canfd.md):
# 它只决定 CAN 帧里 0~65535 映射到多少物理量,编码与解码必须用同一套(§5/§9.6),
# 不是运行限幅——限幅在 [joint_limits]。必须与模组实际配置一致。
# CAN 参数(总线名/仲裁 ID/波特率/使能帧)取自台架脚本。
[motor.joint] # 摆臂关节电机 [motor.joint] # 摆臂关节电机
model = "P65MAXS" model = "P65MAXS"
p_max = 12.5 # [rad] p_max = 12.5 # [rad]
@@ -72,8 +81,10 @@ kd_max = 5.0
model = "W190S" model = "W190S"
p_max = 12.5 p_max = 12.5
p_min = -12.5 p_min = -12.5
v_max = 10.0 # [rad/s] v_max = 200.0 # [rad/s] 手册 §7 的 W190-S 阈值,编码/解码满量程
v_min = -10.0 v_min = -200.0 # 注意:这不是工作上限(工作上限是 [joint_limits] 的 100)。
# 台架脚本曾把摇杆范围 ±10 当满量程,换算后轮速被放大 20×,
# 属用法错误,不要照搬
t_max = 50.0 t_max = 50.0
t_min = -50.0 t_min = -50.0
kp_max = 500.0 kp_max = 500.0
@@ -91,14 +102,31 @@ enable_interval_s = 0.02
bench_joint_kp = 150.0 bench_joint_kp = 150.0
bench_joint_kd = 2.0 bench_joint_kd = 2.0
bench_wheel_kd = 0.5 bench_wheel_kd = 0.5
# 指令看门狗触发后的阻尼(kp=0、给定速度 0,只留 Kd 抵抗外力)
timeout_joint_kd = 4.0
timeout_wheel_kd = 1.0
# ------------------------------------------------------------------ 本节点的话题与节奏 # ------------------------------------------------------------------ 本节点的话题与节奏
[topics] [topics]
joint_states = "joint_states" # 发布 sensor_msgs/JointState joint_states = "joint_states" # 发布 sensor_msgs/JointState
imu = "imu/data" # 发布 sensor_msgs/Imu imu = "imu/data" # 发布 sensor_msgs/Imu
joint_commands = "joint_commands" # 订阅 invicta_msgs/JointCommandArray joint_commands = "joint_commands" # 订阅 invicta_msgs/JointCommandArray
joint_diagnostics = "joint_diagnostics" # 发布 invicta_msgs/JointDiagnostics(温度/状态/电压)
[hardware] [hardware]
publish_rate_hz = 200.0 # 关节/IMU 状态发布频率(RL 侧控制环 200 Hz) publish_rate_hz = 200.0 # 关节/IMU 状态发布频率(RL 侧控制环 200 Hz)
diagnostics_rate_hz = 10.0 # 诊断量频率:CAN 反馈每条指令都有,但话题按低频发
imu_frame_id = "base_link" imu_frame_id = "base_link"
command_queue = 1 # 指令只取最新一帧 command_queue = 1 # 指令只取最新一帧
# true:CAN 打不开就退出(真机默认,避免以为在控车其实没连上)
# false:没有 CAN 也启动,只发零位状态与指令缓存(调试 / 无硬件冒烟测试)
# launch 里可用 require_can:=false 覆盖,例如 smoke_test.sh
require_can = true
# 指令看门狗:超过这么久没收到 /joint_commands 就转阻尼(kp=0 + [motor.gains].timeout_*_kd),
# 避免上游进程挂掉后一直重发最后一帧。0 = 关闭。
command_timeout_s = 0.1
# ------------------------------------------------------------------ 总线前置条件(不在本节点里设置)
# 位率必须由系统配置好,本节点只负责 socket 收发:
# sudo ip link set can0 up type can bitrate 1000000 dbitrate 5000000 fd on
# sudo ip link set can1 up type can bitrate 1000000 dbitrate 5000000 fd on
@@ -0,0 +1,176 @@
/**
* @file can_bus.h
* @brief SocketCAN / CANFD 收发封装(Linux 原生接口,无第三方依赖)
*
* 只负责 socket 层的打开、非阻塞收发;帧内编码见 joint_module_protocol.h。
* 比特率由系统配置,本文件不动(需要 root):
*
* sudo ip link set can0 up type can bitrate 1000000 dbitrate 5000000 fd on
* sudo ip link set can1 up type can bitrate 1000000 dbitrate 5000000 fd on
*
* 配置里的 bitrate / data_bitrate 只用于日志提示,不能透过 SocketCAN 设置。
*/
#pragma once
#include <fcntl.h>
#include <linux/can.h>
#include <linux/can/raw.h>
#include <net/if.h>
#include <sys/ioctl.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#include <string>
#include <utility>
#include <vector>
namespace invicta_hardware {
/// 与 canfd_frame 同形的载体,让上层不必直接依赖 linux/can.h
struct CanFrame {
uint32_t id = 0; ///< 仲裁 ID(标准帧,不含标志位)
uint8_t len = 0; ///< 数据长度
bool fd = false; ///< 是否 CANFD 帧
uint8_t data[64] = {};
};
class CanBus {
public:
CanBus() = default;
explicit CanBus(std::string name) : name_(std::move(name)) {}
~CanBus() { Close(); }
CanBus(const CanBus&) = delete;
CanBus& operator=(const CanBus&) = delete;
CanBus(CanBus&& other) noexcept : fd_(other.fd_), name_(std::move(other.name_)) { other.fd_ = -1; }
CanBus& operator=(CanBus&& other) noexcept {
if (this != &other) {
Close();
fd_ = other.fd_;
name_ = std::move(other.name_);
other.fd_ = -1;
}
return *this;
}
/// 打开并绑定一路 CAN 接口,开启 FD 帧与非阻塞读
bool Open(const std::string& ifname, std::string* err = nullptr) {
Close();
const auto fail = [&](const std::string& what) {
if (err) *err = what + ": " + std::strerror(errno);
Close();
return false;
};
const int fd = ::socket(PF_CAN, SOCK_RAW, CAN_RAW);
if (fd < 0) return fail("socket(PF_CAN)");
struct ifreq ifr {};
std::strncpy(ifr.ifr_name, ifname.c_str(), IFNAMSIZ - 1);
if (::ioctl(fd, SIOCGIFINDEX, &ifr) < 0) {
::close(fd);
errno = ENODEV;
return fail("ioctl(SIOCGIFINDEX) " + ifname);
}
const int enable_fd = 1;
if (::setsockopt(fd, SOL_CAN_RAW, CAN_RAW_FD_FRAMES, &enable_fd, sizeof(enable_fd)) < 0) {
::close(fd);
return fail("setsockopt(CAN_RAW_FD_FRAMES)");
}
struct sockaddr_can addr {};
addr.can_family = AF_CAN;
addr.can_ifindex = ifr.ifr_ifindex;
if (::bind(fd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr)) < 0) {
::close(fd);
return fail("bind(" + ifname + ")");
}
const int flags = ::fcntl(fd, F_GETFL, 0);
if (::fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) {
::close(fd);
return fail("fcntl(O_NONBLOCK)");
}
fd_ = fd;
name_ = ifname;
return true;
}
void Close() {
if (fd_ >= 0) {
::close(fd_);
fd_ = -1;
}
}
bool is_open() const { return fd_ >= 0; }
const std::string& name() const { return name_; }
/// 发送一帧。fd=true 时走 CANFD(并置 BRS,对应 5 Mbps 数据段)
bool Send(uint32_t id, const uint8_t* data, uint8_t len, bool fd, std::string* err = nullptr) {
if (fd_ < 0) {
if (err) *err = "bus not open";
return false;
}
if (len > 64) {
if (err) *err = "payload > 64 bytes";
return false;
}
if (fd) {
struct canfd_frame raw {};
raw.can_id = id & CAN_SFF_MASK;
raw.len = len;
raw.flags = CANFD_BRS;
std::memcpy(raw.data, data, len);
if (::write(fd_, &raw, sizeof(raw)) != static_cast<ssize_t>(sizeof(raw))) {
if (err) *err = std::string("write: ") + std::strerror(errno);
return false;
}
} else {
struct can_frame raw {};
raw.can_id = id & CAN_SFF_MASK;
raw.can_dlc = len;
std::memcpy(raw.data, data, len);
if (::write(fd_, &raw, sizeof(raw)) != static_cast<ssize_t>(sizeof(raw))) {
if (err) *err = std::string("write: ") + std::strerror(errno);
return false;
}
}
return true;
}
/// 非阻塞读,最多取 max_frames 帧;返回实际帧数(0 = 当前没数据)
size_t Recv(std::vector<CanFrame>* out, size_t max_frames = 128) {
if (fd_ < 0 || !out) return 0;
size_t count = 0;
while (count < max_frames) {
struct canfd_frame raw {};
const ssize_t got = ::recv(fd_, &raw, CANFD_MTU, MSG_DONTWAIT);
if (got < 0) {
if (errno == EINTR) continue;
break; // EAGAIN/EWOULDBLOCK:这一轮读空了
}
if (got != CAN_MTU && got != CANFD_MTU) continue;
CanFrame frame;
frame.fd = (got == CANFD_MTU);
frame.id = (raw.can_id & CAN_EFF_FLAG) ? (raw.can_id & CAN_EFF_MASK) : (raw.can_id & CAN_SFF_MASK);
frame.len = static_cast<uint8_t>(raw.len > 64 ? 64 : raw.len);
std::memcpy(frame.data, raw.data, frame.len);
out->push_back(frame);
++count;
}
return count;
}
private:
int fd_ = -1;
std::string name_;
};
} // namespace invicta_hardware
@@ -1,19 +1,21 @@
/** /**
* @file hardware_bridge.h * @file hardware_bridge.h
* @brief 关节/IMU 数据源与关节指令执行端(骨架,硬件 I/O 待实现) * @brief 关节/IMU 数据源与关节指令执行端(SocketCAN + 智身模组 CANFD 协议)
* *
* 这个节点是「关节状态发布 + 关节命令接收」合一的那一个: * 循环(publish_rate_hz,默认 200 Hz):
* - 以 publish_rate_hz(默认 200 Hz)向 /joint_states 与 /imu/data 发布状态 * 1. ReadHardware() —— 非阻塞收各路总线上的 12 字节反馈帧,更新关节状态/温度/状态码
* - 订阅 /joint_commands,缓存最新一帧,交给 writeMotors() 下发 * 2. PublishState() —— 发 /joint_states 与 /imu/data
* 3. WriteHardware() —— 按控制字发使能/失能,使能后每周期发 MIT 运行帧
* 另按 diagnostics_rate_hz(默认 10 Hz)发 /joint_diagnostics。
* *
* 约定(重要,见 README): * 反馈不需要主动查询:模组对每条指令都回一帧状态(手册 §3.2),所以本节点
* 硬件约定(关节方向 dir、零点 offset、单位、CAN 帧格式)全部由本节点负责, * 「发了指令就有反馈」,读回与写入天然同频。
* 话题上流动的必须是 policy 约定下的量:关节角 [rad]、角速度 [rad/s]、力矩 [Nm]、
* IMU 姿态为四元数且角度为弧度。这样 invicta_deploy 侧的 calibration 参数保持恒等。
* *
* 当前 ReadHardware()/WriteHardware() 是空的 TODO,节点只会: * 与 invicta_deploy 的分工(重要):
* - 发布全零的关节状态与单位四元数姿态(说明数据源还没接上) * 本节点在话题上收发的都是**原始电机量**(电机坐标系、电机单位),
* - 缓存指令并打印控制字变化,不会真的驱动电机 * dir / offset 由 invicta_deploy 的 InvictaInterface 施加(它已经这么做了)。
* 如果改成由本节点施加,必须同时把 deploy 的 [legs].dir/offset 置为恒等,
* 否则 dir=±1 会二次相乘抵消、offset_deg 会翻倍。
*/ */
#pragma once #pragma once
@@ -29,7 +31,10 @@
#include <thread> #include <thread>
#include <vector> #include <vector>
#include "invicta_hardware/can_bus.h"
#include "invicta_hardware/joint_module_protocol.h"
#include "invicta_msgs/JointCommandArray.h" #include "invicta_msgs/JointCommandArray.h"
#include "invicta_msgs/JointDiagnostics.h"
namespace invicta_hardware { namespace invicta_hardware {
@@ -37,67 +42,91 @@ class HardwareBridge {
public: public:
explicit HardwareBridge(const ros::NodeHandle& pnh); explicit HardwareBridge(const ros::NodeHandle& pnh);
/// 主循环:以 publish_rate_hz 周期跑「读硬件 → 发状态 → 下发指令」,阻塞 /// CAN 总线是否全部打开成功(false 时 main 直接退出)
bool Ready() const { return ready_; }
/// 主循环,阻塞
void Run(); void Run();
void Stop(); void Stop();
private: private:
/// 一路总线:通道名 + 帧长 + CANID → 关节下标
struct BusRuntime {
std::string name;
CanBus can;
int max_canid = 0;
int frame_len = 0;
std::vector<int> canid_to_joint; ///< 下标即 CANID,-1 表示该 ID 未使用
};
void LoadParams(const ros::NodeHandle& pnh); void LoadParams(const ros::NodeHandle& pnh);
bool OpenBuses();
void CommandCallback(const invicta_msgs::JointCommandArray::ConstPtr& msg); void CommandCallback(const invicta_msgs::JointCommandArray::ConstPtr& msg);
/// 发布一帧关节状态与 IMU
void PublishState(); void PublishState();
void PublishDiagnostics();
/// 控制字变化时的处理(使能/失能/清错),后续在 WriteHardware 中落实 /// 控制字变化:使能/失能按语义落成 CAN 帧
void HandleControlWord(uint16_t word); void HandleControlWord(uint16_t word);
/** /// 向所有总线各发若干帧「9×0xFF + code」指令块
* @brief 从电机读回状态 void SendCodeAll(uint8_t code, int frames = 1, double interval_s = 0.0);
*
* TODO(硬件):接 SocketCAN 读回各关节(关节表见 invicta.toml)的
* position[rad] / velocity[rad/s] / effort[Nm]
* 并做 raw → policy 约定的换算(方向、零点、单位)。
* 同时读回 IMU:姿态四元数(弧度)、角速度[rad/s](机体系)、线加速度[m/s²](机体系)。
* 注意 M20 原车 /IMU_DATA 的 rpy 是「度」,这里约定统一用弧度,不要照搬。
* 读失败时返回 false,本帧沿用上一次的值(不要发 NaN)。
*/
bool ReadHardware();
/** /// 指令超时:kp=0、速度 0、只留阻尼,防止上游挂掉后一直重发最后一帧
* @brief 把最新指令下发给电机 bool SendTimeoutFrame();
*
* TODO(硬件):把 policy 约定的量换算成 raw(零点、方向、单位), bool ReadHardware();
* 按 invicta_msgs/control_word.h 的语义设置使能状态,组装 CAN 帧写下去。
* torque = kp*(q_des - q) + kd*(dq_des - dq) + tau_ff 由驱动器或本节点计算,
* 取决于关节模组协议,二选一并写清楚。
*/
bool WriteHardware(); bool WriteHardware();
// ---- 参数 ---- // ---- 参数 ----
std::string joint_state_topic_ = "joint_states"; std::string joint_state_topic_ = "joint_states";
std::string imu_topic_ = "imu/data"; std::string imu_topic_ = "imu/data";
std::string command_topic_ = "joint_commands"; std::string command_topic_ = "joint_commands";
std::string diagnostics_topic_ = "joint_diagnostics";
std::string imu_frame_id_ = "base_link"; std::string imu_frame_id_ = "base_link";
std::vector<std::string> joint_names_; std::vector<std::string> joint_names_;
double publish_rate_hz_ = 200.0; double publish_rate_hz_ = 200.0;
double diagnostics_rate_hz_ = 10.0;
int command_queue_ = 1; int command_queue_ = 1;
/// true:CAN 打不开就退出(真机默认);false:无 CAN 也启动,只发状态(调试/冒烟测试)
bool require_can_ = true;
MotorRange joint_range_, wheel_range_;
// ---- 指令看门狗 ----
double command_timeout_s_ = 0.1;
double timeout_joint_kd_ = 4.0;
double timeout_wheel_kd_ = 1.0;
/// 最近一次收到指令的时刻(steady_clock 纳秒);回调线程写、循环线程读,用原子量避免数据竞争
std::atomic<int64_t> last_cmd_ns_{0};
bool timeout_active_ = false;
// ---- ROS ---- // ---- ROS ----
ros::NodeHandle nh_; ros::NodeHandle nh_;
ros::Publisher joint_state_pub_; ros::Publisher joint_state_pub_;
ros::Publisher imu_pub_; ros::Publisher imu_pub_;
ros::Publisher diagnostics_pub_;
ros::Subscriber command_sub_; ros::Subscriber command_sub_;
// ---- 指令缓存 ---- // ---- 指令缓存 ----
std::mutex cmd_mutex_; std::mutex cmd_mutex_;
invicta_msgs::JointCommandArray latest_cmd_; invicta_msgs::JointCommandArray latest_cmd_;
bool has_cmd_ = false; bool has_cmd_ = false;
uint16_t active_control_word_ = 0; uint16_t applied_control_word_ = 0; ///< 已在循环线程里落地的控制字
std::atomic<bool> enabled_{false}; ///< 是否已使能(未使能不发运行帧)
// ---- 状态缓存(policy 约定)---- // ---- CAN ----
std::mutex can_mutex_; ///< 使能序列(回调线程)与运行帧(循环线程)互斥
std::vector<BusRuntime> buses_;
bool ready_ = false;
// ---- 状态缓存(原始电机量)----
std::vector<double> joint_position_, joint_velocity_, joint_effort_; std::vector<double> joint_position_, joint_velocity_, joint_effort_;
std::vector<double> temperature_, voltage_;
std::vector<uint8_t> driver_status_, last_status_;
std::vector<uint8_t> has_feedback_; ///< 该关节是否收到过反馈(诊断只报有数据的)
double imu_orientation_[4] = {1.0, 0.0, 0.0, 0.0}; ///< w,x,y,z double imu_orientation_[4] = {1.0, 0.0, 0.0, 0.0}; ///< w,x,y,z
double imu_angular_velocity_[3] = {0.0, 0.0, 0.0}; double imu_angular_velocity_[3] = {0.0, 0.0, 0.0};
double imu_linear_acceleration_[3] = {0.0, 0.0, 0.0}; double imu_linear_acceleration_[3] = {0.0, 0.0, 0.0};
@@ -10,6 +10,7 @@
#include <toml++/toml.hpp> #include <toml++/toml.hpp>
#include <algorithm> #include <algorithm>
#include <cmath>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -22,6 +23,7 @@ struct JointSpec {
int leg = -1; int leg = -1;
double lower = 0., upper = 0.; ///< 位置限位 [rad](下发前自检用) double lower = 0., upper = 0.; ///< 位置限位 [rad](下发前自检用)
double vel_limit = 0.; ///< 速度限位 [rad/s] double vel_limit = 0.; ///< 速度限位 [rad/s]
double torque_limit = 0.; ///< 前馈力矩限幅 [N·m](0 = 不额外限制)
double dir = 1.; ///< 硬件方向 ±1 double dir = 1.; ///< 硬件方向 ±1
double offset_deg = 0.; ///< 硬件零点偏置 [deg] double offset_deg = 0.; ///< 硬件零点偏置 [deg]
}; };
@@ -68,9 +70,17 @@ public:
// ---- 本节点话题与频率 ---- // ---- 本节点话题与频率 ----
std::string topic_joint_states = "joint_states", topic_imu = "imu/data"; std::string topic_joint_states = "joint_states", topic_imu = "imu/data";
std::string topic_joint_commands = "joint_commands"; std::string topic_joint_commands = "joint_commands";
std::string topic_joint_diagnostics = "joint_diagnostics";
double publish_rate_hz = 200.0; double publish_rate_hz = 200.0;
double diagnostics_rate_hz = 10.0; ///< 温度/状态/电压发布频率(低频诊断,不进控制路径)
std::string imu_frame_id = "base_link"; std::string imu_frame_id = "base_link";
int command_queue = 1; int command_queue = 1;
bool require_can = true; ///< false = 无 CAN 也启动(调试/冒烟测试)
// ---- 指令看门狗 ----
double command_timeout_s = 0.1; ///< 超过这么久没收到 /joint_commands 就转阻尼;0 = 关闭
double timeout_joint_kd = 4.0; ///< 超时后的摆臂阻尼
double timeout_wheel_kd = 1.0; ///< 超时后的轮阻尼
bool Load(const std::string& path, std::string* error = nullptr); bool Load(const std::string& path, std::string* error = nullptr);
@@ -140,13 +150,15 @@ inline bool HardwareConfig::Load(const std::string& path, std::string* error) {
if (joint_names.empty()) return fail("[robot].joint_names 为空"); if (joint_names.empty()) return fail("[robot].joint_names 为空");
dof = int(joint_names.size()); dof = int(joint_names.size());
std::vector<double> lower, upper, vel_limit; std::vector<double> lower, upper, vel_limit, torque_limit;
const toml::table* limits = detail::Section(tbl, "joint_limits"); const toml::table* limits = detail::Section(tbl, "joint_limits");
detail::GetDoubleArr(limits, "lower", lower); detail::GetDoubleArr(limits, "lower", lower);
detail::GetDoubleArr(limits, "upper", upper); detail::GetDoubleArr(limits, "upper", upper);
detail::GetDoubleArr(limits, "vel_limit", vel_limit); detail::GetDoubleArr(limits, "vel_limit", vel_limit);
if (int(lower.size()) != dof || int(upper.size()) != dof || int(vel_limit.size()) != dof) { detail::GetDoubleArr(limits, "torque_limit", torque_limit);
return fail("[joint_limits] 的 lower/upper/vel_limit 长度必须等于关节数量"); if (int(lower.size()) != dof || int(upper.size()) != dof || int(vel_limit.size()) != dof ||
int(torque_limit.size()) != dof) {
return fail("[joint_limits] 的 lower/upper/vel_limit/torque_limit 长度必须等于关节数量");
} }
joints.assign(size_t(dof), JointSpec{}); joints.assign(size_t(dof), JointSpec{});
for (int i = 0; i < dof; ++i) { for (int i = 0; i < dof; ++i) {
@@ -154,6 +166,7 @@ inline bool HardwareConfig::Load(const std::string& path, std::string* error) {
joints[size_t(i)].lower = lower[size_t(i)]; joints[size_t(i)].lower = lower[size_t(i)];
joints[size_t(i)].upper = upper[size_t(i)]; joints[size_t(i)].upper = upper[size_t(i)];
joints[size_t(i)].vel_limit = vel_limit[size_t(i)]; joints[size_t(i)].vel_limit = vel_limit[size_t(i)];
joints[size_t(i)].torque_limit = torque_limit[size_t(i)];
} }
legs.clear(); legs.clear();
@@ -215,18 +228,46 @@ inline bool HardwareConfig::Load(const std::string& path, std::string* error) {
detail::Get(mg, "bench_joint_kp", bench_joint_kp); detail::Get(mg, "bench_joint_kp", bench_joint_kp);
detail::Get(mg, "bench_joint_kd", bench_joint_kd); detail::Get(mg, "bench_joint_kd", bench_joint_kd);
detail::Get(mg, "bench_wheel_kd", bench_wheel_kd); detail::Get(mg, "bench_wheel_kd", bench_wheel_kd);
detail::Get(mg, "timeout_joint_kd", timeout_joint_kd);
detail::Get(mg, "timeout_wheel_kd", timeout_wheel_kd);
}
// 校验人为限幅落在「归一化边界」内。
// [motor.*] 的 p/v/t 只决定 0~65535 的映射关系,不是限幅;用户限幅超出它说明配置写错
// (或模组阈值被改过),在加载期直接失败,不靠编码饱和兜着。
// 轮关节没有位置控制,lower/upper 是占位值,跳过位置校验。
for (int i = 0; i < dof; ++i) {
const JointSpec& j = joints[size_t(i)];
const MotorSpec& m = j.is_wheel ? motor_wheel : motor_joint;
if (!j.is_wheel && (j.lower < m.p_min || j.upper > m.p_max)) {
return fail("[joint_limits] " + j.name + " 的位置限幅超出归一化边界 [p_min, p_max]");
}
if (j.vel_limit > std::min(std::fabs(m.v_max), std::fabs(m.v_min))) {
return fail("[joint_limits] " + j.name + " 的速度限幅超出归一化边界 [v_min, v_max]");
}
if (j.torque_limit > std::min(std::fabs(m.t_max), std::fabs(m.t_min))) {
return fail("[joint_limits] " + j.name + " 的力矩限幅超出归一化边界 [t_min, t_max]");
}
} }
const toml::table* topics = detail::Section(tbl, "topics"); const toml::table* topics = detail::Section(tbl, "topics");
detail::GetStr(topics, "joint_states", topic_joint_states); detail::GetStr(topics, "joint_states", topic_joint_states);
detail::GetStr(topics, "imu", topic_imu); detail::GetStr(topics, "imu", topic_imu);
detail::GetStr(topics, "joint_commands", topic_joint_commands); detail::GetStr(topics, "joint_commands", topic_joint_commands);
detail::GetStr(topics, "joint_diagnostics", topic_joint_diagnostics);
const toml::table* hw = detail::Section(tbl, "hardware"); const toml::table* hw = detail::Section(tbl, "hardware");
detail::Get(hw, "publish_rate_hz", publish_rate_hz); detail::Get(hw, "publish_rate_hz", publish_rate_hz);
detail::Get(hw, "diagnostics_rate_hz", diagnostics_rate_hz);
detail::GetStr(hw, "imu_frame_id", imu_frame_id); detail::GetStr(hw, "imu_frame_id", imu_frame_id);
detail::Get(hw, "command_queue", command_queue); detail::Get(hw, "command_queue", command_queue);
detail::Get(hw, "command_timeout_s", command_timeout_s);
if (hw) {
if (auto v = (*hw)["require_can"].value<bool>()) require_can = *v;
}
if (publish_rate_hz <= 0.) return fail("[hardware].publish_rate_hz 必须 > 0"); if (publish_rate_hz <= 0.) return fail("[hardware].publish_rate_hz 必须 > 0");
if (diagnostics_rate_hz <= 0.) return fail("[hardware].diagnostics_rate_hz 必须 > 0");
if (command_timeout_s < 0.) return fail("[hardware].command_timeout_s 不能为负");
if (error) error->clear(); if (error) error->clear();
return true; return true;
@@ -0,0 +1,127 @@
/**
* @file joint_module_protocol.h
* @brief 智身关节模组 CANFD 协议的编解码(docs/zhishen_joint_module_canfd.md)
*
* 下发帧:64 字节以内,Byte0-1 是 MSGID,之后每个 CANID 占 10 字节,
* 块起点 = 2 + 10 × (CANID − 1),多字节小端。
* 反馈帧:每个关节独立返回 12 字节(位置/速度/扭矩/温度/状态/电压)。
*
* ⚠ 编码与解码都用「归一化边界」(手册 §7 的 P/V/T/KP/KD);它只决定 0~65535 映射到多少物理量,
* 不是运行限幅。限幅在 [joint_limits] 里先夹紧,再送进 Encode()(见 invicta_hardware/README.md)。
*/
#pragma once
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <cstring>
namespace invicta_hardware {
/// 归一化边界:决定 0~65535 与物理量的映射,不是限幅
struct MotorRange {
double p_min = 0., p_max = 0.;
double v_min = 0., v_max = 0.;
double t_min = 0., t_max = 0.;
double kp_max = 0., kd_max = 0.;
};
constexpr int kBlockBytes = 10; ///< 每个关节在下发帧里占 10 字节
constexpr int kMaxMotorsPerFrame = 6; ///< 一帧最多 6 个关节
constexpr uint16_t kMsgId = 0x0100; ///< 主机 MSGID,从机原样回传
constexpr uint8_t kCodeEnable = 0xFC; ///< 使能(9 字节 0xFF + 本码)
constexpr uint8_t kCodeDisable = 0xFD; ///< 失能,MOS 管全关
constexpr uint8_t kCodeZero = 0xEF; ///< 把当前位置设为机械零位
/// 物理量 → uint16(0~65535 线性映射到 MIN~MAX,手册 §5)
inline uint16_t Encode(double x, double lo, double hi) {
if (hi <= lo) return 0;
if (!std::isfinite(x)) return uint16_t(Encode(0.0, lo, hi));
const double r = std::round((x - lo) / (hi - lo) * 65535.0);
return static_cast<uint16_t>(std::min(65535.0, std::max(0.0, r)));
}
/// uint16 → 物理量(必须与 Encode 用同一套量程)
inline double Decode(uint16_t raw, double lo, double hi) { return lo + double(raw) / 65535.0 * (hi - lo); }
inline uint16_t ReadU16(const uint8_t* p) { return uint16_t(p[0] | (uint16_t(p[1]) << 8)); }
inline void WriteU16(uint8_t* p, uint16_t v) {
p[0] = uint8_t(v & 0xFF);
p[1] = uint8_t(v >> 8);
}
/// 运行块:位置 / 速度 / Kp / Kd / 前馈力矩,各 2 字节小端
inline void PackRunBlock(uint8_t* p, double pos, double vel, double kp, double kd, double tau,
const MotorRange& r) {
WriteU16(p + 0, Encode(pos, r.p_min, r.p_max));
WriteU16(p + 2, Encode(vel, r.v_min, r.v_max));
WriteU16(p + 4, Encode(kp, 0.0, r.kp_max));
WriteU16(p + 6, Encode(kd, 0.0, r.kd_max));
WriteU16(p + 8, Encode(tau, r.t_min, r.t_max));
}
/// 使能/失能/置零块:前 9 字节固定 0xFF,最后 1 字节是指令码
inline void PackCodeBlock(uint8_t* p, uint8_t code) {
std::memset(p, 0xFF, 9);
p[9] = code;
}
/// 12 字节状态帧(手册 §3.2)
struct MotorFeedback {
double position = 0.; ///< [rad]
double velocity = 0.; ///< [rad/s]
double torque = 0.; ///< [N·m]
double temperature = 0.; ///< [℃](raw − 40)
double voltage = 0.; ///< [V](raw 直接是伏)
uint8_t status = 0; ///< bit0 使能,bit1..6 故障
bool enabled() const { return (status & 0x01) != 0; }
bool fault() const { return ((status >> 1) & 0x3F) != 0; }
};
/// 状态码非零故障位的中文名,用于日志
inline const char* FaultName(uint8_t status) {
switch ((status >> 1) & 0x3F) {
case 0x00: return "无";
case 0x01: return "过压";
case 0x02: return "过流";
case 0x04: return "过温";
case 0x08: return "过速";
case 0x10: return "编码器";
case 0x20: return "欠压/预驱";
default: return "组合故障";
}
}
/**
* @brief 解一帧 12 字节反馈
* @param msgid 可选输出:帧内 MSGID(用于和请求配对)
*/
inline bool DecodeFeedback(const uint8_t* d, size_t len, const MotorRange& r, MotorFeedback* out,
uint16_t* msgid = nullptr) {
if (!d || !out || len < 12) return false;
if (msgid) *msgid = ReadU16(d);
out->position = Decode(ReadU16(d + 2), r.p_min, r.p_max);
out->velocity = Decode(ReadU16(d + 4), r.v_min, r.v_max);
out->torque = Decode(ReadU16(d + 6), r.t_min, r.t_max);
out->temperature = double(d[8]) - 40.0;
out->status = d[9];
out->voltage = double(d[10]);
return true;
}
/// 下发长度(手册 §3.1 的裁剪表:按本路最大 CANID 取最小字节数)
inline int FrameLenForMaxCanId(int max_canid) {
switch (max_canid) {
case 0: return 0;
case 1: return 12;
case 2: return 24;
case 3: return 32;
case 4: return 48;
default: return 64;
}
}
} // namespace invicta_hardware
@@ -1,6 +1,9 @@
<launch> <launch>
<!-- 关节/IMU 数据源 + 关节指令执行端(骨架)。参数来自本节点自己的 config/invicta_hardware.toml --> <!-- 关节/IMU 数据源 + 关节指令执行端(SocketCAN)。参数来自本节点自己的 config/invicta_hardware.toml -->
<!-- require_can:=false 用于无 CAN 的调试/冒烟测试:节点照常发状态,但不下发电机指令 -->
<arg name="require_can" default="true" doc="true = CAN 打不开就退出;false = 无 CAN 也启动"/>
<node pkg="invicta_hardware" type="invicta_hardware_node" name="invicta_hardware" output="screen"> <node pkg="invicta_hardware" type="invicta_hardware_node" name="invicta_hardware" output="screen">
<param name="config_file" value="$(find invicta_hardware)/config/invicta_hardware.toml"/> <param name="config_file" value="$(find invicta_hardware)/config/invicta_hardware.toml"/>
<param name="require_can" value="$(arg require_can)"/>
</node> </node>
</launch> </launch>
@@ -1,43 +1,136 @@
/** /**
* @file invicta_hardware_node.cpp * @file invicta_hardware_node.cpp
* @brief 关节/IMU 数据源 + 关节指令执行端(骨架入口) * @brief 关节/IMU 数据源 + 关节指令执行端(SocketCAN,智身模组 CANFD 协议)
*/ */
#include <ros/package.h> #include <ros/package.h>
#include <ros/ros.h> #include <ros/ros.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstring>
#include <iostream>
#include <thread> #include <thread>
#include "invicta_hardware/hardware_config.h"
#include "invicta_hardware/hardware_bridge.h" #include "invicta_hardware/hardware_bridge.h"
#include "invicta_hardware/hardware_config.h"
#include "invicta_msgs/control_word.h" #include "invicta_msgs/control_word.h"
namespace invicta_hardware { namespace invicta_hardware {
namespace {
MotorRange ToRange(const MotorSpec& m) {
MotorRange r;
r.p_min = m.p_min;
r.p_max = m.p_max;
r.v_min = m.v_min;
r.v_max = m.v_max;
r.t_min = m.t_min;
r.t_max = m.t_max;
r.kp_max = m.kp_max;
r.kd_max = m.kd_max;
return r;
}
} // namespace
HardwareBridge::HardwareBridge(const ros::NodeHandle& pnh) : nh_() { HardwareBridge::HardwareBridge(const ros::NodeHandle& pnh) : nh_() {
LoadParams(pnh); LoadParams(pnh);
joint_position_.assign(joint_names_.size(), 0.0);
joint_velocity_.assign(joint_names_.size(), 0.0); const auto& cfg = Config();
joint_effort_.assign(joint_names_.size(), 0.0); const size_t n = joint_names_.size();
joint_position_.assign(n, 0.0);
joint_velocity_.assign(n, 0.0);
joint_effort_.assign(n, 0.0);
temperature_.assign(n, 0.0);
voltage_.assign(n, 0.0);
driver_status_.assign(n, 0);
last_status_.assign(n, 0);
has_feedback_.assign(n, 0);
joint_range_ = ToRange(cfg.motor_joint);
wheel_range_ = ToRange(cfg.motor_wheel);
joint_state_pub_ = nh_.advertise<sensor_msgs::JointState>(joint_state_topic_, 1); joint_state_pub_ = nh_.advertise<sensor_msgs::JointState>(joint_state_topic_, 1);
imu_pub_ = nh_.advertise<sensor_msgs::Imu>(imu_topic_, 1); imu_pub_ = nh_.advertise<sensor_msgs::Imu>(imu_topic_, 1);
diagnostics_pub_ = nh_.advertise<invicta_msgs::JointDiagnostics>(diagnostics_topic_, 1);
command_sub_ = nh_.subscribe(command_topic_, command_queue_, &HardwareBridge::CommandCallback, this); command_sub_ = nh_.subscribe(command_topic_, command_queue_, &HardwareBridge::CommandCallback, this);
ROS_WARN("[invicta_hardware] 这是骨架节点:ReadHardware/WriteHardware 尚未实现,"); ready_ = OpenBuses();
ROS_WARN("[invicta_hardware] 只会发布零位状态并缓存指令,不会驱动电机。实现见 hardware_bridge.h 的 TODO。");
ROS_INFO("[invicta_hardware] %s -> %s/%s,订阅 %s @ %.0f Hz", joint_state_topic_.c_str(), imu_topic_.c_str(), ROS_INFO("[invicta_hardware] %s -> %s/%s,订阅 %s @ %.0f Hz;诊断 %s @ %.0f Hz", joint_state_topic_.c_str(),
imu_frame_id_.c_str(), command_topic_.c_str(), publish_rate_hz_); imu_topic_.c_str(), imu_frame_id_.c_str(), command_topic_.c_str(), publish_rate_hz_,
diagnostics_topic_.c_str(), diagnostics_rate_hz_);
}
bool HardwareBridge::OpenBuses() {
const auto& cfg = Config();
buses_.clear();
if (cfg.can.channels.empty()) {
ROS_ERROR("[invicta_hardware] 配置里没有 CAN 通道([motor.can].channels)");
return !require_can_;
}
for (const auto& name : cfg.can.channels) {
BusRuntime bus;
bus.name = name;
std::string err;
if (!bus.can.Open(name, &err)) {
ROS_ERROR("[invicta_hardware] 打开 %s 失败:%s", name.c_str(), err.c_str());
ROS_ERROR("[invicta_hardware] 请先配置总线:sudo ip link set %s up type can bitrate %.0f dbitrate %.0f fd on",
name.c_str(), cfg.can.bitrate, cfg.can.data_bitrate);
if (require_can_) return false;
ROS_WARN("[invicta_hardware] require_can=false,跳过 %s,只发布状态、不下发电机指令", name.c_str());
continue;
}
// CANID → 关节下标(同一 CANID 在 can0/can1 上各有一份,互不冲突)
for (const auto& leg : cfg.legs) {
if (leg.swing_bus == name) bus.max_canid = std::max(bus.max_canid, leg.swing_canid);
if (leg.wheel_bus == name) bus.max_canid = std::max(bus.max_canid, leg.wheel_canid);
}
if (bus.max_canid <= 0 || bus.max_canid > kMaxMotorsPerFrame) {
ROS_ERROR("[invicta_hardware] %s 上的 CANID 非法(最大 %d,协议要求 1~%d)", name.c_str(), bus.max_canid,
kMaxMotorsPerFrame);
if (require_can_) return false;
continue;
}
bus.canid_to_joint.assign(size_t(bus.max_canid) + 1, -1);
for (const auto& leg : cfg.legs) {
if (leg.swing_bus == name && leg.swing_canid > 0) bus.canid_to_joint[size_t(leg.swing_canid)] = leg.swing_index;
if (leg.wheel_bus == name && leg.wheel_canid > 0) bus.canid_to_joint[size_t(leg.wheel_canid)] = leg.wheel_index;
}
bus.frame_len = FrameLenForMaxCanId(bus.max_canid);
ROS_INFO("[invicta_hardware] %s 已打开(CANID 1~%d,下发 %d 字节)", name.c_str(), bus.max_canid, bus.frame_len);
buses_.push_back(std::move(bus));
}
if (buses_.empty() && require_can_) return false;
return true;
} }
void HardwareBridge::LoadParams(const ros::NodeHandle& pnh) { void HardwareBridge::LoadParams(const ros::NodeHandle& pnh) {
// 参数来自本节点自己的 config/invicta_hardware.toml // 参数来自本节点自己的 config/invicta_hardware.toml
const auto& cfg = invicta_hardware::Config(); const auto& cfg = Config();
joint_state_topic_ = cfg.topic_joint_states; joint_state_topic_ = cfg.topic_joint_states;
imu_topic_ = cfg.topic_imu; imu_topic_ = cfg.topic_imu;
command_topic_ = cfg.topic_joint_commands; command_topic_ = cfg.topic_joint_commands;
diagnostics_topic_ = cfg.topic_joint_diagnostics;
imu_frame_id_ = cfg.imu_frame_id; imu_frame_id_ = cfg.imu_frame_id;
publish_rate_hz_ = cfg.publish_rate_hz; publish_rate_hz_ = cfg.publish_rate_hz;
diagnostics_rate_hz_ = cfg.diagnostics_rate_hz;
command_queue_ = cfg.command_queue; command_queue_ = cfg.command_queue;
require_can_ = cfg.require_can;
command_timeout_s_ = cfg.command_timeout_s;
timeout_joint_kd_ = cfg.timeout_joint_kd;
timeout_wheel_kd_ = cfg.timeout_wheel_kd;
// launch 参数可覆盖配置(冒烟测试/无硬件调试用)
pnh.param<bool>("require_can", require_can_, require_can_);
joint_names_ = cfg.joint_names; // 顺序必须与 invicta_deploy 一致(同一份配置,天然一致) joint_names_ = cfg.joint_names; // 顺序必须与 invicta_deploy 一致(同一份配置,天然一致)
@@ -45,42 +138,279 @@ void HardwareBridge::LoadParams(const ros::NodeHandle& pnh) {
ROS_ERROR("[invicta_hardware] publish_rate_hz 必须 > 0,回退到 200 Hz"); ROS_ERROR("[invicta_hardware] publish_rate_hz 必须 > 0,回退到 200 Hz");
publish_rate_hz_ = 200.0; publish_rate_hz_ = 200.0;
} }
if (diagnostics_rate_hz_ <= 0.) {
ROS_ERROR("[invicta_hardware] diagnostics_rate_hz 必须 > 0,回退到 10 Hz");
diagnostics_rate_hz_ = 10.0;
}
} }
void HardwareBridge::CommandCallback(const invicta_msgs::JointCommandArray::ConstPtr& msg) { void HardwareBridge::CommandCallback(const invicta_msgs::JointCommandArray::ConstPtr& msg) {
// 只缓存,不做任何阻塞 I/O:ROS1 的 spinner 是单线程的,回调里发使能序列(约 80 ms)
// 会把紧随其后的 kEnable/kMotorControl 挤掉。控制字在循环线程的 WriteHardware 里处理。
std::lock_guard<std::mutex> lock(cmd_mutex_); std::lock_guard<std::mutex> lock(cmd_mutex_);
if (msg->control_word != active_control_word_) {
HandleControlWord(msg->control_word);
active_control_word_ = msg->control_word;
}
latest_cmd_ = *msg; latest_cmd_ = *msg;
has_cmd_ = true; has_cmd_ = true;
last_cmd_ns_ = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
} }
// ---------------------------------------------------------------- 控制字 → CAN
void HardwareBridge::HandleControlWord(uint16_t word) { void HardwareBridge::HandleControlWord(uint16_t word) {
switch (word) { switch (word) {
case invicta_msgs::kDisable: case invicta_msgs::kDisable:
ROS_INFO("[invicta_hardware] control_word -> kDisable(1):清使能,电机自由"); SendCodeAll(kCodeDisable, 1);
enabled_ = false;
ROS_INFO("[invicta_hardware] control_word -> kDisable:已发失能(0xFD),线圈断开");
break; break;
case invicta_msgs::kEnable: case invicta_msgs::kEnable:
ROS_INFO("[invicta_hardware] control_word -> kEnable(2):使能并保持当前角"); SendCodeAll(kCodeEnable, Config().can.enable_frames, Config().can.enable_interval_s);
break; enabled_ = true;
case invicta_msgs::kMotorControl: ROS_INFO("[invicta_hardware] control_word -> kEnable:已发使能(0xFC × %d)", Config().can.enable_frames);
ROS_INFO("[invicta_hardware] control_word -> kMotorControl(3):进入 PD+前馈控制");
break; break;
case invicta_msgs::kErrorReset: case invicta_msgs::kErrorReset:
ROS_INFO("[invicta_hardware] control_word -> kErrorReset(4):清驱动错误"); // 协议没有独立的清错指令(手册 §6:故障后靠重新使能或重启恢复)
SendCodeAll(kCodeEnable, Config().can.enable_frames, Config().can.enable_interval_s);
enabled_ = true;
ROS_INFO("[invicta_hardware] control_word -> kErrorReset:本协议无清错指令,按重新使能处理");
break;
case invicta_msgs::kMotorControl:
if (!enabled_) {
// 上游的启动时序可能丢(ROS1 发布无 latch,或本节点比上游后启动)。
// 协议要求先使能再运行,这里补一次使能,而不是永远不发帧。
SendCodeAll(kCodeEnable, Config().can.enable_frames, Config().can.enable_interval_s);
enabled_ = true;
ROS_WARN("[invicta_hardware] 收到 kMotorControl 但未使能:已补发使能序列(上游启动时序可能丢失)");
}
ROS_INFO("[invicta_hardware] control_word -> kMotorControl:进入 kp/kd + 前馈控制");
break; break;
case invicta_msgs::kGetStatus: case invicta_msgs::kGetStatus:
ROS_INFO("[invicta_hardware] control_word -> kGetStatus(5):上报驱动状态"); // 每条指令都会回 12 字节状态帧,无需专门请求
ROS_INFO("[invicta_hardware] control_word -> kGetStatus:状态随每条指令自动返回,无需请求");
break; break;
default: default:
ROS_WARN("[invicta_hardware] 未知 control_word=%u,忽略", word); ROS_WARN("[invicta_hardware] 未知 control_word=%u,忽略", word);
break; break;
} }
// TODO(硬件):把上面这些语义落到电机使能序列上(上电顺序、清错指令、超时保护)。
} }
void HardwareBridge::SendCodeAll(uint8_t code, int frames, double interval_s) {
std::lock_guard<std::mutex> lock(can_mutex_);
for (int f = 0; f < std::max(1, frames); ++f) {
for (auto& bus : buses_) {
if (!bus.can.is_open()) continue;
uint8_t payload[64] = {};
WriteU16(payload, kMsgId);
bool any = false;
for (int canid = 1; canid <= bus.max_canid; ++canid) {
if (bus.canid_to_joint[size_t(canid)] < 0) continue;
PackCodeBlock(payload + 2 + kBlockBytes * (canid - 1), code);
any = true;
}
if (!any) continue;
std::string err;
if (!bus.can.Send(Config().can.arbitration_id, payload, uint8_t(bus.frame_len), true, &err)) {
ROS_WARN_THROTTLE(1.0, "[invicta_hardware] %s 发送控制码 0x%02X 失败: %s", bus.name.c_str(), code,
err.c_str());
}
}
if (f + 1 < std::max(1, frames) && interval_s > 0.) {
std::this_thread::sleep_for(std::chrono::duration<double>(interval_s));
}
}
}
// ---------------------------------------------------------------- 看门狗
bool HardwareBridge::SendTimeoutFrame() {
std::lock_guard<std::mutex> lock(can_mutex_);
for (auto& bus : buses_) {
if (!bus.can.is_open()) continue;
uint8_t payload[64] = {};
WriteU16(payload, kMsgId);
bool any = false;
for (int canid = 1; canid <= bus.max_canid; ++canid) {
const int joint = bus.canid_to_joint[size_t(canid)];
if (joint < 0) continue;
const bool is_wheel = Config().IsWheel(joint);
const MotorRange& range = is_wheel ? wheel_range_ : joint_range_;
const double kd = is_wheel ? timeout_wheel_kd_ : timeout_joint_kd_;
// kp=0、速度与力矩都给 0:只留阻尼抵抗外力,不保持位置
PackRunBlock(payload + 2 + kBlockBytes * (canid - 1), 0.0, 0.0, 0.0, kd, 0.0, range);
any = true;
}
if (!any) continue;
std::string err;
if (!bus.can.Send(Config().can.arbitration_id, payload, uint8_t(bus.frame_len), true, &err)) {
ROS_WARN_THROTTLE(1.0, "[invicta_hardware] %s 发送阻尼帧失败: %s", bus.name.c_str(), err.c_str());
}
}
return true;
}
// ---------------------------------------------------------------- 读回
bool HardwareBridge::ReadHardware() {
bool updated = false;
std::vector<CanFrame> frames;
for (auto& bus : buses_) {
if (!bus.can.is_open()) continue;
frames.clear();
bus.can.Recv(&frames, 256);
for (const auto& frame : frames) {
const int canid = int(frame.id);
const int joint = (canid > 0 && canid <= bus.max_canid) ? bus.canid_to_joint[size_t(canid)] : -1;
if (joint < 0) {
ROS_WARN_THROTTLE(5.0, "[invicta_hardware] %s 收到未知帧 id=0x%X len=%u,忽略", bus.name.c_str(),
unsigned(frame.id), unsigned(frame.len));
continue;
}
if (frame.len < 12) {
ROS_WARN_THROTTLE(5.0, "[invicta_hardware] %s CANID=%d 反馈只有 %u 字节(协议要求 12)", bus.name.c_str(),
canid, unsigned(frame.len));
continue;
}
MotorFeedback fb;
if (!DecodeFeedback(frame.data, frame.len, Config().IsWheel(joint) ? wheel_range_ : joint_range_, &fb)) {
continue;
}
joint_position_[size_t(joint)] = fb.position;
joint_velocity_[size_t(joint)] = fb.velocity;
joint_effort_[size_t(joint)] = fb.torque;
temperature_[size_t(joint)] = fb.temperature;
voltage_[size_t(joint)] = fb.voltage;
driver_status_[size_t(joint)] = fb.status;
has_feedback_[size_t(joint)] = 1;
if (fb.fault() && last_status_[size_t(joint)] != fb.status) {
ROS_ERROR("[invicta_hardware] %s 故障:%s(status=0x%02X,温度 %.0f ℃)",
joint_names_[size_t(joint)].c_str(), FaultName(fb.status), unsigned(fb.status), fb.temperature);
}
last_status_[size_t(joint)] = fb.status;
updated = true;
}
}
return updated;
}
// ---------------------------------------------------------------- 写
bool HardwareBridge::WriteHardware() {
invicta_msgs::JointCommandArray cmd;
{
std::lock_guard<std::mutex> lock(cmd_mutex_);
if (!has_cmd_) {
if (!warned_no_command_) {
warned_no_command_ = true;
ROS_WARN("[invicta_hardware] 还没收到 %s,不下发任何指令", command_topic_.c_str());
}
return false;
}
cmd = latest_cmd_;
}
// 控制字变化在这里处理(循环线程),不阻塞订阅回调
if (cmd.control_word != applied_control_word_) {
HandleControlWord(cmd.control_word);
applied_control_word_ = cmd.control_word;
}
const size_t n = joint_names_.size();
if (cmd.position.size() != n || cmd.velocity.size() != n || cmd.effort.size() != n || cmd.kp.size() != n ||
cmd.kd.size() != n) {
ROS_WARN_THROTTLE(5.0, "[invicta_hardware] 指令数组长度与 %zu 个关节不匹配,丢弃该帧", n);
return false;
}
for (size_t i = 0; i < n; ++i) {
if (!std::isfinite(cmd.position[i]) || !std::isfinite(cmd.velocity[i]) || !std::isfinite(cmd.effort[i]) ||
!std::isfinite(cmd.kp[i]) || !std::isfinite(cmd.kd[i])) {
ROS_WARN_THROTTLE(1.0, "[invicta_hardware] 指令含非有限值,丢弃该帧");
return false;
}
}
// 协议要求先使能再发运行帧(手册 §9.1)
if (!enabled_) return false;
// 指令看门狗:上游(deploy)挂掉/卡住时不能一直重发最后一帧
if (command_timeout_s_ > 0.) {
const int64_t now_ns = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::steady_clock::now().time_since_epoch())
.count();
const double age = double(now_ns - last_cmd_ns_.load()) / 1e9;
if (age > command_timeout_s_) {
if (!timeout_active_) {
timeout_active_ = true;
ROS_ERROR("[invicta_hardware] %.0f ms 未收到 %s,转阻尼(kp=0, kd=%.1f/%.1f)",
age * 1000.0, command_topic_.c_str(), timeout_joint_kd_, timeout_wheel_kd_);
}
return SendTimeoutFrame();
}
if (timeout_active_) {
timeout_active_ = false;
ROS_WARN("[invicta_hardware] 指令恢复,退出阻尼");
}
}
std::lock_guard<std::mutex> lock(can_mutex_);
for (auto& bus : buses_) {
if (!bus.can.is_open()) continue;
uint8_t payload[64] = {};
WriteU16(payload, kMsgId);
bool any = false;
for (int canid = 1; canid <= bus.max_canid; ++canid) {
const int joint = bus.canid_to_joint[size_t(canid)];
if (joint < 0) continue;
const auto& spec = Config().Joint(joint);
const MotorRange& range = Config().IsWheel(joint) ? wheel_range_ : joint_range_;
const std::string& name = joint_names_[size_t(joint)];
const size_t j = size_t(joint);
// 先按人为限幅夹紧,再交给编码;[motor.*] 只是归一化边界,不参与取最小
const double pos = std::min(std::max(cmd.position[j], spec.lower), spec.upper);
const double vlim = spec.vel_limit > 0. ? spec.vel_limit : std::abs(range.v_max);
const double vel = std::min(std::max(cmd.velocity[j], -vlim), vlim);
const double tlim = spec.torque_limit > 0. ? spec.torque_limit : std::abs(range.t_max);
double tau = cmd.effort[j];
if (std::abs(tau) > tlim) {
ROS_WARN_THROTTLE(1.0, "[invicta_hardware] %s 前馈力矩 %.1f N·m 超过限幅 %.1f,已夹紧", name.c_str(),
tau, tlim);
tau = std::min(std::max(tau, -tlim), tlim);
}
// kp/kd 来自配置([policy].kp/kd、[leg.gains]),不是策略算出来的:
// 越界会被编码器静默饱和,这里显式夹紧并把配置错误报出来
double kp = cmd.kp[j];
if (kp < 0. || kp > range.kp_max) {
ROS_WARN_THROTTLE(1.0, "[invicta_hardware] %s 的 kp=%.1f 超出模组范围 [0, %.1f],已夹紧", name.c_str(),
kp, range.kp_max);
kp = std::min(std::max(kp, 0.0), range.kp_max);
}
double kd = cmd.kd[j];
if (kd < 0. || kd > range.kd_max) {
ROS_WARN_THROTTLE(1.0, "[invicta_hardware] %s 的 kd=%.2f 超出模组范围 [0, %.1f],已夹紧", name.c_str(),
kd, range.kd_max);
kd = std::min(std::max(kd, 0.0), range.kd_max);
}
PackRunBlock(payload + 2 + kBlockBytes * (canid - 1), pos, vel, kp, kd, tau, range);
any = true;
}
if (!any) continue;
std::string err;
if (!bus.can.Send(Config().can.arbitration_id, payload, uint8_t(bus.frame_len), true, &err)) {
ROS_WARN_THROTTLE(1.0, "[invicta_hardware] %s 发送运行帧失败: %s", bus.name.c_str(), err.c_str());
}
}
return true;
}
// ---------------------------------------------------------------- 发布
void HardwareBridge::PublishState() { void HardwareBridge::PublishState() {
sensor_msgs::JointState js; sensor_msgs::JointState js;
js.header.stamp = ros::Time::now(); js.header.stamp = ros::Time::now();
@@ -110,50 +440,40 @@ void HardwareBridge::PublishState() {
imu_pub_.publish(imu); imu_pub_.publish(imu);
} }
bool HardwareBridge::ReadHardware() { void HardwareBridge::PublishDiagnostics() {
// TODO(硬件):SocketCAN 读回关节与 IMU,换算到 policy 约定后写入 invicta_msgs::JointDiagnostics msg;
// joint_position_ / joint_velocity_ / joint_effort_ / imu_* msg.header.stamp = ros::Time::now();
// 读不到就 return false,保留上一帧值。 for (size_t i = 0; i < joint_names_.size(); ++i) {
return false; if (!has_feedback_[i]) continue; // 没反馈的关节不报,避免下游把 0 ℃ 当有效值
msg.name.push_back(joint_names_[i]);
msg.temperature.push_back(temperature_[i]);
msg.status.push_back(driver_status_[i]);
msg.voltage.push_back(voltage_[i]);
} }
if (msg.name.empty()) return;
bool HardwareBridge::WriteHardware() { diagnostics_pub_.publish(msg);
invicta_msgs::JointCommandArray cmd;
{
std::lock_guard<std::mutex> lock(cmd_mutex_);
if (!has_cmd_) {
if (!warned_no_command_) {
warned_no_command_ = true;
ROS_WARN("[invicta_hardware] 还没收到 %s,不下发任何指令", command_topic_.c_str());
}
return false;
}
cmd = latest_cmd_;
}
// 上游 M20 的启停只看控制字,这里也先做长度与有限性检查,避免把坏指令写进电机
const size_t n = joint_names_.size();
if (cmd.position.size() != n || cmd.velocity.size() != n || cmd.effort.size() != n || cmd.kp.size() != n ||
cmd.kd.size() != n) {
ROS_WARN_THROTTLE(5.0, "[invicta_hardware] 指令数组长度与 %zu 个关节不匹配,丢弃该帧", n);
return false;
}
// TODO(硬件):raw = 反换算(零点, 方向, 单位) 后按控制字下发。
return false;
} }
void HardwareBridge::Run() { void HardwareBridge::Run() {
running_ = true; running_ = true;
const auto period = std::chrono::duration_cast<std::chrono::nanoseconds>( const auto period = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::duration<double>(1.0 / publish_rate_hz_)); std::chrono::duration<double>(1.0 / publish_rate_hz_));
const auto diag_period = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::duration<double>(1.0 / diagnostics_rate_hz_));
auto next = std::chrono::steady_clock::now(); auto next = std::chrono::steady_clock::now();
auto next_diag = next;
while (ros::ok() && running_) { while (ros::ok() && running_) {
next += period; next += period;
ReadHardware(); ReadHardware();
PublishState(); PublishState();
WriteHardware(); WriteHardware();
const auto now = std::chrono::steady_clock::now();
if (now >= next_diag) {
next_diag = now + diag_period;
PublishDiagnostics();
}
std::this_thread::sleep_until(next); std::this_thread::sleep_until(next);
} }
} }
@@ -178,13 +498,15 @@ int main(int argc, char** argv) {
std::cout << invicta_hardware::Config().Summary() << std::flush; std::cout << invicta_hardware::Config().Summary() << std::flush;
invicta_hardware::HardwareBridge bridge(pnh); invicta_hardware::HardwareBridge bridge(pnh);
std::thread loop_thread(&invicta_hardware::HardwareBridge::Run, &bridge); if (!bridge.Ready()) {
ROS_FATAL("[invicta_hardware] CAN 初始化失败,退出(无硬件调试可设 require_can:=false)");
return 1;
}
std::thread loop_thread(&invicta_hardware::HardwareBridge::Run, &bridge);
ros::spin(); ros::spin();
bridge.Stop(); bridge.Stop();
if (loop_thread.joinable()) { if (loop_thread.joinable()) loop_thread.join();
loop_thread.join();
}
return 0; return 0;
} }
+1
View File
@@ -9,6 +9,7 @@ find_package(catkin REQUIRED COMPONENTS
add_message_files( add_message_files(
FILES FILES
JointCommandArray.msg JointCommandArray.msg
JointDiagnostics.msg
MotionCommand.msg MotionCommand.msg
MotionState.msg MotionState.msg
) )
+12 -8
View File
@@ -9,12 +9,14 @@
| `/joint_states` | `sensor_msgs/JointState` | **标准** | 关节状态:`name` / `position[rad]` / `velocity[rad/s]` / `effort[Nm]` | | `/joint_states` | `sensor_msgs/JointState` | **标准** | 关节状态:`name` / `position[rad]` / `velocity[rad/s]` / `effort[Nm]` |
| `/imu/data` | `sensor_msgs/Imu` | **标准** | IMU:`orientation`(四元数,rad)、`angular_velocity[rad/s]`、`linear_acceleration[m/s²]` | | `/imu/data` | `sensor_msgs/Imu` | **标准** | IMU:`orientation`(四元数,rad)、`angular_velocity[rad/s]`、`linear_acceleration[m/s²]` |
| `/joint_commands` | `invicta_msgs/JointCommandArray` | **自定义** | 关节指令:目标角/角速度/前馈力矩/`kp`/`kd`/`control_word` | | `/joint_commands` | `invicta_msgs/JointCommandArray` | **自定义** | 关节指令:目标角/角速度/前馈力矩/`kp`/`kd`/`control_word` |
| `/joint_diagnostics` | `invicta_msgs/JointDiagnostics` | **自定义** | 低频诊断:电机温度/驱动状态码/母线电压(默认 10 Hz) |
| `/motion_command` | `invicta_msgs/MotionCommand` | **自定义** | 协议节点 → RL 节点:运动状态/步态/使用模式/轴指令 | | `/motion_command` | `invicta_msgs/MotionCommand` | **自定义** | 协议节点 → RL 节点:运动状态/步态/使用模式/轴指令 |
| `/motion_state` | `invicta_msgs/MotionState` | **自定义** | RL 节点 → 协议节点:实际运动状态/步态等 | | `/motion_state` | `invicta_msgs/MotionState` | **自定义** | RL 节点 → 协议节点:实际运动状态/步态等 |
自定义消息只有 3 条,原因: 自定义消息只有 4 条,原因:
- `JointCommandArray`:标准消息无法表达 `kp`/`kd`/前馈力矩/控制字(`sensor_msgs/JointState` 只有 position/velocity/effort,`trajectory_msgs/JointTrajectory` 也没有增益)。 - `JointCommandArray`:标准消息无法表达 `kp`/`kd`/前馈力矩/控制字(`sensor_msgs/JointState` 只有 position/velocity/effort,`trajectory_msgs/JointTrajectory` 也没有增益)。
- `JointDiagnostics`:`JointState` 没有温度和状态字字段;诊断量也不该塞进 200 Hz 控制路径,所以单独一条低频话题。
- `MotionCommand` / `MotionState`:是节点间的指令/状态接口,字段与厂家协议一一对应,不适合塞进 `std_msgs`。 - `MotionCommand` / `MotionState`:是节点间的指令/状态接口,字段与厂家协议一一对应,不适合塞进 `std_msgs`。
## 共享值域:`include/invicta_msgs/motion_defs.h` ## 共享值域:`include/invicta_msgs/motion_defs.h`
@@ -36,20 +38,22 @@
| M20 | invicta | 变化 | | M20 | invicta | 变化 |
|---|---|---| |---|---|---|
| `/JOINTS_DATA` `drdds/JointsData` | `/joint_states` `sensor_msgs/JointState` | 换标准消息;丢掉 `motion_temp`/`driver_temp`/`status_word`(见下) | | `/JOINTS_DATA` `drdds/JointsData` | `/joint_states` `sensor_msgs/JointState` | 换标准消息;`motion_temp`/`driver_temp`/`status_word` 移到 `/joint_diagnostics`(见下) |
| `/IMU_DATA` `drdds/ImuData` | `/imu/data` `sensor_msgs/Imu` | 换标准消息;`rpy` 由**度**改为四元数 + 弧度 | | `/IMU_DATA` `drdds/ImuData` | `/imu/data` `sensor_msgs/Imu` | 换标准消息;`rpy` 由**度**改为四元数 + 弧度 |
| `/JOINTS_CMD` `drdds/JointsDataCmd` | `/joint_commands` `JointCommandArray` | 控制字重新定义(平台无关) | | `/JOINTS_CMD` `drdds/JointsDataCmd` | `/joint_commands` `JointCommandArray` | 控制字重新定义(平台无关) |
| `/BATTERY_DATA` `drdds/BatteryData` | 无 | RL 层不需要;若做协议状态上报,由 `invicta_hardware` 用标准消息提供 | | `/BATTERY_DATA` `drdds/BatteryData` | 无 | RL 层不需要;若做协议状态上报,由 `invicta_hardware` 用标准消息提供 |
| `drdds/GamepadData` | 无 | 不需要:手柄走 UDP,在 `invicta_remote` 里 | | `drdds/GamepadData` | 无 | 不需要:手柄走 UDP,在 `invicta_remote` 里 |
| — | `/motion_command` / `/motion_state` | 新增:协议节点与 RL 节点之间的接口 | | — | `/motion_command` / `/motion_state` | 新增:协议节点与 RL 节点之间的接口 |
## 被删掉的字段与影响 ## 温度与驱动状态字:`/joint_diagnostics`
`sensor_msgs/JointState` 放不下关节温度与驱动器状态字,相对 M20 少了两条保护: `sensor_msgs/JointState` 放不下关节温度与驱动器状态字,相对 M20 会少两条保护:
| M20 字段 | 用途 | 现在的行为 | | M20 字段 | 用途 | 现在的实现 |
|---|---|---| |---|---|---|
| `motion_temp` | 电机过温 >100 ℃ 告警 | 温度接口返回常量,检查恒不触发 | | `motion_temp` | 电机过温 >100 ℃ 告警 | `JointDiagnostics.temperature`(默认 10 Hz),协议来源是 CAN 反馈帧 Byte8 |
| `status_word` | 驱动器报错判断(`!=1` 即报错) | 状态字接口返回全 1,检查恒不触发 | | `status_word` | 驱动器报错判断(`!=1` 即报错) | `JointDiagnostics.status`(Byte9 原样),invicta_deploy 侧尚未接 |
| — | 母线电压 | `JointDiagnostics.voltage`(Byte10) |
要恢复就加一条**独立低频诊断话题**(例如 1 Hz 的 `JointDiagnostics`),不要塞回 200 Hz 控制路径。协议里的设备状态上报(`1002/5`)需要的温度/电池数据,也应走 `invicta_hardware` → 协议节点这条线。 设计上一律走**独立低频诊断话题**,不塞回 200 Hz 控制路径。协议里的设备状态上报(`1002/5`)需要的
电机温度,也走 `invicta_hardware` → `/joint_diagnostics` → `invicta_remote` 这条线。
+19
View File
@@ -0,0 +1,19 @@
# 关节低频诊断量:温度 / 驱动状态码 / 母线电压。
#
# 为什么不放进 /joint_states:sensor_msgs/JointState 没有温度和状态字字段,
# 而 200 Hz 的控制路径也不该承载诊断量。本话题由 invicta_hardware 以
# [hardware].diagnostics_rate_hz(默认 10 Hz)发布。
#
# 字段来源:智身关节模组 CANFD 12 字节反馈帧(docs/zhishen_joint_module_canfd.md §3.2)
# Byte8 电机温度:raw 0~255 对应 −40~215 ℃
# Byte9 状态码:bit0 使能,bit1~6 故障(过压/过流/过温/过速/编码器/欠压)
# Byte10 母线电压:raw 0~255 直接对应 0~255 V
#
# 数组顺序与 /joint_states 的 name 一致;没有反馈的关节不出现在本消息里。
std_msgs/Header header
string[] name # 关节名,与 /joint_states 顺序一致
float64[] temperature # 电机温度 [℃]
uint8[] status # 驱动状态码(按协议原样上报,位定义见文档 §6)
float64[] voltage # 母线电压 [V]
+16 -3
View File
@@ -36,11 +36,24 @@
| 1101 | 2 | 照明灯 | ❌ `0xE00A`(本平台无灯硬件) | | 1101 | 2 | 照明灯 | ❌ `0xE00A`(本平台无灯硬件) |
| 1101 | 6/7 | 休眠设置/查询 | ❌ `0xE00A` | | 1101 | 6/7 | 休眠设置/查询 | ❌ `0xE00A` |
| 1002 | 6 | 基础状态上报 | ✅ 2 Hz(MotionState/Gait/Charge/HES/ControlUsageMode/Sleep/Version) | | 1002 | 6 | 基础状态上报 | ✅ 2 Hz(MotionState/Gait/Charge/HES/ControlUsageMode/Sleep/Version) |
| 1002 | 4 | 运控状态上报 | ✅ 10 Hz(姿态/OmegaZ + 16 关节角/轮速) | | 1002 | 4 | 运控状态上报 | ✅ 10 Hz(姿态/OmegaZ + 16 槽关节,8 个实关节折叠进 M20 布局,见下) |
| 1002 | 5 | 设备状态上报 | ⚠️ 2 Hz,结构完整但电池/温度/CPU/GPS 为占位 0(等硬件数据) | | 1002 | 5 | 设备状态上报 | ⚠️ 2 Hz,电机温度来自 `/joint_diagnostics`;电池/CPU/GPS 仍为占位 0 |
| 1002 | 3 | 异常上报 | ✅ 2 Hz,当前恒为空列表(暂无错误源) | | 1002 | 3 | 异常上报 | ✅ 2 Hz,当前恒为空列表(暂无错误源) |
| 1003/1004/1007/2101 | — | 导航任务(Pro) | ❌ 未实现 | | 1003/1004/1007/2101 | — | 导航任务(Pro) | ❌ 未实现 |
### 1002/4 的关节:8 → 16 槽
协议(`proto.md` §3.2)固定 16 个关节,每腿 `HipX / HipY / Knee / Wheel`,腿序 前左、前右、后左、后右。本机每腿只有 swing + wheel 两个自由度,因此:
| M20 槽 | 本机来源 |
|---|---|
| `*HipX`(侧摆) | 不存在,恒 **0**(机构无侧摆) |
| `*HipY`(髋俯仰) | `*_swing` 关节角 [rad] |
| `*Knee`(膝) | 不存在,恒 **0**(单连杆,腿笔直) |
| `*Wheel` | `*_wheel` 轮转速 [rad/s] |
`MotorStatus.Joint` 数组与 16 个具名字段用同一套值(非轮为角度、轮为转速)。保留 16 槽而不裁成 8 槽,是为了让同一套 App 不必区分机型;两个恒 0 槽不是缺数据,而是当前机构的正确取值,客户端姿态显示不会异常。映射优先按 `/joint_states` 的关节名解析(`fl_swing`/`fl_wheel` …),无 name 时退回「每腿两个、先 swing 后 wheel」的下标顺序。
未实现项默认回 `0xE00A "Unsupported function"`;若某个 App 把非 0 当致命错误,把参数 `unsupported_as_success` 设为 `true`(只影响这些未实现项,校验类错误码照旧)。 未实现项默认回 `0xE00A "Unsupported function"`;若某个 App 把非 0 当致命错误,把参数 `unsupported_as_success` 设为 `true`(只影响这些未实现项,校验类错误码照旧)。
错误码采用厂家定义(`0xE001`~`0xE00B`),见 `include/invicta_remote/protocol_defs.h`。 错误码采用厂家定义(`0xE001`~`0xE00B`),见 `include/invicta_remote/protocol_defs.h`。
@@ -50,7 +63,7 @@
| TOML 段 | 内容 | | TOML 段 | 内容 |
|---|---| |---|---|
| `[protocol]` | `udp_port`、`heartbeat_timeout_s`、`unsupported_as_success` | | `[protocol]` | `udp_port`、`heartbeat_timeout_s`、`unsupported_as_success` |
| `[topics]` | `motion_command` / `motion_state` / `joint_states` / `imu` 话题名 | | `[topics]` | `motion_command` / `motion_state` / `joint_states` / `imu` / `joint_diagnostics` 话题名 |
| `[telemetry]` | 四路上报的开关与频率(`enabled`、`require_subscription`、`basic_hz`/`motion_hz`/`device_hz`/`error_hz`) | | `[telemetry]` | 四路上报的开关与频率(`enabled`、`require_subscription`、`basic_hz`/`motion_hz`/`device_hz`/`error_hz`) |
launch 只传 `config_file`(默认 `$(find invicta_remote)/config/invicta_remote.toml`)。 launch 只传 `config_file`(默认 `$(find invicta_remote)/config/invicta_remote.toml`)。
@@ -17,6 +17,7 @@ motion_command = "motion_command" # 发布 invicta_msgs/MotionCommand(invic
motion_state = "motion_state" # 订阅 invicta_msgs/MotionState(判前提 + 状态上报) motion_state = "motion_state" # 订阅 invicta_msgs/MotionState(判前提 + 状态上报)
joint_states = "joint_states" # 订阅(1002/4 运控状态上报用) joint_states = "joint_states" # 订阅(1002/4 运控状态上报用)
imu = "imu/data" # 订阅(状态上报用) imu = "imu/data" # 订阅(状态上报用)
joint_diagnostics = "joint_diagnostics" # 订阅(1002/5 设备状态的电机温度用)
# ------------------------------------------------------------------ 状态上报(协议 1002/*) # ------------------------------------------------------------------ 状态上报(协议 1002/*)
[telemetry] [telemetry]
@@ -24,6 +24,7 @@ public:
std::string topic_motion_state = "motion_state"; std::string topic_motion_state = "motion_state";
std::string topic_joint_states = "joint_states"; std::string topic_joint_states = "joint_states";
std::string topic_imu = "imu/data"; std::string topic_imu = "imu/data";
std::string topic_joint_diagnostics = "joint_diagnostics";
// ---- 状态上报(1002/*)---- // ---- 状态上报(1002/*)----
bool telemetry_enabled = true; bool telemetry_enabled = true;
@@ -86,6 +87,7 @@ inline bool RemoteConfig::Load(const std::string& path, std::string* error) {
detail::GetStr(topics, "motion_state", topic_motion_state); detail::GetStr(topics, "motion_state", topic_motion_state);
detail::GetStr(topics, "joint_states", topic_joint_states); detail::GetStr(topics, "joint_states", topic_joint_states);
detail::GetStr(topics, "imu", topic_imu); detail::GetStr(topics, "imu", topic_imu);
detail::GetStr(topics, "joint_diagnostics", topic_joint_diagnostics);
const toml::table* tlm = detail::Section(tbl, "telemetry"); const toml::table* tlm = detail::Section(tbl, "telemetry");
detail::GetBool(tlm, "enabled", telemetry_enabled); detail::GetBool(tlm, "enabled", telemetry_enabled);
@@ -35,6 +35,8 @@
#include <vector> #include <vector>
#include "heartbeat.h" #include "heartbeat.h"
#include "invicta_msgs/JointCommandArray.h"
#include "invicta_msgs/JointDiagnostics.h"
#include "invicta_msgs/MotionCommand.h" #include "invicta_msgs/MotionCommand.h"
#include "invicta_msgs/MotionState.h" #include "invicta_msgs/MotionState.h"
#include "remote_config.h" #include "remote_config.h"
@@ -56,6 +58,7 @@ public:
motion_state_topic_ = cfg_.topic_motion_state; motion_state_topic_ = cfg_.topic_motion_state;
joint_states_topic_ = cfg_.topic_joint_states; joint_states_topic_ = cfg_.topic_joint_states;
imu_topic_ = cfg_.topic_imu; imu_topic_ = cfg_.topic_imu;
joint_diagnostics_topic_ = cfg_.topic_joint_diagnostics;
heartbeat_timeout_s_ = cfg_.heartbeat_timeout_s; heartbeat_timeout_s_ = cfg_.heartbeat_timeout_s;
unsupported_as_success_ = cfg_.unsupported_as_success; unsupported_as_success_ = cfg_.unsupported_as_success;
@@ -71,6 +74,8 @@ public:
motion_state_sub_ = nh_.subscribe(motion_state_topic_, 10, &RemoteNode::MotionStateCallback, this); motion_state_sub_ = nh_.subscribe(motion_state_topic_, 10, &RemoteNode::MotionStateCallback, this);
joint_state_sub_ = nh_.subscribe(joint_states_topic_, 10, &RemoteNode::JointStateCallback, this); joint_state_sub_ = nh_.subscribe(joint_states_topic_, 10, &RemoteNode::JointStateCallback, this);
imu_sub_ = nh_.subscribe(imu_topic_, 10, &RemoteNode::ImuCallback, this); imu_sub_ = nh_.subscribe(imu_topic_, 10, &RemoteNode::ImuCallback, this);
diagnostics_sub_ =
nh_.subscribe(joint_diagnostics_topic_, 10, &RemoteNode::JointDiagnosticsCallback, this);
RegisterHandlers(); RegisterHandlers();
} }
@@ -211,9 +216,18 @@ private:
void JointStateCallback(const sensor_msgs::JointState::ConstPtr& msg) { void JointStateCallback(const sensor_msgs::JointState::ConstPtr& msg) {
std::lock_guard<std::mutex> lock(state_mutex_); std::lock_guard<std::mutex> lock(state_mutex_);
joint_names_ = msg->name;
joint_pos_.assign(msg->position.begin(), msg->position.end()); joint_pos_.assign(msg->position.begin(), msg->position.end());
joint_vel_.assign(msg->velocity.begin(), msg->velocity.end()); joint_vel_.assign(msg->velocity.begin(), msg->velocity.end());
have_joints_ = joint_pos_.size() == 16; // 本机 8 关节(每腿 swing + wheel),不是 M20 的 16;上报时由 FillM20Joints 折叠到 16 槽
have_joints_ = !joint_pos_.empty();
}
void JointDiagnosticsCallback(const invicta_msgs::JointDiagnostics::ConstPtr& msg) {
std::lock_guard<std::mutex> lock(state_mutex_);
diag_names_ = msg->name;
diag_temperature_.assign(msg->temperature.begin(), msg->temperature.end());
diag_status_.assign(msg->status.begin(), msg->status.end());
} }
void ImuCallback(const sensor_msgs::Imu::ConstPtr& msg) { void ImuCallback(const sensor_msgs::Imu::ConstPtr& msg) {
@@ -230,6 +244,77 @@ private:
have_imu_ = true; have_imu_ = true;
} }
// ---------------------------------------------------------------- M20 16 关节布局
// 协议 §3.2 固定 16 槽:每腿 [HipX, HipY, Knee, Wheel],腿序 前左/前右/后左/后右。
// 本机每腿只有 swing + wheel:HipY ← 摆臂角,Wheel ← 轮转速。
// HipX(侧摆)与 Knee(膝)在单连杆腿上不存在,恒为 0——这不是缺数据,而是当前机构
// 「无侧摆、腿笔直」的正确取值,客户端按 16 槽解析时姿态不会异常。
// 保留 16 槽而不是裁成 8 槽,是为了让同一套 App 不必区分机型。
static constexpr int kM20Joints = 16;
static const char* M20JointName(int slot) {
static const char* kNames[kM20Joints] = {
"LeftFrontHipX", "LeftFrontHipY", "LeftFrontKnee", "LeftFrontWheel",
"RightFrontHipX", "RightFrontHipY", "RightFrontKnee", "RightFrontWheel",
"LeftBackHipX", "LeftBackHipY", "LeftBackKnee", "LeftBackWheel",
"RightBackHipX", "RightBackHipY", "RightBackKnee", "RightBackWheel"};
return kNames[slot];
}
/// 关节名 → 腿序号(0 前左 fl、1 前右 fr、2 后左 hl、3 后右 hr);不认识返回 -1
static int LegIndexFromJointName(const std::string& n) {
if (n.compare(0, 2, "fl") == 0) return 0;
if (n.compare(0, 2, "fr") == 0) return 1;
if (n.compare(0, 2, "hl") == 0) return 2;
if (n.compare(0, 2, "hr") == 0) return 3;
return -1;
}
/**
* @brief 一组关节名 → M20 的 16 槽
*
* @param names 关节名(/joint_states 或 /joint_diagnostics 的 name)
* @param value_count 该消息实际带的数据条数(无 name 时按它做下标回退)
* @param slot_index 输出:槽 → names 下标,-1 表示本机没有这个自由度
*
* HipX(侧摆)与 Knee(膝)在单连杆腿上不存在,永远 -1;swing → HipY,wheel → Wheel。
* 优先按 name 解析(fl_swing / fl_wheel …),无 name 时退回「每腿两个、先 swing 后 wheel」。
*/
static void BuildSlotToIndex(const std::vector<std::string>& names, size_t value_count,
int slot_index[kM20Joints]) {
for (int i = 0; i < kM20Joints; ++i) slot_index[i] = -1;
const bool by_name = !names.empty() && names.size() == value_count;
const size_t n = by_name ? names.size() : std::min(value_count, size_t(kM20Joints / 2));
for (size_t k = 0; k < n; ++k) {
int leg = -1;
bool is_wheel = false;
if (by_name) {
leg = LegIndexFromJointName(names[k]);
if (leg < 0) continue;
const bool wheel = names[k].find("wheel") != std::string::npos;
const bool swing = names[k].find("swing") != std::string::npos;
if (!wheel && !swing) continue;
is_wheel = wheel;
} else {
leg = int(k / 2);
is_wheel = (k % 2 == 1);
}
slot_index[leg * 4 + (is_wheel ? 3 : 1)] = int(k);
}
}
/// 16 槽取值:协议里 Joint 数组与具名字段的混合量(角度,轮为 rad/s)
void FillM20Joints(double value[kM20Joints]) const {
for (int i = 0; i < kM20Joints; ++i) value[i] = 0.0;
int slot_index[kM20Joints];
BuildSlotToIndex(joint_names_, std::min(joint_pos_.size(), joint_vel_.size()), slot_index);
for (int slot = 0; slot < kM20Joints; ++slot) {
const int k = slot_index[slot];
if (k < 0) continue;
value[slot] = (slot % 4 == 3) ? joint_vel_[size_t(k)] : joint_pos_[size_t(k)];
}
}
// ---------------------------------------------------------------- 状态上报 // ---------------------------------------------------------------- 状态上报
json BasicDoc(int type, int command) { json BasicDoc(int type, int command) {
std::lock_guard<std::mutex> lock(state_mutex_); std::lock_guard<std::mutex> lock(state_mutex_);
@@ -268,19 +353,14 @@ private:
ms["RemainMile"] = 0.0; // 待电池/里程 ms["RemainMile"] = 0.0; // 待电池/里程
auto& motor = items["MotorStatus"]; auto& motor = items["MotorStatus"];
std::vector<double> joint = joint_pos_; double value[kM20Joints];
std::vector<double> vel = joint_vel_; FillM20Joints(value);
joint.resize(16, 0.0); if (!have_joints_ && std::chrono::steady_clock::now() - start_time_ > std::chrono::seconds(2)) {
vel.resize(16, 0.0); ROS_WARN_THROTTLE(30.0, "[invicta_remote] 尚未收到 %s,1002/4 的关节按 0 上报",
motor["Joint"] = joint; joint_states_topic_.c_str());
static const char* kNames[16] = {
"LeftFrontHipX", "LeftFrontHipY", "LeftFrontKnee", "LeftFrontWheel",
"RightFrontHipX", "RightFrontHipY", "RightFrontKnee", "RightFrontWheel",
"LeftBackHipX", "LeftBackHipY", "LeftBackKnee", "LeftBackWheel",
"RightBackHipX", "RightBackHipY", "RightBackKnee", "RightBackWheel"};
for (int i = 0; i < 16; ++i) {
motor[kNames[i]] = (i % 4 == 3) ? vel[i] : joint[i]; // 轮关节报转速,其余报角度
} }
motor["Joint"] = std::vector<double>(value, value + kM20Joints);
for (int i = 0; i < kM20Joints; ++i) motor[M20JointName(i)] = value[i];
doc["PatrolDevice"]["Type"] = type; doc["PatrolDevice"]["Type"] = type;
doc["PatrolDevice"]["Command"] = command; doc["PatrolDevice"]["Command"] = command;
doc["PatrolDevice"]["Time"] = CurrentDateTime(); doc["PatrolDevice"]["Time"] = CurrentDateTime();
@@ -288,9 +368,10 @@ private:
} }
json DeviceDoc(int type, int command) { json DeviceDoc(int type, int command) {
std::lock_guard<std::mutex> lock(state_mutex_);
json doc; json doc;
json& items = doc["PatrolDevice"]["Items"]; json& items = doc["PatrolDevice"]["Items"];
// 电池 / 温度 / CPU / GPS 需要硬件桥接提供数据,当前用 0 占位(Version 除外) // 电池 / CPU / GPS 还没有数据源,用 0 占位;电机温度来自 /joint_diagnostics
json battery = {{"Voltage", 0.0}, {"BatteryLevel", 0.0}, {"battery_temperature", 0.0}, json battery = {{"Voltage", 0.0}, {"BatteryLevel", 0.0}, {"battery_temperature", 0.0},
{"charge", false}, {"serial", ""}}; {"charge", false}, {"serial", ""}};
items["BatteryList"] = json::array({battery, battery}); items["BatteryList"] = json::array({battery, battery});
@@ -298,9 +379,24 @@ private:
{"BatteryLevelLeft", 0.0}, {"BatteryLevelRight", 0.0}, {"BatteryLevelLeft", 0.0}, {"BatteryLevelRight", 0.0},
{"battery_temperatureLeft", 0.0}, {"battery_temperatureRight", 0.0}, {"battery_temperatureLeft", 0.0}, {"battery_temperatureRight", 0.0},
{"chargeLeft", false}, {"chargeRight", false}}; {"chargeLeft", false}, {"chargeRight", false}};
json zeros16 = json::array(); // 温度按 M20 布局展开:本机 8 个电机落在对应槽(swing → HipY、wheel → Wheel),
for (int i = 0; i < 16; ++i) zeros16.push_back(0.0); // 其余槽没有对应电机填 0;驱动器温度本协议不返回,恒 0。
items["DeviceTemperature"] = {{"Motor", zeros16}, {"Driver", zeros16}}; int slot_index[kM20Joints];
BuildSlotToIndex(diag_names_, diag_temperature_.size(), slot_index);
json motor_temp = json::array();
json driver_temp = json::array();
for (int slot = 0; slot < kM20Joints; ++slot) {
const int k = slot_index[slot];
motor_temp.push_back((k >= 0 && size_t(k) < diag_temperature_.size()) ? diag_temperature_[size_t(k)] : 0.0);
driver_temp.push_back(0.0);
}
items["DeviceTemperature"] = {{"Motor", motor_temp}, {"Driver", driver_temp}};
auto& temps = items["DeviceTemperature"];
for (int slot = 0; slot < kM20Joints; ++slot) {
const std::string base = M20JointName(slot);
temps[base + "Motor"] = motor_temp[slot];
temps[base + "Driver"] = 0.0;
}
items["Led"] = {{"Fill", {{"Front", 0}, {"Back", 0}}}}; items["Led"] = {{"Fill", {{"Front", 0}, {"Back", 0}}}};
items["GPS"] = {{"Latitude", 0.0}, {"Longitude", 0.0}, {"Speed", 0.0}, {"Course", 0.0}, items["GPS"] = {{"Latitude", 0.0}, {"Longitude", 0.0}, {"Speed", 0.0}, {"Course", 0.0},
{"FixQuality", 0}, {"NumSatellites", 0}, {"Altitude", 0.0}, {"FixQuality", 0}, {"NumSatellites", 0}, {"Altitude", 0.0},
@@ -569,6 +665,7 @@ private:
ros::Subscriber motion_state_sub_; ros::Subscriber motion_state_sub_;
ros::Subscriber joint_state_sub_; ros::Subscriber joint_state_sub_;
ros::Subscriber imu_sub_; ros::Subscriber imu_sub_;
ros::Subscriber diagnostics_sub_;
std::unique_ptr<Heartbeat> heartbeat_; std::unique_ptr<Heartbeat> heartbeat_;
std::unique_ptr<UdpServer> udp_server_; std::unique_ptr<UdpServer> udp_server_;
@@ -582,6 +679,7 @@ private:
std::string motion_state_topic_ = "motion_state"; std::string motion_state_topic_ = "motion_state";
std::string joint_states_topic_ = "joint_states"; std::string joint_states_topic_ = "joint_states";
std::string imu_topic_ = "imu/data"; std::string imu_topic_ = "imu/data";
std::string joint_diagnostics_topic_ = "joint_diagnostics";
double heartbeat_timeout_s_ = 3.0; double heartbeat_timeout_s_ = 3.0;
bool unsupported_as_success_ = false; bool unsupported_as_success_ = false;
@@ -612,8 +710,14 @@ private:
uint8_t actual_charge_ = 0, actual_hes_ = 0, actual_sleep_ = 0; uint8_t actual_charge_ = 0, actual_hes_ = 0, actual_sleep_ = 0;
std::string actual_version_ = "INVICTA"; std::string actual_version_ = "INVICTA";
bool have_state_ = false; bool have_state_ = false;
std::vector<std::string> joint_names_;
std::vector<double> joint_pos_, joint_vel_; std::vector<double> joint_pos_, joint_vel_;
bool have_joints_ = false; bool have_joints_ = false;
/// 启动后 2 s 内不报「还没收到关节状态」:节点刚起来时上游本来就还没发
const std::chrono::steady_clock::time_point start_time_ = std::chrono::steady_clock::now();
std::vector<std::string> diag_names_;
std::vector<double> diag_temperature_;
std::vector<uint8_t> diag_status_;
double rpy_[3] = {0., 0., 0.}; double rpy_[3] = {0., 0., 0.};
double omega_z_ = 0.; double omega_z_ = 0.;
bool have_imu_ = false; bool have_imu_ = false;