Initial commit: extract M20 sim2real into ROS1 invicta project

Source: DeepRoboticsLab/sdk_deploy (BSD-3-Clause), M20 sim2real part only.

Packages:
- invicta_msgs: JointCommandArray, MotionCommand, MotionState; standard messages
  (sensor_msgs/JointState, sensor_msgs/Imu) elsewhere; shared motion value defs.
- invicta_deploy: state machine (idle/standup/rl/joint-damping/lie-down), ONNX
  policy with per-gait models, auto RL entry after standup with readiness checks,
  safety loop, gait->model table; no protocol/network code.
- invicta_remote: vendor basic_server protocol (UDP + JSON, proto.md V1.2.0),
  header/MsgId, 0xE0xx error codes, motion/usage-mode/gait translation, telemetry
  1002/3,4,5,6 (2/10/2/2 Hz); publishes /motion_command, subscribes /motion_state.
- invicta_hardware: joint/IMU bridge skeleton (SocketCAN I/O is TODO), publishes
  sensor_msgs/JointState + sensor_msgs/Imu, consumes /joint_commands.

Verified on ROS Noetic (Ubuntu 20.04) in docker: clean catkin_make, 200 Hz joint
state / 50 Hz RL commands / 20 Hz state report, protocol conformance (MotionParam,
gait preconditions, error codes), telemetry push, and end-to-end switching of two
policy models with a real remote controller.
This commit is contained in:
2026-10-21 00:46:29 +08:00
commit cf70b1a1d7
2016 changed files with 489092 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
cmake_minimum_required(VERSION 3.0.2)
project(invicta_hardware)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release)
endif()
find_package(catkin REQUIRED COMPONENTS
roscpp
sensor_msgs
geometry_msgs
invicta_msgs
)
catkin_package(
INCLUDE_DIRS include
CATKIN_DEPENDS roscpp sensor_msgs geometry_msgs invicta_msgs
)
include_directories(
include
${catkin_INCLUDE_DIRS}
)
add_executable(invicta_hardware_node
src/invicta_hardware_node.cpp
)
target_link_libraries(invicta_hardware_node ${catkin_LIBRARIES})
install(TARGETS invicta_hardware_node
RUNTIME DESTINATION ${CATKIN_PACKAGE_BIN_DESTINATION}
)
install(DIRECTORY launch config
DESTINATION ${CATKIN_PACKAGE_SHARE_DESTINATION}
)
+55
View File
@@ -0,0 +1,55 @@
# invicta_hardware
关节/IMU 数据源与关节指令执行端。**当前是骨架**:结构、话题、参数都齐了,只有 `ReadHardware()` / `WriteHardware()` 是待实现的 TODO。
## 它负责什么
以 `publish_rate_hz`(默认 200 Hz)为一个周期,循环做三件事:
1. `ReadHardware()` — 从电机/IMU 读回状态(TODO)
2. `PublishState()` — 发布 `sensor_msgs/JointState` 与 `sensor_msgs/Imu`(已实现)
3. `WriteHardware()` — 把最新指令下发到电机(TODO,含长度与有限性校验、控制字处理)
| 话题 | 方向 | 消息 |
|---|---|---|
| `/joint_states` | 发布 | `sensor_msgs/JointState` |
| `/imu/data` | 发布 | `sensor_msgs/Imu` |
| `/joint_commands` | 订阅 | `invicta_msgs/JointCommandArray` |
## 约定(最重要的部分)
**硬件约定(方向、零点、单位、CAN 帧格式)在本节点内消化,话题上流动的必须是 policy 约定下的量**:
- 关节角 `[rad]`、角速度 `[rad/s]`、力矩 `[Nm]`,按 `joint_names` 的固定顺序。
- IMU:姿态四元数(**弧度**)、角速度 `[rad/s]`(机体系)、线加速度 `[m/s²]`(机体系)。
- ⚠️ 上游 M20 的 `/IMU_DATA` 里 `rpy` 是**度**,不要照搬;本工程统一弧度。四元数能避免单位歧义,这也是选 `sensor_msgs/Imu` 的原因之一。
- 姿态确实拿不到时,必须把 `orientation_covariance[0]` 置 `-1`(而不是发单位四元数假装有效),deploy 侧会据此丢弃该帧。
方向/零点换算参考(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`。
## 待实现清单
- [ ] `ReadHardware()`:SocketCAN(或你的总线)读回 16 关节的 position/velocity/effort + IMU,换算到 policy 约定。读失败返回 `false`,保留上一帧值,**不要发 NaN**。
- [ ] `WriteHardware()`:按 `control_word` 落实使能/失能/清错,把目标角/角速度/前馈力矩/`kp`/`kd` 组装成帧下发;`tau = kp*(q_des-q) + kd*(dq_des-dq) + tau_ff` 由驱动器算还是本节点算,取决于关节模组协议,选一种并写清楚。
- [ ] 上电顺序与安全:使能前的自检、通信超时(多久没指令就卸力)、过温/过流保护。注意 deploy 在 `idle` 状态也会以 200 Hz 发零增益指令,所以「按控制字判断是否使能」比「按有无指令判断」更稳。
- [ ] 关节顺序必须与 `invicta_deploy` 的 `joint_names` 完全一致(默认 16 个 `fl/fr/hl/hr × hipx/hipy/knee/wheel`)。
## 参数(私有命名空间,`config/invicta_hardware.yaml`)
| 参数 | 默认 | 说明 |
|---|---|---|
| `joint_states_topic` / `imu_topic` / `joint_commands_topic` | `joint_states` / `imu/data` / `joint_commands` | 话题名 |
| `imu_frame_id` | `base_link` | IMU 坐标系 |
| `publish_rate_hz` | 200.0 | 状态发布频率(需 ≥ deploy 的 200 Hz 控制周期) |
| `command_queue` | 1 | 指令只取最新一帧 |
| `joint_names` | 16 个 | 关节顺序 |
## 运行
```bash
roslaunch invicta_hardware invicta_hardware.launch
```
骨架状态下会打印告警,并且只发布零位状态、只缓存指令,**不会驱动电机**。
@@ -0,0 +1,17 @@
# invicta_hardware 参数(私有命名空间 ~)
# ---- 话题与频率 ----
joint_states_topic: joint_states # sensor_msgs/JointState
imu_topic: imu/data # sensor_msgs/Imu
joint_commands_topic: joint_commands # invicta_msgs/JointCommandArray
imu_frame_id: base_link
publish_rate_hz: 200.0 # 状态发布频率,需 >= RL 侧 200 Hz 控制周期
command_queue: 1 # 指令只取最新一帧
# ---- 关节顺序(必须与 invicta_deploy 的 joint_names 完全一致)----
joint_names:
[fl_hipx_joint, fl_hipy_joint, fl_knee_joint, fl_wheel_joint,
fr_hipx_joint, fr_hipy_joint, fr_knee_joint, fr_wheel_joint,
hl_hipx_joint, hl_hipy_joint, hl_knee_joint, hl_wheel_joint,
hr_hipx_joint, hr_hipy_joint, hr_knee_joint, hr_wheel_joint]
@@ -0,0 +1,109 @@
/**
* @file hardware_bridge.h
* @brief 关节/IMU 数据源与关节指令执行端(骨架,硬件 I/O 待实现)
*
* 这个节点是「关节状态发布 + 关节命令接收」合一的那一个:
* - 以 publish_rate_hz(默认 200 Hz)向 /joint_states 与 /imu/data 发布状态
* - 订阅 /joint_commands,缓存最新一帧,交给 writeMotors() 下发
*
* 约定(重要,见 README):
* 硬件约定(关节方向 dir、零点 offset、单位、CAN 帧格式)全部由本节点负责,
* 话题上流动的必须是 policy 约定下的量:关节角 [rad]、角速度 [rad/s]、力矩 [Nm]、
* IMU 姿态为四元数且角度为弧度。这样 invicta_deploy 侧的 calibration 参数保持恒等。
*
* 当前 ReadHardware()/WriteHardware() 是空的 TODO,节点只会:
* - 发布全零的关节状态与单位四元数姿态(说明数据源还没接上)
* - 缓存指令并打印控制字变化,不会真的驱动电机
*/
#pragma once
#include <ros/ros.h>
#include <sensor_msgs/Imu.h>
#include <sensor_msgs/JointState.h>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
#include "invicta_msgs/JointCommandArray.h"
namespace invicta_hardware {
class HardwareBridge {
public:
explicit HardwareBridge(const ros::NodeHandle& pnh);
/// 主循环:以 publish_rate_hz 周期跑「读硬件 → 发状态 → 下发指令」,阻塞
void Run();
void Stop();
private:
void LoadParams(const ros::NodeHandle& pnh);
void CommandCallback(const invicta_msgs::JointCommandArray::ConstPtr& msg);
/// 发布一帧关节状态与 IMU
void PublishState();
/// 控制字变化时的处理(使能/失能/清错),后续在 WriteHardware 中落实
void HandleControlWord(uint16_t word);
/**
* @brief 从电机读回状态
*
* TODO(硬件):接 SocketCAN 读回 16 个关节的
* position[rad] / velocity[rad/s] / effort[Nm]
* 并做 raw → policy 约定的换算(方向、零点、单位)。
* 同时读回 IMU:姿态四元数(弧度)、角速度[rad/s](机体系)、线加速度[m/s²](机体系)。
* 注意 M20 原车 /IMU_DATA 的 rpy 是「度」,这里约定统一用弧度,不要照搬。
* 读失败时返回 false,本帧沿用上一次的值(不要发 NaN)。
*/
bool ReadHardware();
/**
* @brief 把最新指令下发给电机
*
* TODO(硬件):把 policy 约定的量换算成 raw(零点、方向、单位),
* 按 invicta_msgs/control_word.h 的语义设置使能状态,组装 CAN 帧写下去。
* torque = kp*(q_des - q) + kd*(dq_des - dq) + tau_ff 由驱动器或本节点计算,
* 取决于关节模组协议,二选一并写清楚。
*/
bool WriteHardware();
// ---- 参数 ----
std::string joint_state_topic_ = "joint_states";
std::string imu_topic_ = "imu/data";
std::string command_topic_ = "joint_commands";
std::string imu_frame_id_ = "base_link";
std::vector<std::string> joint_names_;
double publish_rate_hz_ = 200.0;
int command_queue_ = 1;
// ---- ROS ----
ros::NodeHandle nh_;
ros::Publisher joint_state_pub_;
ros::Publisher imu_pub_;
ros::Subscriber command_sub_;
// ---- 指令缓存 ----
std::mutex cmd_mutex_;
invicta_msgs::JointCommandArray latest_cmd_;
bool has_cmd_ = false;
uint16_t active_control_word_ = 0;
// ---- 状态缓存(policy 约定)----
std::vector<double> joint_position_, joint_velocity_, joint_effort_;
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_linear_acceleration_[3] = {0.0, 0.0, 0.0};
std::atomic<bool> running_{false};
bool warned_no_command_ = false;
};
} // namespace invicta_hardware
@@ -0,0 +1,6 @@
<launch>
<!-- 关节/IMU 数据源 + 关节指令执行端(骨架)。参数在节点的私有命名空间下 -->
<node pkg="invicta_hardware" type="invicta_hardware_node" name="invicta_hardware" output="screen">
<rosparam command="load" file="$(find invicta_hardware)/config/invicta_hardware.yaml"/>
</node>
</launch>
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0"?>
<package format="2">
<name>invicta_hardware</name>
<version>0.1.0</version>
<description>
关节/IMU 数据源与关节指令执行端(骨架):发布 sensor_msgs/JointState 与 sensor_msgs/Imu,
订阅 invicta_msgs/JointCommandArray,负责硬件约定(方向、零点、单位、CAN)与 policy 约定之间的换算。
</description>
<maintainer email="todo@example.com">TODO</maintainer>
<license>BSD-3-Clause</license>
<buildtool_depend>catkin</buildtool_depend>
<depend>roscpp</depend>
<depend>sensor_msgs</depend>
<depend>geometry_msgs</depend>
<depend>invicta_msgs</depend>
</package>
@@ -0,0 +1,180 @@
/**
* @file invicta_hardware_node.cpp
* @brief 关节/IMU 数据源 + 关节指令执行端(骨架入口)
*/
#include <ros/ros.h>
#include <thread>
#include "invicta_hardware/hardware_bridge.h"
#include "invicta_msgs/control_word.h"
namespace invicta_hardware {
HardwareBridge::HardwareBridge(const ros::NodeHandle& pnh) : nh_() {
LoadParams(pnh);
joint_position_.assign(joint_names_.size(), 0.0);
joint_velocity_.assign(joint_names_.size(), 0.0);
joint_effort_.assign(joint_names_.size(), 0.0);
joint_state_pub_ = nh_.advertise<sensor_msgs::JointState>(joint_state_topic_, 1);
imu_pub_ = nh_.advertise<sensor_msgs::Imu>(imu_topic_, 1);
command_sub_ = nh_.subscribe(command_topic_, command_queue_, &HardwareBridge::CommandCallback, this);
ROS_WARN("[invicta_hardware] 这是骨架节点:ReadHardware/WriteHardware 尚未实现,");
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(),
imu_frame_id_.c_str(), command_topic_.c_str(), publish_rate_hz_);
}
void HardwareBridge::LoadParams(const ros::NodeHandle& pnh) {
pnh.param<std::string>("joint_states_topic", joint_state_topic_, "joint_states");
pnh.param<std::string>("imu_topic", imu_topic_, "imu/data");
pnh.param<std::string>("joint_commands_topic", command_topic_, "joint_commands");
pnh.param<std::string>("imu_frame_id", imu_frame_id_, "base_link");
pnh.param<double>("publish_rate_hz", publish_rate_hz_, 200.0);
pnh.param<int>("command_queue", command_queue_, 1);
joint_names_ = {"fl_hipx_joint", "fl_hipy_joint", "fl_knee_joint", "fl_wheel_joint",
"fr_hipx_joint", "fr_hipy_joint", "fr_knee_joint", "fr_wheel_joint",
"hl_hipx_joint", "hl_hipy_joint", "hl_knee_joint", "hl_wheel_joint",
"hr_hipx_joint", "hr_hipy_joint", "hr_knee_joint", "hr_wheel_joint"};
pnh.getParam("joint_names", joint_names_);
if (publish_rate_hz_ <= 0.) {
ROS_ERROR("[invicta_hardware] publish_rate_hz 必须 > 0,回退到 200 Hz");
publish_rate_hz_ = 200.0;
}
}
void HardwareBridge::CommandCallback(const invicta_msgs::JointCommandArray::ConstPtr& msg) {
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;
has_cmd_ = true;
}
void HardwareBridge::HandleControlWord(uint16_t word) {
switch (word) {
case invicta_msgs::kDisable:
ROS_INFO("[invicta_hardware] control_word -> kDisable(1):清使能,电机自由");
break;
case invicta_msgs::kEnable:
ROS_INFO("[invicta_hardware] control_word -> kEnable(2):使能并保持当前角");
break;
case invicta_msgs::kMotorControl:
ROS_INFO("[invicta_hardware] control_word -> kMotorControl(3):进入 PD+前馈控制");
break;
case invicta_msgs::kErrorReset:
ROS_INFO("[invicta_hardware] control_word -> kErrorReset(4):清驱动错误");
break;
case invicta_msgs::kGetStatus:
ROS_INFO("[invicta_hardware] control_word -> kGetStatus(5):上报驱动状态");
break;
default:
ROS_WARN("[invicta_hardware] 未知 control_word=%u,忽略", word);
break;
}
// TODO(硬件):把上面这些语义落到电机使能序列上(上电顺序、清错指令、超时保护)。
}
void HardwareBridge::PublishState() {
sensor_msgs::JointState js;
js.header.stamp = ros::Time::now();
js.name = joint_names_;
js.position = joint_position_;
js.velocity = joint_velocity_;
js.effort = joint_effort_;
joint_state_pub_.publish(js);
sensor_msgs::Imu imu;
imu.header.stamp = js.header.stamp;
imu.header.frame_id = imu_frame_id_;
imu.orientation.w = imu_orientation_[0];
imu.orientation.x = imu_orientation_[1];
imu.orientation.y = imu_orientation_[2];
imu.orientation.z = imu_orientation_[3];
imu.angular_velocity.x = imu_angular_velocity_[0];
imu.angular_velocity.y = imu_angular_velocity_[1];
imu.angular_velocity.z = imu_angular_velocity_[2];
imu.linear_acceleration.x = imu_linear_acceleration_[0];
imu.linear_acceleration.y = imu_linear_acceleration_[1];
imu.linear_acceleration.z = imu_linear_acceleration_[2];
// 首元素置 0 表示「该字段可用」;若某字段确实拿不到,必须置 -1,让使用方知道不能信
imu.orientation_covariance[0] = 0.;
imu.angular_velocity_covariance[0] = 0.;
imu.linear_acceleration_covariance[0] = 0.;
imu_pub_.publish(imu);
}
bool HardwareBridge::ReadHardware() {
// TODO(硬件):SocketCAN 读回关节与 IMU,换算到 policy 约定后写入
// joint_position_ / joint_velocity_ / joint_effort_ / imu_*
// 读不到就 return false,保留上一帧值。
return false;
}
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_;
}
// 上游 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() {
running_ = true;
const auto period = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::duration<double>(1.0 / publish_rate_hz_));
auto next = std::chrono::steady_clock::now();
while (ros::ok() && running_) {
next += period;
ReadHardware();
PublishState();
WriteHardware();
std::this_thread::sleep_until(next);
}
}
void HardwareBridge::Stop() { running_ = false; }
} // namespace invicta_hardware
int main(int argc, char** argv) {
ros::init(argc, argv, "invicta_hardware");
ros::NodeHandle nh;
ros::NodeHandle pnh("~");
invicta_hardware::HardwareBridge bridge(pnh);
std::thread loop_thread(&invicta_hardware::HardwareBridge::Run, &bridge);
ros::spin();
bridge.Stop();
if (loop_thread.joinable()) {
loop_thread.join();
}
return 0;
}