Commit 8275ee2e authored by 唐永康's avatar 唐永康

feat: add RM75 O6 trajectory replay workflow

parent b145d527
...@@ -50,7 +50,7 @@ message(STATUS "Found LinkerHand headers: ${LINKER_HAND_INCLUDE_DIR}") ...@@ -50,7 +50,7 @@ message(STATUS "Found LinkerHand headers: ${LINKER_HAND_INCLUDE_DIR}")
unset(REALMAN_ARM_LIB CACHE) unset(REALMAN_ARM_LIB CACHE)
unset(REALMAN_ARM_LIB_CANDIDATE CACHE) unset(REALMAN_ARM_LIB_CANDIDATE CACHE)
set(REALMAN_RM_API2_ROOT "" CACHE PATH "Path to RealMan RM_API2 C++ SDK root, for example /home/mashiro/RM_API2/C++") set(REALMAN_RM_API2_ROOT "" CACHE PATH "Path to the RealMan RM_API2 C++ SDK root")
set(REALMAN_ARM_ROOT "") set(REALMAN_ARM_ROOT "")
set(REALMAN_ARM_INCLUDE_DIR "") set(REALMAN_ARM_INCLUDE_DIR "")
set(REALMAN_ARM_LIB_DIR "") set(REALMAN_ARM_LIB_DIR "")
...@@ -61,7 +61,6 @@ if(REALMAN_RM_API2_ROOT) ...@@ -61,7 +61,6 @@ if(REALMAN_RM_API2_ROOT)
list(APPEND REALMAN_ARM_CANDIDATE_ROOTS ${REALMAN_RM_API2_ROOT}) list(APPEND REALMAN_ARM_CANDIDATE_ROOTS ${REALMAN_RM_API2_ROOT})
endif() endif()
list(APPEND REALMAN_ARM_CANDIDATE_ROOTS list(APPEND REALMAN_ARM_CANDIDATE_ROOTS
/home/mashiro/RM_API2/C++
${CMAKE_CURRENT_SOURCE_DIR}/third_party/Robotic_Arm ${CMAKE_CURRENT_SOURCE_DIR}/third_party/Robotic_Arm
) )
list(REMOVE_DUPLICATES REALMAN_ARM_CANDIDATE_ROOTS) list(REMOVE_DUPLICATES REALMAN_ARM_CANDIDATE_ROOTS)
...@@ -149,7 +148,7 @@ if(REALMAN_ARM_INCLUDE_DIR) ...@@ -149,7 +148,7 @@ if(REALMAN_ARM_INCLUDE_DIR)
endif() endif()
option(REALMAN_BUILD_HARDWARE_TESTS "Build independent RealMan hardware module test executables" ON) option(REALMAN_BUILD_HARDWARE_TESTS "Build independent RealMan hardware module test executables" ON)
option(REALMAN_REGISTER_HARDWARE_TESTS "Register independent RealMan hardware module tests with CTest" ON) option(REALMAN_REGISTER_HARDWARE_TESTS "Register manual RealMan hardware programs with CTest" OFF)
if(REALMAN_REGISTER_HARDWARE_TESTS) if(REALMAN_REGISTER_HARDWARE_TESTS)
enable_testing() enable_testing()
......
...@@ -17,8 +17,8 @@ ...@@ -17,8 +17,8 @@
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
"BUILD_TESTING": "OFF", "BUILD_TESTING": "OFF",
"REALMAN_BUILD_HARDWARE_TESTS": "ON", "REALMAN_BUILD_HARDWARE_TESTS": "ON",
"REALMAN_REGISTER_HARDWARE_TESTS": "ON", "REALMAN_REGISTER_HARDWARE_TESTS": "OFF",
"REALMAN_RM_API2_ROOT": "/home/mashiro/RM_API2/C++" "REALMAN_RM_API2_ROOT": "$env{REALMAN_RM_API2_ROOT}"
} }
}, },
{ {
...@@ -32,8 +32,8 @@ ...@@ -32,8 +32,8 @@
"CMAKE_EXPORT_COMPILE_COMMANDS": "ON", "CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
"BUILD_TESTING": "OFF", "BUILD_TESTING": "OFF",
"REALMAN_BUILD_HARDWARE_TESTS": "ON", "REALMAN_BUILD_HARDWARE_TESTS": "ON",
"REALMAN_REGISTER_HARDWARE_TESTS": "ON", "REALMAN_REGISTER_HARDWARE_TESTS": "OFF",
"REALMAN_RM_API2_ROOT": "/home/mashiro/RM_API2/C++" "REALMAN_RM_API2_ROOT": "$env{REALMAN_RM_API2_ROOT}"
} }
} }
], ],
...@@ -102,27 +102,6 @@ ...@@ -102,27 +102,6 @@
] ]
}, },
{ {
"name": "realman-hw-run-readonly-tests-debug",
"configurePreset": "clion-debug",
"targets": [
"run_realman_hardware_readonly_tests"
]
},
{
"name": "rm75-acceptance-run-readonly-debug",
"configurePreset": "clion-debug",
"targets": [
"run_rm75_acceptance_readonly_tests"
]
},
{
"name": "realman-hw-run-tests-debug",
"configurePreset": "clion-debug",
"targets": [
"run_realman_hardware_tests"
]
},
{
"name": "realman-hw-force-retreat-debug", "name": "realman-hw-force-retreat-debug",
"configurePreset": "clion-debug", "configurePreset": "clion-debug",
"targets": [ "targets": [
...@@ -137,13 +116,6 @@ ...@@ -137,13 +116,6 @@
] ]
}, },
{ {
"name": "realman-hw-run-manual-tests-debug",
"configurePreset": "clion-debug",
"targets": [
"run_realman_hardware_manual_tests"
]
},
{
"name": "realman-demo-release", "name": "realman-demo-release",
"configurePreset": "clion-release", "configurePreset": "clion-release",
"targets": [ "targets": [
...@@ -207,27 +179,6 @@ ...@@ -207,27 +179,6 @@
] ]
}, },
{ {
"name": "realman-hw-run-readonly-tests-release",
"configurePreset": "clion-release",
"targets": [
"run_realman_hardware_readonly_tests"
]
},
{
"name": "rm75-acceptance-run-readonly-release",
"configurePreset": "clion-release",
"targets": [
"run_rm75_acceptance_readonly_tests"
]
},
{
"name": "realman-hw-run-tests-release",
"configurePreset": "clion-release",
"targets": [
"run_realman_hardware_tests"
]
},
{
"name": "realman-hw-force-retreat-release", "name": "realman-hw-force-retreat-release",
"configurePreset": "clion-release", "configurePreset": "clion-release",
"targets": [ "targets": [
...@@ -240,13 +191,6 @@ ...@@ -240,13 +191,6 @@
"targets": [ "targets": [
"realman_hw_test_force_retreat_hold" "realman_hw_test_force_retreat_hold"
] ]
},
{
"name": "realman-hw-run-manual-tests-release",
"configurePreset": "clion-release",
"targets": [
"run_realman_hardware_manual_tests"
]
} }
] ]
} }
This diff is collapsed.
This diff is collapsed.
{
"hardware_execution_enabled": false,
"emergency_stop_verified": false,
"safety_limits_loaded": false,
"workspace_and_virtual_wall_verified": false,
"native_replay_without_speed_control_accepted": false,
"trajectory_sample_period_confirmed": false,
"vendor_raw_units_per_degree_confirmed": false,
"max_speed_percent": 10,
"vendor_raw_units_per_degree": 1000,
"maximum_origin_move_delta_deg": 30.0,
"max_joint_jump_deg": 5.0,
"max_joint_velocity_deg_per_second": 30.0,
"max_joint_acceleration_deg_per_second_squared": 100.0,
"maximum_absolute_force_newton": 100.0,
"maximum_absolute_torque_newton_meter": 20.0,
"maximum_o6_speed_command": 0,
"maximum_o6_torque_command": 0
}
This source diff could not be displayed because it is too large. You can view the blob instead.
#pragma once
#include <cstddef>
namespace rm_control::common {
// RM75-6F 固定为七自由度;所有 RM75 关节数据都以该常量为唯一长度来源。
inline constexpr std::size_t kRm75JointCount = 7;
} // namespace rm_control::common
#pragma once #pragma once
#include "rm_control/common/rm75_constants.h"
#include <array> #include <array>
#include <filesystem>
#include <string> #include <string>
#include <vector> #include <vector>
...@@ -31,6 +34,26 @@ struct RobotInfo { ...@@ -31,6 +34,26 @@ struct RobotInfo {
int arm_model = 0; int arm_model = 0;
int force_type = 0; int force_type = 0;
int controller_version = 0; int controller_version = 0;
bool is_rm75 = false;
bool six_axis_force_sensor_available = false;
};
// SDK 与控制器软件信息。firmware_version 明确映射 RM_API2 的 ctrl_info.version,
// 其余分层版本单独保留,避免把不同固件组件误合并成一个版本号。
struct RobotSoftwareInfo {
std::string sdk_version;
std::string product_version;
std::string controller_generation;
std::string firmware_version;
std::string firmware_build_time;
std::string plan_version;
std::string plan_build_time;
std::string algorithm_version;
std::string dynamics_version;
std::string communication_version;
std::string communication_build_time;
std::string program_version;
std::string program_build_time;
}; };
// 当前机械臂状态:关节角、TCP 位姿、错误码。 // 当前机械臂状态:关节角、TCP 位姿、错误码。
...@@ -50,26 +73,92 @@ struct ForceData { ...@@ -50,26 +73,92 @@ struct ForceData {
std::array<float, 6> tool_zero{}; std::array<float, 6> tool_zero{};
}; };
// 安全门控需要的只读机械臂状态。RM_API2 v1.1.5 没有独立的急停查询接口,
// 因此适配器必须保持 emergency_stop_state_available=false,不能根据错误码猜测急停正常。
struct RobotSafetyStatus {
bool real_mode_enabled = false;
bool arm_power_on = false;
bool all_joints_enabled = false;
bool arm_error_free = false;
bool controller_error_free = false;
bool virtual_wall_enabled = false;
bool self_collision_enabled = false;
bool emergency_stop_state_available = false;
bool emergency_stop_normal = false;
int controller_error_code = 0;
float controller_voltage = 0.0F;
float controller_current = 0.0F;
float controller_temperature = 0.0F;
std::array<int, kRm75JointCount> joint_enable_states{};
std::array<int, kRm75JointCount> joint_error_codes{};
std::vector<int> arm_error_codes;
};
// 控制器当前生效的七轴位置、速度和加速度限制。位置为 degree,速度为
// degree/second。RM_API2 v1.1.5 的 get-max-acc 注释误写为 degree/second,
// 而对应 setter 写为 degree/second^2;本工程按函数“加速度”语义显式使用后者。
struct RobotJointLimits {
std::array<float, kRm75JointCount> minimum_position_deg{};
std::array<float, kRm75JointCount> maximum_position_deg{};
std::array<float, kRm75JointCount> maximum_speed_deg_per_second{};
std::array<float, kRm75JointCount> maximum_acceleration_deg_per_second_squared{};
};
enum class DragTeachMode { enum class DragTeachMode {
OrdinaryRecorded, OrdinaryRecorded,
SixDofForcePositionAndOrientation, SixDofForcePositionAndOrientation,
}; };
struct DragTeachStartOptions { struct DragTeachStartOptions {
DragTeachMode mode = DragTeachMode::SixDofForcePositionAndOrientation; DragTeachMode mode = DragTeachMode::OrdinaryRecorded;
bool record_vendor_trajectory = true; bool record_vendor_trajectory = true;
bool enable_singular_wall = true; bool enable_singular_wall = true;
}; };
struct SavedVendorTrajectory { // 未实际调用厂商保存接口时不能把默认值 0 误记为成功。
std::string path; inline constexpr int kVendorApiNotCalledReturnCode = -10000;
// 一次 rm_save_trajectory 调用的完整结果。SDK 返回非零时也必须保留返回码、
// 控制器可能写入的点数和目标路径,供 manifest 与故障报告使用。
struct VendorTrajectorySaveAttempt {
std::filesystem::path path;
bool api_called = false;
int return_code = kVendorApiNotCalledReturnCode;
int point_count = 0; int point_count = 0;
std::string error_message;
};
// 上传到控制器保存或兼容性上传即执行的厂商拖动轨迹工程参数。路径仍使用工程统一的
// std::filesystem::path;具体保存/执行语义由调用的 IRobotArm 方法明确表达。
struct VendorDragTrajectoryExecutionRequest {
std::filesystem::path trajectory_path;
int plan_speed_percent = 0;
int controller_program_id = 0;
};
// 运行控制器中已保存程序的参数。速度必须由调用方显式给出 1..100,禁止传 0
// 隐式沿用控制器中的历史速度;blocking=false 可用于状态机非阻塞监控。
struct ControllerProgramRunRequest {
int controller_program_id = 0;
int speed_percent = 0;
bool blocking = false;
};
// 控制器在线编程运行状态。raw_state 保留 SDK 原值,避免上层把未知状态
// 错当成已完成;当前 SDK 已知 0=停止/结束、1=运行、2=暂停。
struct ControllerProgramRunState {
int raw_state = -1;
int program_id = 0;
int plan_number = 0;
int plan_speed_percent = 0;
}; };
struct TrajectorySample { struct TrajectorySample {
long long elapsed_ms = 0; std::size_t sample_index = 0; // 从 0 连续递增。
std::vector<float> joints_deg; long long elapsed_ms = 0; // steady_clock 相对录制起点,单位 millisecond。
Pose tcp_pose; std::vector<float> joints_deg; // RM75 固定七关节,单位 degree。
Pose tcp_pose; // 位置 meter,欧拉姿态 radian。
std::string source = "realman_rm75_drag_teach";
}; };
} // namespace rm_control::common } // namespace rm_control::common
/**
* @file drag_teach_cli_config.h
* @brief RM75 拖动示教录制程序命令行参数解析接口。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 本文件声明 `rm_drag_teach_record` 的 CLI 配置结构和解析函数。拖动示教会访问
* 真实机械臂,因此入口文件必须先得到清晰、可验证的参数配置,再显式建立硬件连接。
*
* 设计原则:
* - 参数解析不连接机械臂;
* - 参数解析不启动拖动示教;
* - 默认使用普通记录拖动模式,确保厂商保存接口有明确记录契约;
* - 保持原有位置参数和选项语义不变。
*
* @dependencies
* - rm_control/common/result.h
* - rm_control/core/drag_teach_recorder.h
*
* @note
* 后续增加人工确认、dry-run 或安全开关时,应优先扩展这里的配置结构。
*/
#pragma once
#include "rm_control/common/result.h"
#include "rm_control/core/drag_teach_recorder.h"
#include <filesystem>
#include <iosfwd>
namespace rm_control::config {
/**
* @brief 拖动示教录制程序的命令行解析结果。
*
* @reason 将危险的命令行授权与静态安全配置路径显式分离,确保入口层可以在连接前拒绝执行。
*
* @author 唐永康
* @date 2026-07-10
*
* @details
* 该结构体把“是否请求帮助”和“录制配置”放在一起返回给入口文件。
* `record_config` 是 core 层真正需要的业务配置,CLI 层不会直接调用硬件。
*/
struct DragTeachRecordCliConfig {
bool help_requested = false; ///< 是否请求帮助信息。
bool live_requested = false; ///< 是否显式请求真实硬件模式。
std::filesystem::path safety_config_path; ///< 静态安全配置文件路径。
core::DragTeachRecordConfig record_config; ///< 拖动示教录制配置。
};
/**
* @brief 打印拖动示教录制程序的命令行使用说明。
*
* @reason 集中维护安全选项说明,避免帮助文本与真实门控条件发生漂移。
*
* @author 唐永康
* @date 2026-07-10
*
* @details
* 该函数集中维护 usage 文本,避免 `main.cpp`、错误分支和测试中出现多份不一致文案。
* 它不访问真实机械臂、不创建文件、不启动拖动示教。
*
* @param[in] program_name 当前进程名,可以为空指针。
* @param[out] out 使用说明输出流。
*
* @return 无返回值。
*
* @calls 仅调用标准输出流,不访问配置文件或设备。
* @safety 本函数不连接硬件、不触发运动。
*
* @throws 本函数不主动抛出异常;输出流异常由调用方处理。
*
* @note
* 帮助文本必须强调拖动示教会访问真实硬件,后续维护时不能删除安全提示。
*
* @warning
* 如果解析规则变化,必须同步更新此函数文本。
*
* @thread_safety
* 函数本身无全局状态;并发写同一输出流需要调用方保护。
*
* @par 边界条件
* `program_name == nullptr` 时使用默认程序名。
*
* @par 后续维护
* 新增选项时应说明默认值和安全影响。
*/
void PrintDragTeachRecordUsage(const char *program_name, std::ostream &out);
/**
* @brief 解析拖动示教录制程序的命令行参数。
*
* @reason 在任何连接动作前解析 `--live` 和安全配置路径,使错误参数保持 fail-closed。
*
* @author 唐永康
* @date 2026-07-10
*
* @details
* 该函数负责解析 `session_name`、录制时长、采样周期、存储根目录以及拖动示教模式选项。
* 它只返回结构化配置,不连接 SDK、不移动机械臂,因此可以被单元测试直接覆盖。
*
* @param[in] argc 命令行参数数量。
* @param[in] argv 命令行参数数组,允许元素为空指针。
*
* @return 成功时返回 `DragTeachRecordCliConfig`。
* @return 失败时返回错误原因,例如未知选项、整数解析失败或位置参数过多。
*
* @calls 不调用厂商接口;只处理命令行文本。
* @safety 未显式传入 `--live` 时 `live_requested` 永远为 false。
*
* @throws 本函数不主动抛出异常;所有解析失败通过 `Result` 返回。
*
* @note
* - 默认 session name 使用时间戳生成。
* - 默认存储目录来自 trajectory 模块。
* - 默认启用厂商轨迹保存。
*
* @warning
* 本函数不校验机械臂连接状态;真实硬件状态由 driver/core 层检查。
*
* @thread_safety
* 函数只使用局部对象和只读输入,不依赖全局可变状态,线程安全。
*
* @par 边界条件
* 空字符串、未知 `-` 开头选项、过多位置参数都会返回失败。
*
* @par 后续维护
* 新增运动相关参数时必须保持“默认不绕过安全检查”的原则。
*/
common::Result<DragTeachRecordCliConfig> ParseDragTeachRecordCliArguments(int argc, char **argv);
} // namespace rm_control::config
#pragma once
#include "rm_control/common/result.h"
#include <cstdint>
#include <filesystem>
namespace rm_control::config {
/**
* @brief 拖动示教真实硬件执行所需的静态安全配置。
*
* @reason 将可审计的配置条件与命令行和运行时设备状态分离,避免单个输入源绕过安全门控。
*
* @author 唐永康
* @date 2026-07-11
*
* @note 所有布尔开关默认关闭,O6 速度/转矩上限默认 0;人工确认和设备连接状态
* 不允许从本结构伪造。
*/
struct DragTeachSafetyConfig {
bool hardware_execution_enabled = false;
bool emergency_stop_verified = false;
bool safety_limits_loaded = false;
bool workspace_and_virtual_wall_verified = false;
bool native_replay_without_speed_control_accepted = false;
bool trajectory_sample_period_confirmed = false;
bool vendor_raw_units_per_degree_confirmed = false;
int max_speed_percent = 10; // 1..100。
int vendor_raw_units_per_degree = 1000;
float maximum_origin_move_delta_deg = 30.0F;
float max_joint_jump_deg = 5.0F;
float max_joint_velocity_deg_per_second = 30.0F;
float max_joint_acceleration_deg_per_second_squared = 100.0F;
float maximum_absolute_force_newton = 100.0F;
float maximum_absolute_torque_newton_meter = 20.0F;
std::uint16_t maximum_o6_speed_command = 0U;
std::uint16_t maximum_o6_torque_command = 0U;
};
/**
* @brief 从 JSON 文件加载并校验拖动示教安全配置。
*
* @reason 真实硬件开关必须来自显式配置文件,并在连接设备前完成类型和值域校验。
*
* @author 唐永康
* @date 2026-07-11
*
* @param config_path 安全配置文件路径;调用方保留所有权。
* @return 成功时返回完整配置;文件、JSON、字段或数值非法时返回带路径上下文的失败。
*
* @calls rm_control::config::parseJson。
* @safety 本函数不连接设备、不触发运动;失败时保持硬件执行禁用。
* @note 配置缺少任一必填字段时拒绝加载,不使用隐式危险默认值。
*/
common::Result<DragTeachSafetyConfig>
LoadDragTeachSafetyConfig(const std::filesystem::path &config_path);
} // namespace rm_control::config
/**
* @file json_runner_cli_config.h
* @brief RM JSON 指令运行器命令行参数解析接口。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 本文件只声明 `rm_json_runner` 可执行程序的命令行参数结构和解析函数。
* 设计上把命令行解析从 `main.cpp` 中移出,是为了让入口文件只负责装配依赖
* 和启动应用流程,同时让参数解析逻辑可以在不连接真实机械臂的情况下单元测试。
*
* 设计原则:
* - 只处理命令行文本,不读取 JSON 文件;
* - 只返回结构化配置和错误原因,不直接退出进程;
* - 不依赖 RealMan RM_API2,避免 CLI 单元测试访问真实硬件;
* - 保持现有 `rm_json_runner <commands.json> [--validate-only]` 运行方式不变。
*
* @dependencies
* - rm_control/common/result.h
*
* @note
* 后续如果新增 `rm_json_runner` 命令行选项,应优先扩展本文件中的配置结构,
* 不应把解析逻辑重新写回 `RM/src/main.cpp`。
*/
#pragma once
#include "rm_control/common/result.h"
#include <iosfwd>
#include <string>
namespace rm_control::config {
/**
* @brief `rm_json_runner` 命令行解析后的结构化配置。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 该结构体只表达启动参数层面的意图,不保存已经解析好的 JSON 指令内容。
* 这样 `main.cpp` 可以先处理帮助信息,再把 `command_file_path` 交给 JSON
* 配置模块读取,避免 CLI 层和 JSON 文件解析层相互耦合。
*
* @note
* `help_requested=true` 时,调用方应打印 usage 并返回成功退出码。
*/
struct JsonRunnerCliConfig {
bool help_requested = false; ///< 是否请求帮助信息。
bool validate_only = false; ///< 是否只校验 JSON 文件,不连接 SDK。
std::string command_file_path; ///< JSON 指令文件路径。
};
/**
* @brief 打印 `rm_json_runner` 的命令行使用说明。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 该函数只负责把稳定的 usage 文本写入输出流,不解析参数、不访问文件、
* 不连接真实机械臂。之所以单独封装,是为了让 `main.cpp` 在错误和帮助场景
* 复用同一份提示文本,避免不同分支输出不一致。
*
* @param[in] program_name 当前进程名,可以为空指针;为空时使用固定占位名称。
* @param[out] out 使用说明输出流,通常是 `std::cout` 或测试中的字符串流。
*
* @return 无返回值。
*
* @throws 本函数不主动抛出异常;如果输出流自身异常,由调用方处理。
*
* @note
* - 不读取环境变量。
* - 不修改全局状态。
*
* @warning
* 如果后续新增命令行选项,必须同步更新此函数的输出内容。
*
* @thread_safety
* 函数内部不使用全局可变状态;多个线程写同一个输出流时需要调用方加锁。
*
* @par 边界条件
* `program_name == nullptr` 时仍能输出可读 usage。
*
* @par 后续维护
* 保持 usage 和 `ParseJsonRunnerCliArguments()` 的实际解析规则一致。
*/
void PrintJsonRunnerUsage(const char *program_name, std::ostream &out);
/**
* @brief 解析 `rm_json_runner` 的命令行参数。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 该函数把 `argc/argv` 转换为 `JsonRunnerCliConfig`。它只做参数数量、
* 帮助选项和 `--validate-only` 选项校验,不读取 JSON 文件,也不访问真实机械臂。
* 这样设计可以保证命令行解析逻辑可独立测试,并让 `main.cpp` 保持简洁。
*
* @param[in] argc 命令行参数数量,来自 `main()`。
* @param[in] argv 命令行参数数组,来自 `main()`,允许数组元素为空指针。
*
* @return 成功时返回解析后的 `JsonRunnerCliConfig`。
* @return 失败时返回错误原因,例如参数数量错误或未知选项。
*
* @throws 本函数不主动抛出异常;内部通过 `Result` 返回错误。
*
* @note
* - `-h` 和 `--help` 只允许作为第一个业务参数。
* - `--validate-only` 只允许作为第二个业务参数。
*
* @warning
* 本函数不判断 JSON 文件是否存在,文件存在性由配置加载层负责。
*
* @thread_safety
* 函数只读取输入参数并返回局部对象,不使用全局可变状态,线程安全。
*
* @par 边界条件
* `argv[1] == nullptr` 或空路径会返回失败,避免后续文件读取错误不明确。
*
* @par 后续维护
* 新增选项时应保持向后兼容,不破坏现有命令格式。
*/
common::Result<JsonRunnerCliConfig> ParseJsonRunnerCliArguments(int argc, char **argv);
} // namespace rm_control::config
/**
* @file trajectory_convert_cli_config.h
* @brief RM75 轨迹 CSV 转 C++ waypoint 程序命令行参数解析接口。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 本文件声明 `rm_trajectory_convert` 的 CLI 参数解析接口。该工具只读取 CSV 并写出
* C++ waypoint 文件,不连接真实机械臂。将参数解析单独封装后,入口文件只负责
* 调用读取、转换和写出流程。
*
* @dependencies
* - rm_control/common/result.h
*
* @note
* 轨迹转换工具属于离线工具,任何后续改动都不应引入真实硬件依赖。
*/
#pragma once
#include "rm_control/common/result.h"
#include <iosfwd>
#include <string>
namespace rm_control::config {
/**
* @brief 轨迹转换程序的命令行解析结果。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 该结构体保存输入 CSV、输出 C++ 文件路径和从输入文件名推导出的 session name。
* session name 用于生成稳定的 C++ 变量名,输出路径可以由用户显式指定或按默认规则生成。
*/
struct TrajectoryConvertCliConfig {
bool help_requested = false; ///< 是否请求帮助信息。
std::string input_csv_path; ///< 输入轨迹 CSV 文件路径。
std::string output_cpp_path; ///< 输出 C++ waypoint 文件路径。
std::string session_name; ///< 用于生成 C++ 标识符的会话名。
};
/**
* @brief 打印轨迹转换程序的命令行使用说明。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 该函数只输出 usage 文本,不读取 CSV、不创建输出文件。独立函数可以避免多个错误分支
* 各自维护不同提示内容。
*
* @param[in] program_name 当前进程名,可以为空指针。
* @param[out] out 使用说明输出流。
*
* @return 无返回值。
*
* @throws 本函数不主动抛出异常;输出流异常由调用方处理。
*
* @note
* usage 中必须说明该工具不连接真实机器人。
*
* @warning
* 命令行格式变化时必须同步更新此函数。
*
* @thread_safety
* 函数无全局状态;并发写同一输出流时由调用方加锁。
*
* @par 边界条件
* `program_name == nullptr` 时使用默认程序名。
*
* @par 后续维护
* 保持提示文本和解析逻辑一致。
*/
void PrintTrajectoryConvertUsage(const char *program_name, std::ostream &out);
/**
* @brief 解析轨迹转换程序的命令行参数。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 该函数解析 `<input_csv> [output_cpp]`。如果用户没有提供输出路径,则基于输入文件名
* 和默认轨迹目录生成输出 C++ 文件路径。函数不检查输入文件是否存在,文件读取模块会在
* 实际加载时返回明确错误。
*
* @param[in] argc 命令行参数数量。
* @param[in] argv 命令行参数数组,允许元素为空指针。
*
* @return 成功时返回 `TrajectoryConvertCliConfig`。
* @return 失败时返回错误原因,例如参数数量错误或输入路径为空。
*
* @throws 本函数不主动抛出异常;所有错误通过 `Result` 返回。
*
* @note
* `-h` 和 `--help` 只作为第一个业务参数处理。
*
* @warning
* 本函数只处理路径字符串,不保证路径权限或磁盘空间。
*
* @thread_safety
* 函数只使用局部变量和只读输入,线程安全。
*
* @par 边界条件
* 输入路径为空或 `argv[1] == nullptr` 时返回失败。
*
* @par 后续维护
* 如果未来支持更多输出格式,应把格式选项加入该配置结构,而不是在入口文件临时判断。
*/
common::Result<TrajectoryConvertCliConfig> ParseTrajectoryConvertCliArguments(int argc, char **argv);
} // namespace rm_control::config
/**
* @file trajectory_o6_task_cli_config.h
* @brief RM75 轨迹与 LinkerHand O6 抓握任务命令行参数解析接口。
*
* @author 唐永康
* @date 2026-07-11
*
* @details
* 本文件只声明集成任务入口所需的结构化参数。参数解析与硬件连接、轨迹加载、
* O6 通信和运动执行完全分离,因此可以在不连接任何设备的单元测试中验证。
*
* @dependencies
* - rm_control/common/result.h
*
* @note
* 默认运行模式为 DryRun。`--live` 只表达操作者意图,不能单独授权真实运动。
*/
#pragma once
#include "rm_control/common/result.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <iosfwd>
namespace rm_control::config {
inline constexpr std::size_t kLinkerHandO6JointCount = 6;
/**
* @brief LinkerHand O6 在 RM75 工具 RS-485 总线上的设备侧别。
*
* @reason 将命令行中的 `right|left` 映射集中为强类型,避免入口层散落设备地址魔法数字。
*
* @author 唐永康
* @date 2026-07-11
*
* @safety 该枚举只保存地址,不访问总线,也不触发手部动作。
* @note 当前工程约定右手地址为 0x27,左手地址为 0x28。
*/
enum class O6HandSide : std::uint8_t {
Right = 0x27,
Left = 0x28,
};
/**
* @brief RM75 轨迹与 O6 抓握集成任务的命令行解析结果。
*
* @reason 把轨迹来源、节拍、双程序槽、低速参数、O6 姿态、速度、转矩和真实运动意图
* 集中传给应用层,使入口无需重新解释字符串,也不会因危险默认值进入执行流程。
*
* @author 唐永康
* @date 2026-07-11
*
* @safety `live_requested` 默认 false;后续硬件执行仍必须通过完整安全门控。
* @note `grip_pose`、`grip_speed` 和 `grip_torque` 均为必填的 O6 六关节协议值,
* 每个值的闭区间为 0..255;解析器不会为这些动作参数提供可执行默认值。
*/
struct TrajectoryO6TaskCliConfig {
bool help_requested = false; ///< 是否仅请求帮助文本。
bool live_requested = false; ///< 是否显式传入 `--live`,默认 DryRun。
std::filesystem::path trajectory_path; ///< 只读厂商轨迹源文件路径。
int sample_period_ms = 0; ///< 轨迹采样周期,单位毫秒,范围 1..1000。
int origin_speed_percent = 5; ///< 回到轨迹原点速度比例,范围 1..10。
int plan_speed_percent = 5; ///< 轨迹规划速度比例,范围 1..10。
int program_id = 1; ///< 厂商轨迹规划程序编号,范围 1..100。
bool return_from_trajectory_end = false; ///< 是否先从原轨迹末点沿反向轨迹返回首点。
int return_program_id = 0; ///< 反向返回程序编号;0 表示返回模式未启用。
O6HandSide o6_side = O6HandSide::Right; ///< O6 侧别,默认右手地址 0x27。
/// O6 六关节抓握姿态。
std::array<std::uint8_t, kLinkerHandO6JointCount> grip_pose{};
/// O6 六关节抓握速度,必须由命令行显式提供。
std::array<std::uint8_t, kLinkerHandO6JointCount> grip_speed{};
/// O6 六关节抓握转矩,必须由命令行显式提供。
std::array<std::uint8_t, kLinkerHandO6JointCount> grip_torque{};
std::filesystem::path safety_config_path; ///< 真实执行所需静态安全配置路径。
};
/**
* @brief 打印 RM75 轨迹与 O6 抓握任务的命令行使用说明。
*
* @reason 集中维护必填参数、默认低速值和真实运动警示,防止入口错误分支的说明漂移。
*
* @author 唐永康
* @date 2026-07-11
*
* @param[in] program_name 当前进程名,可以为空指针或空字符串。
* @param[out] out 接收使用说明的输出流。
* @return 无返回值。
*
* @calls 仅调用标准输出流,不读取轨迹、配置文件或设备状态。
* @safety 本函数不连接 RM75 或 O6,不触发真实硬件动作。
* @note 命令行规则变化时必须同步更新本函数和解析单元测试。
*/
void PrintTrajectoryO6TaskUsage(const char *program_name, std::ostream &out);
/**
* @brief 解析 RM75 轨迹与 O6 抓握任务命令行参数。
*
* @reason 在加载轨迹和连接设备前严格验证全部外部输入,确保缺少必填项、超范围值
* 或非法 O6 姿态、速度、转矩时保持 fail-closed。
*
* @author 唐永康
* @date 2026-07-11
*
* @param[in] argc 命令行参数数量,必须为非负数。
* @param[in] argv 命令行参数数组;`argc > 0` 时不能为空。
* @return 成功时返回完整 `TrajectoryO6TaskCliConfig`;失败时返回带参数上下文的错误信息。
*
* @calls 仅调用标准库字符串、数值和路径构造函数,不调用厂商 SDK。
* @safety 默认 `live_requested=false`;本函数不会执行轨迹、连接设备或写入源轨迹。
* @note `--return-from-trajectory-end` 与 `--return-program-id` 必须成对出现,且返回程序号
* 必须与正向 `--program-id` 不同;其余必填项在 `-h|--help` 模式下豁免。
*/
common::Result<TrajectoryO6TaskCliConfig>
ParseTrajectoryO6TaskCliArguments(int argc, char **argv);
} // namespace rm_control::config
This diff is collapsed.
This diff is collapsed.
#pragma once
#include "rm_control/common/result.h"
#include <string>
#include <vector>
namespace rm_control::core {
// 睿尔曼厂商原生“移动到拖动轨迹起点”接口固定使用 20% 速度。
inline constexpr int kNativeTrajectoryOriginRequiredSpeedPercent = 20;
/**
* @brief 真实硬件动作执行前由各层共同提供的安全事实。
*
* @reason 将命令行授权、静态配置和运行时设备状态汇总成不可隐式补全的输入,
* 使业务服务能够在调用任何运动接口前统一执行 fail-closed 判定。
*
* @author 唐永康
* @date 2026-07-11
*
* @safety 所有字段默认拒绝执行;调用方不得用默认 true 或跳过未知状态。
* @note 本结构只承载已经由对应模块验证过的事实,不负责连接设备或读取配置。
*/
struct HardwareExecutionGateInput {
bool live_requested = false;
bool hardware_execution_enabled = false;
bool arm_connected = false;
bool hand_connected = false;
bool emergency_stop_verified = false;
bool safety_limits_loaded = false;
bool operator_confirmed = false;
bool trajectory_validated = false;
bool trajectory_sample_period_confirmed = false;
bool vendor_raw_units_per_degree_confirmed = false;
bool workspace_and_virtual_wall_verified = false;
int configured_max_speed_percent = 0;
bool native_replay_without_speed_control_accepted = false;
bool native_replay_speed_within_config_verified = false;
};
/**
* @brief 当前硬件动作对安全门控提出的附加要求。
*
* @reason 厂商原生回放和普通受控动作的速度、时间及单位前提不同,显式传入动作要求
* 可以复用同一基础门控,同时避免把轨迹文件特有条件错误施加给其他动作。
*
* @author 唐永康
* @date 2026-07-11
*
* @safety `required_speed_percent` 为动作实际需要的速度比例;0 表示该动作不声明速度。
* @note 移动到原生轨迹起点应传入固定 20%;文件回放应显式要求采样周期和厂商单位比例确认。
*/
struct HardwareExecutionGateRequirements {
int required_speed_percent = 0;
bool require_native_replay_without_speed_control_acceptance = false;
bool require_native_replay_speed_verification = false;
bool require_confirmed_trajectory_sample_period = false;
bool require_confirmed_vendor_raw_units_per_degree = false;
};
/**
* @brief 安全门控的完整判定结果。
*
* @reason 保留全部拒绝原因便于操作者一次修正多个缺失条件,并为审计日志提供稳定输入。
*
* @author 唐永康
* @date 2026-07-11
*
* @safety 只有 `execution_allowed` 为 true 时才允许调用真实硬件动作接口。
* @note 拒绝原因不包含设备密钥、网络地址或其他敏感数据。
*/
struct HardwareExecutionGateDecision {
bool execution_allowed = false;
std::vector<std::string> rejection_reasons;
};
/**
* @brief 评估真实硬件动作的全部安全门控条件。
*
* @reason 使用无副作用的纯软件判定,使服务层、单元测试和审计工具共享同一套拒绝规则。
*
* @author 唐永康
* @date 2026-07-11
*
* @param input 已经由命令行、配置和设备服务确认的安全事实。
* @param requirements 当前动作的速度与原生回放附加要求。
* @return 返回允许标志及全部拒绝原因;本函数本身不以异常或错误码表示门控拒绝。
*
* @calls 仅调用本模块内部的纯软件校验函数。
* @safety 本函数不连接设备、不触发运动;未知或非法输入一律产生拒绝结果。
* @note `configured_max_speed_percent` 和声明的动作速度有效范围均为 1..100;动作速度可为 0 表示不声明。
*/
HardwareExecutionGateDecision EvaluateHardwareExecutionGate(
const HardwareExecutionGateInput &input,
const HardwareExecutionGateRequirements &requirements = {});
/**
* @brief 以统一 `Result<void>` 形式要求真实硬件执行权限。
*
* @reason 服务层通常需要失败即返回的控制流,本函数把完整门控判定转换为包含上下文的错误。
*
* @author 唐永康
* @date 2026-07-11
*
* @param input 已经由命令行、配置和设备服务确认的安全事实。
* @param requirements 当前动作的速度与原生回放附加要求。
* @return 全部条件满足时成功;否则返回合并后的全部拒绝原因。
*
* @calls EvaluateHardwareExecutionGate。
* @safety 调用方必须在每次真实运动开始或继续前调用;成功结果不替代设备侧急停。
* @note Pause 和 Stop 属于减险动作,应由服务层直接执行,不应被本门控阻止。
*/
common::Result<void> RequireHardwareExecutionPermission(
const HardwareExecutionGateInput &input,
const HardwareExecutionGateRequirements &requirements = {});
} // namespace rm_control::core
#pragma once
#include "rm_control/common/result.h"
#include "rm_control/interfaces/tool_modbus_bus.h"
#include <cstddef>
#include <cstdint>
#include <string>
#include <vector>
namespace rm_control::core {
struct O6BusScanConfig {
std::vector<std::uint8_t> device_ids{0x27U, 0x28U};
std::uint16_t identity_start_address = 30U;
std::size_t identity_register_count = 6U;
};
struct O6BusScanResponse {
std::uint8_t device_id = 0U;
bool multiple_read_succeeded = false;
std::vector<std::uint16_t> identity_registers;
std::string multiple_read_error;
bool single_read_succeeded = false;
std::uint16_t single_freedom_register = 0U;
std::string single_read_error;
bool single_and_multiple_reads_match = false;
bool matches_o6_identity_shape = false;
};
struct O6BusScanReport {
std::size_t attempted_device_count = 0U;
std::size_t failed_device_count = 0U;
std::size_t confirmed_o6_device_count = 0U;
std::vector<O6BusScanResponse> responses;
};
/**
* @brief 对 O6 默认左右手地址执行目标化只读身份探测。
* @reason 固定探测 0x27/0x28 的 FC04 地址 30..35,并用单寄存器地址 30 交叉验证
* RM_API2 的两条读取路径,避免无边界全地址扫描。
* @author 唐永康
* @date 2026-07-11
* @param tool_bus 已连接并显式初始化的工具端 Modbus 主站。
* @param config 固定候选设备和身份寄存器范围;偏离 O6 默认值会被拒绝。
* @return 参数有效时返回两个设备的双路径结果和原始错误上下文。
* @calls IToolModbusBus::ReadInputRegisters 和 ReadInputRegister。
* @safety 只调用功能码 04,不写保持寄存器,不触发 O6 或机械臂动作。
* @note 每条读取路径仅调用一次,不自动重试。
*/
common::Result<O6BusScanReport> ScanO6IdentityRegisters(
interfaces::IToolModbusBus &tool_bus,
const O6BusScanConfig &config);
} // namespace rm_control::core
#pragma once #pragma once
#include "rm_control/interfaces/robot_arm.h" #include "flight_robot/realman/realman_arm_adapter.h"
#include "rm_interface.h"
namespace rm_control::drivers::realman { namespace rm_control::drivers::realman {
// 睿尔曼 RM_API2 的具体驱动实现。 // 旧名称仅作为源码兼容别名;唯一实现是允许目录中的 RealmanArmAdapter。
// 这是 RM 子工程中唯一直接依赖 rm_interface.h 的位置之一; using RealManRobotArm = ::flight_robot::realman::RealmanArmAdapter;
// core/config/interfaces 都不应该 include 睿尔曼 SDK 头文件。
class RealManRobotArm final : public interfaces::IRobotArm {
public:
RealManRobotArm() = default;
RealManRobotArm(const RealManRobotArm &) = delete;
RealManRobotArm &operator=(const RealManRobotArm &) = delete;
~RealManRobotArm() override;
common::Result<void> connect(const common::RobotConnectionConfig &config) override;
void disconnect() override;
common::Result<common::RobotInfo> getRobotInfo() override;
common::Result<common::ArmState> getCurrentArmState() override;
common::Result<common::ForceData> getForceData() override;
common::Result<void> moveJ(const std::vector<float> &joints_deg,
int speed_percent,
int blend_radius,
int trajectory_connect,
bool blocking) override;
common::Result<void> moveL(const common::Pose &pose,
int speed_percent,
int blend_radius,
int trajectory_connect,
bool blocking) override;
common::Result<void> startDragTeach(const common::DragTeachStartOptions &options) override;
common::Result<void> stopDragTeach() override;
common::Result<common::SavedVendorTrajectory>
saveDragTeachTrajectory(const std::string &path) override;
private:
// 所有 SDK 读写前先检查连接状态,防止空句柄传入 C SDK。
common::Result<void> ensureConnected() const;
// RM_API2 使用裸指针句柄,因此必须由本类析构函数集中释放。
rm_robot_handle *handle_ = nullptr;
bool initialized_ = false;
int dof_ = 0;
};
} // namespace rm_control::drivers::realman } // namespace rm_control::drivers::realman
#pragma once
#include "rm_control/common/result.h"
#include "rm_control/interfaces/tool_modbus_bus.h"
#include <array>
#include <cstddef>
#include <cstdint>
namespace rm_control::interfaces {
inline constexpr std::size_t kO6JointCount = 6U;
enum class DexterousHandSide {
Left,
Right,
};
using O6JointPose = std::array<std::uint16_t, kO6JointCount>;
/**
* @brief 一次 O6 动作所需的六关节位置、速度和转矩命令。
*
* @reason 把三个必须一致下发的寄存器组放入单一输入,适配器可以先完整校验,再按
* “转矩读回、速度读回、最后位置”顺序执行,避免使用设备残留配置。
*
* @author 唐永康
* @date 2026-07-11
*
* @safety 该结构体自身不触发动作;传给 CommandMotion 后目标位置可能立即驱动 O6。
* @note 三组值均按拇指弯曲、拇指横摆、食指、中指、无名指、小指排列,范围 0..255。
*/
struct O6MotionCommand {
O6JointPose target_positions{};
O6JointPose joint_speed_commands{};
O6JointPose joint_torque_commands{};
};
/**
* @brief 经 O6 协议字段校验后的灵巧手 readiness 快照。
*
* @reason 将总线状态、身份、六关节位置、转矩、速度、温度和故障放在同一结果中,
* 状态机只在完整快照合规时才能把 `ready` 作为真实硬件门控事实。
*
* @author 唐永康
* @date 2026-07-11
*
* @safety 只有 `ReadReadiness` 成功返回的快照才允许 `ready=true`。
* @note 位置、转矩和速度为 O6 无量纲 0..255,温度为摄氏度整数。
*/
struct DexterousHandReadiness {
bool ready = false;
DexterousHandSide side = DexterousHandSide::Right;
std::uint8_t device_id = 0;
ToolModbusBusStatus tool_bus_status;
std::uint16_t freedom = 0;
std::uint16_t hand_version = 0;
std::array<std::uint16_t, 3U> hand_number_parts{};
char direction = '\0';
O6JointPose joint_positions{};
O6JointPose joint_torques{};
O6JointPose joint_speeds{};
O6JointPose temperatures_celsius{};
O6JointPose fault_codes{};
};
/**
* @brief 飞行操纵业务使用的 O6 灵巧手抽象接口。
*
* @reason 状态机只依赖 readiness 和完整六关节动作语义,不包含 LinkerHand 或 RealMan
* 厂商头文件。
*
* @author 唐永康
* @date 2026-07-11
*
* @safety readiness 为只读;动作命令可能触发真实握夹动作并必须受上层门控。
* @note 当前接口只覆盖项目目标 O6,不承诺其他灵巧手型号兼容性。
*/
class IDexterousHand {
public:
/**
* @brief 销毁灵巧手接口对象。
*
* @reason 允许通过抽象接口安全释放具体 O6 适配器。
*
* @author 唐永康
* @date 2026-07-10
*
* @return 析构函数无返回值且不得抛出异常。
*
* @calls 具体适配器析构函数。
* @safety 只释放资源,不发送新的 O6 动作命令。
* @note 总线所有权由具体依赖组装决定。
*/
virtual ~IDexterousHand() = default;
/**
* @brief 读取并验证当前 O6 总线、身份、位置、转矩、速度、温度和故障状态。
*
* @reason 单一成功结果可以作为硬件门控输入,避免业务层把“总线已连接”误当成“O6 可用”。
*
* @author 唐永康
* @date 2026-07-11
*
* @return 所有强制字段合规时返回 ready 快照;任一读取、维度或安全值非法时失败。
*
* @calls IToolModbusBus::GetStatus 和 ReadInputRegister。
* @safety 只读,不触发 O6 或机械臂运动。
* @note 调用方应在每次握夹前重新读取,不能永久缓存 readiness。
*/
virtual common::Result<DexterousHandReadiness> ReadReadiness() = 0;
/**
* @brief 校验并下发 O6 六关节位置、速度和转矩动作命令。
*
* @reason 单一命令入口强制适配器先写转矩并读回、再写速度并读回,只有两组精确一致
* 后才允许写位置,防止使用未知的残留速度或转矩执行抓握。
*
* @author 唐永康
* @date 2026-07-11
*
* @param command 六关节位置、速度和转矩命令,每项有效范围 0..255;调用期间只读。
* @return 三组命令按安全顺序完成时成功;输入越界、读写失败或读回不一致时返回错误。
*
* @calls IToolModbusBus::WriteHoldingRegisters;动作后的状态由 ReadReadiness 读取。
* @safety 可能触发真实握夹动作,必须由状态机在 readiness 和 Live 门控通过后调用。
* @note 不自动重试;转矩或速度任一步失败时禁止下发位置,成功也不替代动作后状态验证。
*/
virtual common::Result<void> CommandMotion(
const O6MotionCommand &command) = 0;
};
} // namespace rm_control::interfaces
#pragma once
#include "rm_control/common/result.h"
#include <cstddef>
#include <cstdint>
#include <vector>
namespace rm_control::interfaces {
/**
* @brief RM75 工具端 Modbus 总线的当前只读配置快照。
*
* @reason O6 readiness 必须依据实际工具端口、电压和通信参数,而不能由业务层用布尔值伪造。
*
* @author 唐永康
* @date 2026-07-10
*
* @safety `connected` 只表示总线连接事实,不代表 O6 已通过身份、温度和故障检查。
* @note `timeout_units` 沿用 RM_API2 的百毫秒单位。
*/
struct ToolModbusBusStatus {
bool connected = false;
int port = 0;
int mode = 0;
int baudrate = 0;
int voltage_type = 0;
int timeout_units = 0;
};
/**
* @brief RM75 工具端 Modbus RTU 会话的显式初始化参数。
* @reason 控制器保存的 mode 状态不能替代机械臂启动后的 `rm_set_modbus_mode` 调用,
* 因此每个真实 O6 会话必须在读写寄存器前主动初始化。
* @author 唐永康
* @date 2026-07-11
* @safety 配置端口和供电但不写 O6 动作寄存器,也不移动机械臂。
*/
struct ToolModbusBusConfiguration {
int port = 1;
int baudrate = 115200;
int voltage_type = 3;
int timeout_units = 10;
bool reset_existing_mode = true;
bool power_cycle_tool = false;
int power_off_delay_ms = 500;
int startup_delay_ms = 2000;
};
/**
* @brief 隔离 RM75 工具端 Modbus 实现的厂商无关接口。
*
* @reason LinkerHandAdapter 只应表达 O6 寄存器协议,RM_API2 句柄、结构体和函数必须留在
* RealMan 厂商适配层。
*
* @author 唐永康
* @date 2026-07-11
*
* @safety 读取方法不触发设备动作;写保持寄存器可能触发真实 O6 关节运动。
* @note 接口不负责自动重试,调用失败必须原样返回给上层状态机。
*/
class IToolModbusBus {
public:
/**
* @brief 销毁工具端 Modbus 总线接口对象。
*
* @reason 允许业务层通过接口安全释放具体总线实现。
*
* @author 唐永康
* @date 2026-07-10
*
* @return 析构函数无返回值且不得抛出异常。
*
* @calls 具体实现的析构函数。
* @safety 只释放资源,不发送新的 O6 动作命令。
* @note 具体实现负责自身连接资源的所有权。
*/
virtual ~IToolModbusBus() = default;
/**
* @brief 显式初始化 RM75 工具端供电和 Modbus RTU 主站会话。
* @reason RM_API2 要求机械臂启动后在任何寄存器读写前调用 Modbus 配置接口。
* @author 唐永康
* @date 2026-07-11
* @param configuration 端口、波特率、供电、超时和是否先关闭既有模式。
* @return 成功时返回控制器实际读回的总线状态;任一设置或读回不一致时失败。
* @calls 具体实现的工具电压、关闭/启动 Modbus 和状态查询接口。
* @safety 不发送 O6 位置、速度或转矩命令;可能重新初始化工具端通信会话。
*/
virtual common::Result<ToolModbusBusStatus> Configure(
const ToolModbusBusConfiguration &configuration) = 0;
/**
* @brief 读取 RM75 工具端 Modbus 和供电配置。
*
* @reason O6 readiness 必须确认端口 1、主站模式、115200 波特率、24V 和有效超时均已生效。
*
* @author 唐永康
* @date 2026-07-10
*
* @return 成功时返回当前总线状态;任一底层查询失败时返回带接口上下文的错误。
*
* @calls 具体 RealMan 实现的工具电压和 RS485 状态查询接口。
* @safety 只读,不触发机械臂或 O6 运动。
* @note 返回成功不代表目标 Modbus 从站已经响应。
*/
virtual common::Result<ToolModbusBusStatus> GetStatus() = 0;
/**
* @brief 使用 Modbus 功能码 04 读取单个输入寄存器。
* @reason RM_API2 的单寄存器接口和多寄存器接口走不同代码路径,诊断时交叉读取可区分
* O6 无响应与多寄存器解码问题。
* @author 唐永康
* @date 2026-07-11
* @param device_id Modbus RTU 从站地址,合法范围 1..247。
* @param address 输入寄存器 PDU 地址,不使用 30001 风格的人机显示地址。
* @return 成功时返回一个无符号 16 位寄存器值;失败时保留 RM_API2 返回码上下文。
* @calls 具体 RealMan 实现的单输入寄存器读取接口。
* @safety 只读,不触发机械臂或 O6 运动。
* @note 不自动重试,避免把连续超时误判为设备响应。
*/
virtual common::Result<std::uint16_t> ReadInputRegister(
std::uint8_t device_id,
std::uint16_t address) = 0;
/**
* @brief 使用 Modbus 功能码 04 读取连续输入寄存器。
*
* @reason O6 的位置、转矩、速度、温度、故障和身份均位于输入寄存器,集中接口可避免
* LinkerHand 协议层接触 RM_API2 参数结构体。
*
* @author 唐永康
* @date 2026-07-11
*
* @param device_id Modbus RTU 从站地址。
* @param start_address 首个输入寄存器地址。
* @param register_count 要读取的 16 位寄存器数量,必须大于零。
* @return 成功时返回按地址递增排列的寄存器值;通信失败时返回原始上下文。
*
* @calls 具体 RealMan 实现的多输入寄存器读取接口。
* @safety 只读,不触发机械臂或 O6 运动。
* @note 实现必须把 Modbus 字节序转换为 16 位寄存器值。
*/
virtual common::Result<std::vector<std::uint16_t>> ReadInputRegisters(
std::uint8_t device_id,
std::uint16_t start_address,
std::size_t register_count) = 0;
/**
* @brief 使用 Modbus 功能码 03 读取连续保持寄存器。
*
* @reason O6 速度和转矩在位置动作下发前必须精确读回,独立的功能码 03 接口使
* LinkerHand 协议层能够验证写入结果,同时继续隔离 RM_API2 类型。
*
* @author 唐永康
* @date 2026-07-11
*
* @param device_id Modbus RTU 从站地址。
* @param start_address 首个保持寄存器地址。
* @param register_count 要读取的 16 位寄存器数量,必须大于零。
* @return 成功时返回按地址递增排列的寄存器值;通信失败时返回带接口上下文的错误。
*
* @calls 具体 RealMan 实现的多保持寄存器读取接口。
* @safety 只读,不触发机械臂或 O6 运动。
* @note 实现必须把 Modbus 高低字节转换为 16 位寄存器值,且不得自动重试。
*/
virtual common::Result<std::vector<std::uint16_t>> ReadHoldingRegisters(
std::uint8_t device_id,
std::uint16_t start_address,
std::size_t register_count) = 0;
/**
* @brief 使用 Modbus 功能码 16 写入连续保持寄存器。
*
* @reason O6 六关节位置、转矩和速度命令位于保持寄存器 0..17,统一写接口使上层可在
* Mock 中验证地址、从站和数据,同时阻止厂商 API 泄漏。
*
* @author 唐永康
* @date 2026-07-11
*
* @param device_id Modbus RTU 从站地址。
* @param start_address 首个保持寄存器地址。
* @param values 按地址递增排列的 16 位寄存器值;调用期间只读且不转移所有权。
* @return 写入成功时返回成功;通信或设备拒绝时返回带上下文的失败。
*
* @calls 具体 RealMan 实现的多保持寄存器写接口。
* @safety 可能立即触发真实 O6 关节运动,调用前必须通过状态机和硬件安全门控。
* @note 本接口不自动重试,避免超时后重复执行不确定的手部动作。
*/
virtual common::Result<void> WriteHoldingRegisters(
std::uint8_t device_id,
std::uint16_t start_address,
const std::vector<std::uint16_t> &values) = 0;
};
} // namespace rm_control::interfaces
#pragma once
#include "rm_control/common/result.h"
#include "rm_control/common/robot_types.h"
#include <cstddef>
#include <vector>
namespace rm_control::modules::trajectory {
/**
* @brief RM75 拖动示教轨迹的关节运动安全上限。
*
* @reason 将可调安全限制作为显式输入,使校验算法不读取配置文件且能够在 Mock 测试中复现。
*
* @author 唐永康
* @date 2026-07-10
*
* @safety 三项限制都必须是有限正数;单位分别为 degree、degree/second 和 degree/second^2。
* @note 这些限制用于离线轨迹校验,不会改变机械臂控制器内部限制。
*/
struct DragTrajectoryValidationLimits {
double maximum_joint_jump_deg = 5.0;
double maximum_joint_velocity_deg_per_second = 30.0;
double maximum_joint_acceleration_deg_per_second_squared = 100.0;
};
/**
* @brief 通过校验的 RM75 拖动示教轨迹统计报告。
*
* @reason 在允许回放前保留计算得到的峰值和位置,便于 manifest、审计日志和回放报告复用。
*
* @author 唐永康
* @date 2026-07-10
*
* @safety 报告仅在 `ValidateDragTrajectory` 成功时有效,关节索引使用 0..6。
* @note 时间单位为 millisecond,运动指标单位与 `DragTrajectoryValidationLimits` 一致。
*/
struct DragTrajectoryValidationReport {
std::size_t sample_count = 0;
long long duration_ms = 0;
double observed_maximum_joint_jump_deg = 0.0;
double observed_maximum_joint_velocity_deg_per_second = 0.0;
double observed_maximum_joint_acceleration_deg_per_second_squared = 0.0;
std::size_t maximum_jump_sample_index = 0;
std::size_t maximum_jump_joint_index = 0;
std::size_t maximum_velocity_sample_index = 0;
std::size_t maximum_velocity_joint_index = 0;
std::size_t maximum_acceleration_sample_index = 0;
std::size_t maximum_acceleration_joint_index = 0;
};
/**
* @brief 校验 RM75 七关节拖动示教轨迹并计算运动峰值。
*
* @reason 在进入 DryRun 或硬件回放前一次性拒绝空轨迹、维度错误、非法浮点数、时间倒退及
* 超出跃变、速度或加速度限制的轨迹,避免播放线程中临时发现结构性错误。
*
* @author 唐永康
* @date 2026-07-10
*
* @param samples 已全部载入内存的轨迹点;关节角单位为 degree,时间为单调 elapsed millisecond。
* @param limits 关节跃变、速度和加速度的有限正数上限。
* @return 轨迹完全合规时返回峰值报告;任一输入或安全限制非法时返回带样本和关节上下文的失败。
*
* @calls 仅调用标准数学函数和本模块内部纯算法函数。
* @safety 本函数不连接设备、不触发运动;失败结果不得被转换为可回放状态。
* @note 相邻区间速度按各自时间差计算,加速度按两个区间中点之间的时间差计算。
*/
common::Result<DragTrajectoryValidationReport> ValidateDragTrajectory(
const std::vector<common::TrajectorySample> &samples,
const DragTrajectoryValidationLimits &limits);
} // namespace rm_control::modules::trajectory
#pragma once
#include "rm_control/common/result.h"
#include <cstdint>
#include <filesystem>
#include <string>
namespace rm_control::modules::trajectory {
struct RawTrajectorySessionPaths {
std::filesystem::path directory;
std::filesystem::path raw_trajectory;
std::filesystem::path synchronized_csv;
std::filesystem::path manifest;
std::string session_id;
std::string saved_at_utc;
};
struct RawTrajectoryManifestMetadata {
std::string robot_model;
std::string sdk_version;
std::string firmware_version;
std::uint64_t trajectory_point_count = 0;
std::uint64_t recorded_sample_count = 0;
bool vendor_save_api_called = false;
int vendor_save_return_code = 0;
std::string vendor_save_error_message;
bool synchronized_csv_write_succeeded = false;
};
struct RawTrajectoryArtifact {
RawTrajectorySessionPaths paths;
RawTrajectoryManifestMetadata metadata;
bool raw_file_present = false;
std::uintmax_t raw_file_size_bytes = 0;
std::string raw_file_sha256;
std::string manifest_written_at_utc;
};
/**
* @brief 将操作者提供的会话名清理为安全的 ASCII 文件名片段。
*
* @reason 会话名来自外部输入,统一折叠非法字符、限制长度并规避 Windows 保留名,可以让同一轨迹目录在 Linux 和 Windows 上安全复用。
*
* @author 唐永康
* @date 2026-07-10
*
* @param requested_session_name 原始会话名;允许为空、包含空白或非 ASCII 字符。
* @return 最长 64 字符的非空 ASCII 名称,只包含字母、数字、下划线和连字符。
*
* @calls 不调用厂商接口;仅执行确定性的字符串清理。
* @safety 不访问文件系统,不连接机械臂,也不会触发任何硬件动作。
* @note 连续非法字符折叠为单个下划线;空结果使用 `trajectory`。
*/
std::string SanitizeRawTrajectorySessionName(const std::string &requested_session_name);
/**
* @brief 在 `storage_root/sessions` 下原子创建一个不会覆盖既有数据的唯一会话目录。
*
* @reason 厂商保存接口需要先拿到目标路径;先用 `create_directory` 独占目录,可以在并发采集和同名会话场景下保持原始轨迹不可覆盖。
*
* @author 唐永康
* @date 2026-07-10
*
* @param storage_root 轨迹存储根目录;不得为空,父目录可尚未存在。
* @param requested_session_name 操作者提供的会话名,函数会先进行安全清理。
* @return 成功时返回会话目录、原始轨迹、同步 CSV、manifest、会话 ID 和 UTC 保存时间;失败时返回文件系统上下文。
*
* @calls std::filesystem::create_directories 和 std::filesystem::create_directory。
* @safety 只创建新的本地目录,不连接机械臂,也不会触发任何硬件动作。
* @note 会话 ID 格式为 `<清理名>_<UTC毫秒>[_NNN]`,所有产物路径都位于新目录内。
*/
common::Result<RawTrajectorySessionPaths>
CreateUniqueRawTrajectorySession(const std::filesystem::path &storage_root,
const std::string &requested_session_name);
/**
* @brief 校验厂商原始文件、计算大小和 SHA-256、移除写权限并原子生成 manifest。
*
* @reason 把不可变保护和 manifest 固化放在同一收尾边界,可以确保成功或失败的厂商保存尝试都有可审计记录,同时不改写厂商原始内容。
*
* @author 唐永康
* @date 2026-07-10
*
* @param paths 由 `CreateUniqueRawTrajectorySession` 返回的受控会话路径。
* @param metadata 机械臂型号、SDK/固件版本、点数、同步样本数和厂商保存返回码。
* @return 成功时返回完整原始轨迹产物;即使厂商保存失败且原始文件不存在,也会成功生成 manifest。文件系统或摘要失败时返回上下文错误。
*
* @calls CalculateFileSha256、std::filesystem::file_size、std::filesystem::permissions 和 std::filesystem::create_hard_link。
* @safety 只读取并保护本地轨迹文件,不连接机械臂,也不会触发任何硬件动作。
* @note 已存在的 manifest 不会被覆盖;完成后原始文件、同步 CSV、manifest 和会话目录均移除全部写权限。
*/
common::Result<RawTrajectoryArtifact>
FinalizeRawTrajectorySession(const RawTrajectorySessionPaths &paths,
const RawTrajectoryManifestMetadata &metadata);
} // namespace rm_control::modules::trajectory
#pragma once
#include "rm_control/common/result.h"
#include "rm_control/modules/trajectory/vendor_drag_trajectory_parser.h"
#include <filesystem>
namespace rm_control::modules::trajectory {
/**
* @brief 返回运行期反向厂商轨迹文件的默认保存目录。
*
* @reason 反向轨迹是由只读原始轨迹派生的运行期产物,必须与 sourceTXT 原件分开保存,
* 避免误改、覆盖或把派生文件冒充原始记录。
*
* @author 唐永康
* @date 2026-07-11
*
* @return CMake 注入的 runtime_data/reversed_vendor_trajectories 路径。
*
* @calls 仅构造 std::filesystem::path,不访问文件系统。
* @safety 不读取轨迹、不连接设备,也不会触发 RM75 或 O6 动作。
*/
std::filesystem::path DefaultReversedVendorTrajectoryDirectory();
/**
* @brief 从已解析的只读厂商轨迹创建逐物理行严格倒序的只读副本。
*
* @reason RM75 位于原轨迹末点时,不能通过放大首点距离限制直接跨越到首点;本函数生成
* 可由控制器沿相同关节路点反向执行的独立 TXT 产物,同时保留原文件不变。
*
* @author 唐永康
* @date 2026-07-11
*
* @param forward_trajectory 已由 ParseVendorDragTrajectoryJsonLines 严格解析的正向轨迹;
* 源文件必须仍是无写权限、非符号链接的普通文件,大小和 SHA-256 必须与元数据一致。
* @param output_directory 反向产物根目录;函数在其下原子创建唯一子目录,绝不覆盖既有文件。
* @return 成功时返回对 0444 反向文件重新解析得到的完整元数据;失败时只清理本次新建产物。
*
* @calls CalculateFileSha256、ParseVendorDragTrajectoryJsonLines 和 std::filesystem 文件接口。
* @safety 仅执行离线文件读取、派生写入和校验,不连接硬件,也不发送任何运动指令。
* @note 输出保持每一物理行的字节内容和换行风格,只反转行顺序;重新解析后的第 i 个样本
* 必须逐字段等于正向轨迹的第 N-1-i 个关节样本。
*/
common::Result<VendorDragTrajectoryParseResult>
CreateReadOnlyReversedVendorTrajectoryWithoutOverwrite(
const VendorDragTrajectoryParseResult &forward_trajectory,
const std::filesystem::path &output_directory);
} // namespace rm_control::modules::trajectory
#pragma once
#include "rm_control/common/result.h"
#include <filesystem>
#include <string>
namespace rm_control::modules::trajectory {
/**
* @brief 流式计算文件的 SHA-256 十六进制校验值。
*
* @reason 原始轨迹可能较大,流式摘要可以避免把完整文件加载到内存,并为只读原始产物提供可复核的完整性证据。
*
* @author 唐永康
* @date 2026-07-10
*
* @param file_path 待校验的普通文件路径;路径必须存在且不能是目录。
* @return 成功时返回 64 个小写十六进制字符,失败时返回带路径上下文的错误信息。
*
* @calls OpenSSL EVP_DigestInit_ex、EVP_DigestUpdate 和 EVP_DigestFinal_ex。
* @safety 只读取本地文件,不连接机械臂,也不会触发任何硬件动作。
* @note 文件在摘要计算期间不得被其他进程修改,否则调用方应重新计算并校验。
*/
common::Result<std::string> CalculateFileSha256(const std::filesystem::path &file_path);
} // namespace rm_control::modules::trajectory
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
#include "rm_control/common/result.h" #include "rm_control/common/result.h"
#include "rm_control/common/robot_types.h" #include "rm_control/common/robot_types.h"
#include <filesystem>
#include <string> #include <string>
#include <vector> #include <vector>
...@@ -34,6 +35,26 @@ common::Result<TrajectoryFileSet> makeTrajectoryFileSet(const std::string &root_ ...@@ -34,6 +35,26 @@ common::Result<TrajectoryFileSet> makeTrajectoryFileSet(const std::string &root_
common::Result<void> writeTrajectoryCsv(const std::string &csv_path, common::Result<void> writeTrajectoryCsv(const std::string &csv_path,
const std::vector<common::TrajectorySample> &samples); const std::vector<common::TrajectorySample> &samples);
/**
* @brief 使用 `std::filesystem::path` 新建同步 RM75 轨迹 CSV,目标存在时拒绝覆盖。
*
* @reason 原始采集会话必须保持唯一且可追溯,不能沿用旧接口的截断覆盖语义。
*
* @author 唐永康
* @date 2026-07-10
*
* @param csv_path 新会话内的同步 CSV 路径;调用方保留路径所有权。
* @param samples 已全部保存在内存中的连续七关节样本。
* @return 新文件完整写入、刷新并关闭时成功;目标已存在或任何文件操作失败时返回上下文。
*
* @calls std::filesystem::create_directories、std::filesystem::exists 和 std::ofstream。
* @safety 只写新的本地 CSV,不连接设备,也不触发硬件动作。
* @note 当前格式使用 sample_index、timestamp_ms、J1..J7、TCP 和 source 共 16 列。
*/
common::Result<void> writeTrajectoryCsvWithoutOverwrite(
const std::filesystem::path &csv_path,
const std::vector<common::TrajectorySample> &samples);
common::Result<std::vector<common::TrajectorySample>> loadTrajectoryCsv(const std::string &csv_path); common::Result<std::vector<common::TrajectorySample>> loadTrajectoryCsv(const std::string &csv_path);
common::Result<void> exportTcpPathCpp(const std::string &cpp_path, common::Result<void> exportTcpPathCpp(const std::string &cpp_path,
......
#pragma once
#include "rm_control/common/result.h"
#include <array>
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <string>
namespace rm_control::modules::trajectory {
inline constexpr std::size_t kPlaybackReportO6JointCount = 6U;
using PlaybackReportO6Values =
std::array<std::uint16_t, kPlaybackReportO6JointCount>;
struct ControllerManagedPlaybackReport {
std::string report_identifier;
std::filesystem::path source_file;
std::string source_file_sha256;
bool returned_from_trajectory_end = false;
std::filesystem::path return_source_file;
std::string return_source_file_sha256;
std::size_t trajectory_point_count = 0U;
std::int64_t sample_period_ms = 0;
std::int64_t source_duration_ms = 0;
bool timestamp_is_derived = true;
int plan_speed_percent = 0;
int controller_program_id = 0;
int return_controller_program_id = 0;
double return_observed_execution_duration_ms = 0.0;
std::size_t return_program_status_poll_count = 0U;
double return_minimum_poll_period_ms = 0.0;
double return_maximum_poll_period_ms = 0.0;
double return_average_poll_period_ms = 0.0;
double return_maximum_absolute_poll_jitter_ms = 0.0;
int return_final_controller_state = -1;
double observed_execution_duration_ms = 0.0;
std::size_t program_status_poll_count = 0U;
double minimum_poll_period_ms = 0.0;
double maximum_poll_period_ms = 0.0;
double average_poll_period_ms = 0.0;
double maximum_absolute_poll_jitter_ms = 0.0;
int final_controller_state = -1;
std::uint8_t o6_device_id = 0U;
PlaybackReportO6Values target_positions{};
PlaybackReportO6Values speed_commands{};
PlaybackReportO6Values torque_commands{};
PlaybackReportO6Values final_positions{};
};
/**
* @brief 返回构建目录中的 RM75/O6 控制器托管回放报告目录。
*
* @reason 运行数据必须与源码目录分离,集中默认目录也避免 Application 硬编码本机绝对路径。
*
* @author 唐永康
* @date 2026-07-11
*
* @return CMake 注入的 runtime_data/trajectory_playback_reports 路径。
*
* @calls 不访问文件系统。
* @safety 只构造路径,不连接设备,也不触发机械臂或 O6 动作。
* @note 测试可以显式传入其他目录,不依赖此默认值。
*/
std::filesystem::path DefaultTrajectoryPlaybackReportDirectory();
/**
* @brief 将一次厂商控制器托管回放与 O6 抓握结果保存为不可覆盖 JSON 报告。
*
* @reason 厂商控制器内部不暴露逐点下发时间,因此报告明确记录可观测的程序轮询周期、
* 总执行时长、轨迹摘要和 O6 命令/读回,避免把主机轮询统计误称为逐点 jitter。
*
* @author 唐永康
* @date 2026-07-11
*
* @param report 已完成任务的只读结构化结果,所有时间单位为 millisecond。
* @param output_directory 新报告所在目录;不存在时创建,已有同名报告时拒绝覆盖。
* @return 成功时返回新 JSON 文件路径;字段非法、目录或写入失败时返回上下文错误。
*
* @calls std::filesystem::create_directories 和 std::ofstream。
* @safety 只写新的构建目录报告,不连接设备,也不会继续、暂停或停止任何动作。
* @note `report_identifier` 由调用方生成且只允许字母、数字、下划线和连字符;
* `maximum_absolute_poll_jitter_ms` 是状态轮询抖动,不是控制器内部轨迹点下发抖动。
*/
common::Result<std::filesystem::path>
WriteControllerManagedPlaybackReportWithoutOverwrite(
const ControllerManagedPlaybackReport &report,
const std::filesystem::path &output_directory);
} // namespace rm_control::modules::trajectory
#pragma once
#include "rm_control/common/result.h"
#include "rm_control/common/robot_types.h"
#include <cstddef>
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
namespace rm_control::modules::trajectory {
inline constexpr std::int64_t kRealmanVendorRawUnitsPerDegree = 1000;
inline constexpr const char *kRealmanVendorDragTrajectorySource =
"realman_rm_save_trajectory_jsonl";
struct VendorDragTrajectoryParseResult {
std::filesystem::path source_file;
std::vector<common::TrajectorySample> samples;
std::size_t trajectory_point_count = 0U;
std::uintmax_t source_file_size_bytes = 0U;
std::string source_file_sha256;
std::string source = kRealmanVendorDragTrajectorySource;
std::int64_t sample_period_ms = 0;
std::int64_t raw_units_per_degree = kRealmanVendorRawUnitsPerDegree;
bool timestamp_is_derived = true;
};
/**
* @brief 严格解析 RealMan 厂商拖动轨迹 JSONL,并生成 RM75 七轴工程样本。
*
* @reason 厂商原始文件只有七个整数关节值,没有时间戳和单位元数据;调用方必须显式提供采样周期,解析器再以可追溯比例转换为 degree 并标记时间戳为推导值。
*
* @author 唐永康
* @date 2026-07-10
*
* @param file_path 厂商原始 JSONL 普通文件;本函数只读该路径且拒绝符号链接、目录和空文件。
* @param sample_period_ms 相邻样本的明确采样周期,单位 millisecond,必须大于 0。
* @param raw_units_per_degree 每 degree 对应的厂商整数单位数,必须大于 0;本项目当前默认显式声明为 1000。
* @return 成功时返回七轴 degree 样本、推导时间戳、点数、原文件字节数与 SHA-256;任何格式、范围、文件一致性或 I/O 错误均失败。
*
* @calls CalculateFileSha256、std::filesystem::symlink_status、file_size 和 last_write_time。
* @safety 仅离线读取和转换文件,不连接机械臂,也不会触发拖动示教或轨迹回放。
* @note 每个物理行必须严格为 `{"point":[七个 JSON 整数]}`,空行也会失败;原格式没有 TCP 位姿,因此输出样本的 tcp_pose 保持默认零值,不能据此执行 TCP 回放。
*/
common::Result<VendorDragTrajectoryParseResult> ParseVendorDragTrajectoryJsonLines(
const std::filesystem::path &file_path,
std::int64_t sample_period_ms,
std::int64_t raw_units_per_degree = kRealmanVendorRawUnitsPerDegree);
} // namespace rm_control::modules::trajectory
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
/**
* @file json_runner_cli_config.cpp
* @brief RM JSON 指令运行器命令行参数解析实现。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 本文件实现 `rm_json_runner` 的参数解析。实现代码只处理 `argc/argv`,
* 不读取 JSON 文件、不连接 RealMan SDK、不访问真实机械臂。这样可以把入口文件
* 控制在依赖装配和流程启动范围内,也便于通过普通单元测试验证参数边界。
*
* 设计原则:
* - 帮助、错误和正常参数路径都返回结构化结果;
* - 不在解析函数中调用 `std::exit`;
* - 不在解析函数中打印错误,错误文本交给调用方统一输出;
* - 不改变原有命令行兼容性。
*
* @dependencies
* - rm_control/config/json_runner_cli_config.h
*/
#include "rm_control/config/json_runner_cli_config.h"
#include <ostream>
#include <string>
namespace rm_control::config {
namespace {
/**
* @brief 将可能为空的进程名转换为可显示文本。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 命令行 usage 需要展示程序名,但测试或异常调用场景可能传入空指针。
* 该函数集中处理空指针和空字符串,避免每个 usage 函数重复写保护逻辑。
*
* @param[in] program_name `main()` 传入的 `argv[0]`,允许为空指针。
* @param[in] fallback_name 程序名不可用时使用的默认名称。
*
* @return 可安全打印的程序名字符串。
*
* @throws 本函数不主动抛出异常;字符串构造失败属于标准库内存异常。
*
* @note
* 该函数不做路径裁剪,保留调用方传入的原始进程名。
*
* @warning
* 不要在此函数中读取 `/proc` 或环境变量,避免 CLI 解析依赖运行环境。
*
* @thread_safety
* 函数只读输入并返回局部对象,线程安全。
*
* @par 边界条件
* `program_name == nullptr` 或 `program_name[0] == '\0'` 时返回 fallback。
*
* @par 后续维护
* 如果多个 CLI 模块继续需要该逻辑,可再抽到公共 utils;当前保持局部以减少改动面。
*/
std::string SafeProgramName(const char *program_name, const char *fallback_name) {
if (program_name == nullptr || program_name[0] == '\0') {
return fallback_name;
}
return program_name;
}
} // namespace
void PrintJsonRunnerUsage(const char *program_name, std::ostream &out) {
const std::string display_name = SafeProgramName(program_name, "rm_json_runner");
out << "Usage: " << display_name << " <commands.json> [--validate-only]\n"
<< "\n"
<< "Examples:\n"
<< " " << display_name << " ../config/sample_readonly.json --validate-only\n"
<< " " << display_name << " ../config/sample_readonly.json\n"
<< "\n"
<< "Safety:\n"
<< " JSON defaults should keep dry_run=true and allow_motion=false first.\n"
<< " Set safety.dry_run=false and safety.allow_motion=true only after checking the target.\n";
}
common::Result<JsonRunnerCliConfig> ParseJsonRunnerCliArguments(int argc, char **argv) {
JsonRunnerCliConfig config;
if (argc == 2) {
const std::string first_argument = argv[1] == nullptr ? "" : argv[1];
if (first_argument == "-h" || first_argument == "--help") {
config.help_requested = true;
return common::Result<JsonRunnerCliConfig>::success(config);
}
}
if (argc < 2 || argc > 3) {
return common::Result<JsonRunnerCliConfig>::failure(
"expected <commands.json> and optional --validate-only");
}
config.command_file_path = argv[1] == nullptr ? "" : argv[1];
if (config.command_file_path.empty()) {
return common::Result<JsonRunnerCliConfig>::failure("commands.json path must not be empty");
}
if (argc == 3) {
const std::string option = argv[2] == nullptr ? "" : argv[2];
if (option != "--validate-only") {
return common::Result<JsonRunnerCliConfig>::failure("unknown option: " + option);
}
config.validate_only = true;
}
return common::Result<JsonRunnerCliConfig>::success(config);
}
} // namespace rm_control::config
/**
* @file trajectory_convert_cli_config.cpp
* @brief RM75 轨迹 CSV 转 C++ waypoint 程序命令行参数解析实现。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 本文件实现 `rm_trajectory_convert` 的参数解析和默认输出路径推导。该工具是离线工具,
* 不连接真实机械臂;解析函数也不读取 CSV 文件,文件存在性和格式错误由 trajectory
* 模块在加载阶段返回。
*
* 设计原则:
* - 参数解析和文件转换分离;
* - 默认输出路径由 trajectory 存储模块统一生成;
* - 不在解析函数中打印或退出;
* - 保持 `<input_csv> [output_cpp]` 命令格式不变。
*
* @dependencies
* - rm_control/config/trajectory_convert_cli_config.h
* - rm_control/modules/trajectory/trajectory_file_store.h
*/
#include "rm_control/config/trajectory_convert_cli_config.h"
#include "rm_control/modules/trajectory/trajectory_file_store.h"
#include <ostream>
#include <string>
namespace rm_control::config {
namespace {
/**
* @brief 将进程名转换为可显示文本。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* usage 输出需要程序名,但测试或异常调用可能传入空指针。该函数统一处理空值,
* 让错误路径也能输出明确命令名。
*
* @param[in] program_name `argv[0]`,允许为空。
* @param[in] fallback_name 默认程序名。
*
* @return 可显示程序名。
*
* @throws 本函数不主动抛出异常;字符串分配失败由标准库报告。
*
* @note
* 保留原始路径,不截取 basename。
*
* @warning
* 不访问文件系统,避免 usage 输出依赖运行环境。
*
* @thread_safety
* 函数只使用局部对象,线程安全。
*
* @par 边界条件
* 空指针或空字符串返回 fallback。
*
* @par 后续维护
* 可在公共 utils 稳定后统一复用。
*/
std::string SafeProgramName(const char *program_name, const char *fallback_name) {
if (program_name == nullptr || program_name[0] == '\0') {
return fallback_name;
}
return program_name;
}
/**
* @brief 从路径中提取文件主名。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 默认输出 C++ 文件名需要基于输入 CSV 文件名生成 session name。该函数只做字符串处理,
* 不访问文件系统,因此输入文件即使不存在也可以得到可预测的默认输出路径。
*
* @param[in] path 输入路径字符串,可以是相对路径或绝对路径。
*
* @return 去掉目录和最后一个扩展名后的文件主名。
*
* @throws 本函数不主动抛出异常;字符串操作失败由标准库报告。
*
* @note
* 仅识别 `/` 作为路径分隔符,符合当前 Linux RM 子工程环境。
*
* @warning
* Windows 反斜杠路径不会被当作目录分隔符;如需跨平台支持需扩展此函数。
*
* @thread_safety
* 函数不使用全局状态,线程安全。
*
* @par 边界条件
* 无扩展名时返回文件名本身;点号出现在目录名前时不视作扩展名。
*
* @par 后续维护
* 如需支持多扩展名规则,应保持该函数单一职责,只做路径主名提取。
*/
std::string FileStem(const std::string &path) {
const size_t slash_position = path.find_last_of('/');
const size_t begin_position = slash_position == std::string::npos ? 0 : slash_position + 1;
const size_t dot_position = path.find_last_of('.');
const size_t end_position =
dot_position == std::string::npos || dot_position < begin_position ? path.size() : dot_position;
return path.substr(begin_position, end_position - begin_position);
}
/**
* @brief 根据输入 CSV 路径生成默认 C++ 输出路径。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 该函数使用 trajectory 模块的存储目录规则生成默认 C++ 输出路径。如果默认目录创建失败,
* 函数退化为当前工作目录下的 `<session>_tcp_path.cpp`,避免 CLI 解析阶段直接失败。
*
* @param[in] input_csv_path 输入 CSV 路径。
* @param[in] session_name 已清洗过的会话名。
*
* @return 默认输出 C++ 文件路径。
*
* @throws 本函数不主动抛出异常;字符串和路径构造失败由标准库报告。
*
* @note
* 真正写文件时仍会再次校验父目录和写权限。
*
* @warning
* 该函数可能创建默认轨迹目录,因为 `makeTrajectoryFileSet()` 会确保目录存在。
*
* @thread_safety
* 函数本身无全局状态;并发创建同一目录由底层 `mkdir` 处理。
*
* @par 边界条件
* 默认目录创建失败时返回相对 fallback 文件名。
*
* @par 后续维护
* 如果不希望解析阶段创建目录,应调整 trajectory 模块,提供只构造路径的函数。
*/
std::string BuildDefaultOutputPath(const std::string &input_csv_path,
const std::string &session_name) {
(void)input_csv_path;
auto files = rm_control::modules::trajectory::makeTrajectoryFileSet(
rm_control::modules::trajectory::defaultTrajectoryStorageRoot(), session_name);
if (!files.ok) {
return session_name + "_tcp_path.cpp";
}
return files.value.converted_cpp_path;
}
} // namespace
void PrintTrajectoryConvertUsage(const char *program_name, std::ostream &out) {
const std::string display_name = SafeProgramName(program_name, "rm_trajectory_convert");
out << "Usage: " << display_name << " <input_csv> [output_cpp]\n"
<< "\n"
<< "Converts recorded CSV into a C++ TCP waypoint table.\n"
<< "This offline tool does not connect to the robot.\n";
}
common::Result<TrajectoryConvertCliConfig> ParseTrajectoryConvertCliArguments(int argc, char **argv) {
TrajectoryConvertCliConfig config;
if (argc == 2) {
const std::string first_argument = argv[1] == nullptr ? "" : argv[1];
if (first_argument == "-h" || first_argument == "--help") {
config.help_requested = true;
return common::Result<TrajectoryConvertCliConfig>::success(config);
}
}
if (argc < 2 || argc > 3) {
return common::Result<TrajectoryConvertCliConfig>::failure(
"expected <input_csv> and optional [output_cpp]");
}
config.input_csv_path = argv[1] == nullptr ? "" : argv[1];
if (config.input_csv_path.empty()) {
return common::Result<TrajectoryConvertCliConfig>::failure("input_csv path must not be empty");
}
config.session_name =
rm_control::modules::trajectory::sanitizeSessionName(FileStem(config.input_csv_path));
config.output_cpp_path = argc == 3
? std::string(argv[2] == nullptr ? "" : argv[2])
: BuildDefaultOutputPath(config.input_csv_path, config.session_name);
if (config.output_cpp_path.empty()) {
return common::Result<TrajectoryConvertCliConfig>::failure("output_cpp path must not be empty");
}
return common::Result<TrajectoryConvertCliConfig>::success(config);
}
} // namespace rm_control::config
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
#include "rm_control/core/hardware_execution_gate.h"
#include <sstream>
#include <string>
namespace rm_control::core {
namespace {
constexpr int kMinimumSpeedPercent = 1;
constexpr int kMaximumSpeedPercent = 100;
void AddMissingCondition(bool condition,
const std::string &rejection_reason,
HardwareExecutionGateDecision *decision) {
if (!condition) {
decision->rejection_reasons.push_back(rejection_reason);
}
}
void ValidateConfiguredMaximumSpeed(const HardwareExecutionGateInput &input,
HardwareExecutionGateDecision *decision) {
const bool speed_is_valid = input.configured_max_speed_percent >= kMinimumSpeedPercent &&
input.configured_max_speed_percent <= kMaximumSpeedPercent;
AddMissingCondition(speed_is_valid,
"configured maximum speed percent must be in the range 1..100",
decision);
}
void ValidateRequiredSpeed(const HardwareExecutionGateInput &input,
const HardwareExecutionGateRequirements &requirements,
HardwareExecutionGateDecision *decision) {
if (requirements.required_speed_percent < 0 ||
requirements.required_speed_percent > kMaximumSpeedPercent) {
decision->rejection_reasons.emplace_back(
"required hardware speed percent must be in the range 0..100");
return;
}
if (requirements.required_speed_percent > input.configured_max_speed_percent) {
decision->rejection_reasons.emplace_back(
"required hardware speed percent exceeds configured maximum speed percent");
}
}
std::string JoinRejectionReasons(const std::vector<std::string> &rejection_reasons) {
std::ostringstream message;
message << "hardware execution refused";
for (const auto &reason : rejection_reasons) {
message << "; " << reason;
}
return message.str();
}
} // namespace
HardwareExecutionGateDecision EvaluateHardwareExecutionGate(
const HardwareExecutionGateInput &input,
const HardwareExecutionGateRequirements &requirements) {
HardwareExecutionGateDecision decision;
AddMissingCondition(input.live_requested, "--live was not explicitly requested", &decision);
AddMissingCondition(input.hardware_execution_enabled,
"hardware_execution_enabled is false",
&decision);
AddMissingCondition(input.arm_connected, "robot arm is not connected", &decision);
AddMissingCondition(input.hand_connected, "dexterous hand is not connected", &decision);
AddMissingCondition(input.emergency_stop_verified,
"emergency stop state has not been verified as safe",
&decision);
AddMissingCondition(input.safety_limits_loaded,
"safety limits have not been loaded",
&decision);
AddMissingCondition(input.operator_confirmed,
"operator second confirmation is missing",
&decision);
AddMissingCondition(input.trajectory_validated,
"trajectory has not passed validation",
&decision);
AddMissingCondition(input.workspace_and_virtual_wall_verified,
"workspace and virtual wall have not been verified",
&decision);
ValidateConfiguredMaximumSpeed(input, &decision);
ValidateRequiredSpeed(input, requirements, &decision);
if (requirements.require_native_replay_without_speed_control_acceptance) {
AddMissingCondition(input.native_replay_without_speed_control_accepted,
"native replay without speed control was not explicitly accepted",
&decision);
}
if (requirements.require_native_replay_speed_verification) {
AddMissingCondition(input.native_replay_speed_within_config_verified,
"native replay speed has not been independently verified "
"within the configured limit",
&decision);
}
if (requirements.require_confirmed_trajectory_sample_period) {
AddMissingCondition(input.trajectory_sample_period_confirmed,
"trajectory sample period has not been confirmed",
&decision);
}
if (requirements.require_confirmed_vendor_raw_units_per_degree) {
AddMissingCondition(input.vendor_raw_units_per_degree_confirmed,
"vendor raw-units-per-degree ratio has not been confirmed",
&decision);
}
decision.execution_allowed = decision.rejection_reasons.empty();
return decision;
}
common::Result<void> RequireHardwareExecutionPermission(
const HardwareExecutionGateInput &input,
const HardwareExecutionGateRequirements &requirements) {
const auto decision = EvaluateHardwareExecutionGate(input, requirements);
if (!decision.execution_allowed) {
return common::Result<void>::failure(JoinRejectionReasons(decision.rejection_reasons));
}
return common::Result<void>::success();
}
} // namespace rm_control::core
This diff is collapsed.
#include "rm_control/core/o6_bus_scanner.h"
#include <utility>
namespace rm_control::core {
namespace {
bool MatchesO6IdentityShape(std::uint8_t device_id,
const std::vector<std::uint16_t> &registers) {
if (registers.size() != 6U || registers[0] != 6U) {
return false;
}
const char expected_direction = device_id == 0x27U ? 'R' : 'L';
return registers[5] == static_cast<std::uint16_t>(expected_direction);
}
common::Result<void> ValidateScanConfig(const O6BusScanConfig &config) {
const std::vector<std::uint8_t> expected_device_ids{0x27U, 0x28U};
if (config.device_ids != expected_device_ids ||
config.identity_start_address != 30U ||
config.identity_register_count != 6U) {
return common::Result<void>::failure(
"O6 bus probe is fixed to device IDs 0x27/0x28 and FC04 addresses 30..35");
}
return common::Result<void>::success();
}
O6BusScanResponse ProbeOneDevice(interfaces::IToolModbusBus &tool_bus,
const O6BusScanConfig &config,
std::uint8_t device_id) {
O6BusScanResponse response;
response.device_id = device_id;
const auto multiple = tool_bus.ReadInputRegisters(
device_id, config.identity_start_address,
config.identity_register_count);
response.multiple_read_succeeded = multiple.ok;
if (multiple.ok) {
response.identity_registers = multiple.value;
if (multiple.value.size() != config.identity_register_count) {
response.multiple_read_succeeded = false;
response.multiple_read_error =
"multiple input read returned an unexpected register count";
}
} else {
response.multiple_read_error = multiple.error_message;
}
const auto single = tool_bus.ReadInputRegister(
device_id, config.identity_start_address);
response.single_read_succeeded = single.ok;
if (single.ok) {
response.single_freedom_register = single.value;
} else {
response.single_read_error = single.error_message;
}
response.single_and_multiple_reads_match =
response.multiple_read_succeeded && response.single_read_succeeded &&
response.identity_registers.front() == response.single_freedom_register;
response.matches_o6_identity_shape =
response.single_and_multiple_reads_match &&
MatchesO6IdentityShape(device_id, response.identity_registers);
return response;
}
} // namespace
common::Result<O6BusScanReport> ScanO6IdentityRegisters(
interfaces::IToolModbusBus &tool_bus,
const O6BusScanConfig &config) {
const auto valid = ValidateScanConfig(config);
if (!valid.ok) {
return common::Result<O6BusScanReport>::failure(valid.error_message);
}
O6BusScanReport report;
for (const std::uint8_t device_id : config.device_ids) {
++report.attempted_device_count;
auto response = ProbeOneDevice(tool_bus, config, device_id);
if (response.matches_o6_identity_shape) {
++report.confirmed_o6_device_count;
} else {
++report.failed_device_count;
}
report.responses.push_back(std::move(response));
}
return common::Result<O6BusScanReport>::success(report);
}
} // namespace rm_control::core
This diff is collapsed.
/**
* @file main.cpp
* @brief RM JSON 指令运行器入口文件。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 本文件是 `rm_json_runner` 可执行程序入口,只负责命令行参数解析结果处理、
* JSON 文件加载、RealMan 驱动创建、核心 runner 装配和进程退出码返回。
* 具体 JSON 解析、安全校验和硬件调用分别放在 config、core 和 drivers 层。
*
* 设计原则:
* - `main()` 不包含业务控制逻辑;
* - `--validate-only` 不连接 SDK、不访问真实机械臂;
* - 真实硬件动作必须经过 JSON safety 配置和 `JsonCommandRunner` 校验;
* - 所有失败路径返回明确退出码。
*
* @dependencies
* - rm_control/config/json_command.h
* - rm_control/config/json_runner_cli_config.h
* - rm_control/core/json_command_runner.h
* - rm_control/drivers/realman/realman_robot_arm.h
*/
#include "rm_control/config/json_command.h" #include "rm_control/config/json_command.h"
#include "rm_control/config/json_runner_cli_config.h"
#include "rm_control/core/json_command_runner.h" #include "rm_control/core/json_command_runner.h"
#include "rm_control/drivers/realman/realman_robot_arm.h" #include "rm_control/drivers/realman/realman_robot_arm.h"
#include <cstdlib>
#include <iostream> #include <iostream>
#include <string>
namespace {
void printUsage(const char *program_name) {
std::cout << "Usage: " << program_name << " <commands.json> [--validate-only]\n"
<< "\n"
<< "Examples:\n"
<< " " << program_name << " ../config/sample_commands.json --validate-only\n"
<< " " << program_name << " ../config/sample_commands.json\n"
<< "\n"
<< "Safety:\n"
<< " JSON defaults should keep dry_run=true and allow_motion=false first.\n"
<< " Set safety.dry_run=false and safety.allow_motion=true only after checking the target.\n";
}
} // namespace
/**
* @brief 启动 RM JSON 指令运行器。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 该函数完成进程级调度:先解析 CLI 参数,再加载 JSON 指令文件;如果用户请求
* `--validate-only`,函数在 JSON 结构校验成功后直接返回,不创建 RealMan 驱动。
* 非 validate-only 模式下,函数创建 `RealManRobotArm` 并注入 `JsonCommandRunner`,
* 由 core 层执行安全校验和命令调度。
*
* @param[in] argc 命令行参数数量。
* @param[in] argv 命令行参数数组。
*
* @return 0 执行成功或帮助信息输出成功。
* @return 1 CLI 参数错误或 JSON 文件加载失败。
* @return 2 JSON 命令执行失败。
* @return 64 命令行使用方式错误。
*
* @throws 本函数不主动抛出异常;底层模块通过 `Result` 返回错误。
*
* @note
* `--validate-only` 只解析文件,不连接 SDK,因此适合作为新 JSON 文件的第一步验证。
*
* @warning
* 非 validate-only 模式会连接真实 RM75;真实运动仍由 JSON safety 字段控制。
*
* @thread_safety
* 入口函数按单进程主线程使用设计,不提供多线程重入保证。
*
* @par 边界条件
* 参数数量错误、路径为空、JSON 文件不存在或格式错误都会在连接硬件前失败。
*
* @par 后续维护
* 新增启动参数时应修改 CLI 配置模块,不应把解析逻辑写回本函数。
*/
int main(int argc, char **argv) { int main(int argc, char **argv) {
// main 只负责参数解析、加载 JSON、创建具体驱动并启动 runner。 const auto cli_config = rm_control::config::ParseJsonRunnerCliArguments(argc, argv);
// 具体安全校验和硬件调用都放在 core/drivers 中,避免入口文件膨胀。 if (!cli_config.ok) {
if (argc < 2 || argc > 3) { std::cerr << "Invalid arguments: " << cli_config.error_message << '\n';
printUsage(argv[0]); rm_control::config::PrintJsonRunnerUsage(argv == nullptr ? nullptr : argv[0], std::cerr);
return 64; return 64;
} }
if (cli_config.value.help_requested) {
const std::string first_arg = argv[1] == nullptr ? "" : argv[1]; rm_control::config::PrintJsonRunnerUsage(argv == nullptr ? nullptr : argv[0], std::cout);
if (first_arg == "-h" || first_arg == "--help") {
printUsage(argv[0]);
return 0; return 0;
} }
bool validate_only = false; const auto program = rm_control::config::loadCommandProgramFromFile(
if (argc == 3) { cli_config.value.command_file_path);
const std::string option = argv[2] == nullptr ? "" : argv[2];
if (option != "--validate-only") {
std::cerr << "Unknown option: " << option << '\n';
printUsage(argv[0]);
return 64;
}
validate_only = true;
}
auto program = rm_control::config::loadCommandProgramFromFile(first_arg);
if (!program.ok) { if (!program.ok) {
std::cerr << "Failed to load JSON command file: " << program.error_message << '\n'; std::cerr << "Failed to load JSON command file: " << program.error_message << '\n';
return 1; return 1;
...@@ -56,18 +90,14 @@ int main(int argc, char **argv) { ...@@ -56,18 +90,14 @@ int main(int argc, char **argv) {
std::cout << "JSON command file parsed successfully. command_count=" std::cout << "JSON command file parsed successfully. command_count="
<< program.value.commands.size() << '\n'; << program.value.commands.size() << '\n';
if (validate_only) { if (cli_config.value.validate_only) {
// 只校验 JSON 结构,不连接 SDK、不访问真实机械臂。
// 调试新 JSON 时建议先跑这个模式。
std::cout << "validate-only mode: no SDK connection or command execution.\n"; std::cout << "validate-only mode: no SDK connection or command execution.\n";
return 0; return 0;
} }
// 这里选择 RealMan 驱动。以后更换机械臂品牌,只需要替换这个具体实现,
// JsonCommandRunner 和 JSON 格式不需要变。
rm_control::drivers::realman::RealManRobotArm robot; rm_control::drivers::realman::RealManRobotArm robot;
rm_control::core::JsonCommandRunner runner(robot); rm_control::core::JsonCommandRunner runner(robot);
auto result = runner.run(program.value, std::cout); const auto result = runner.run(program.value, std::cout);
if (!result.ok) { if (!result.ok) {
std::cerr << "Command execution failed: " << result.error_message << '\n'; std::cerr << "Command execution failed: " << result.error_message << '\n';
return 2; return 2;
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
/**
* @file sha256_file_checksum.cpp
* @brief 使用 OpenSSL EVP 流式计算轨迹文件 SHA-256。
*
* @author 唐永康
* @date 2026-07-10
*/
#include "rm_control/modules/trajectory/sha256_file_checksum.h"
#include <openssl/evp.h>
#include <array>
#include <fstream>
#include <iomanip>
#include <memory>
#include <sstream>
namespace rm_control::modules::trajectory {
namespace {
constexpr std::size_t kDigestReadBufferSize = 64U * 1024U;
std::string PathForError(const std::filesystem::path &path) {
return path.string();
}
} // namespace
common::Result<std::string> CalculateFileSha256(const std::filesystem::path &file_path) {
if (file_path.empty()) {
return common::Result<std::string>::failure("SHA-256 file path must not be empty");
}
std::error_code filesystem_error;
const bool is_regular_file = std::filesystem::is_regular_file(file_path, filesystem_error);
if (filesystem_error) {
return common::Result<std::string>::failure(
"failed to inspect SHA-256 input " + PathForError(file_path) + ": " +
filesystem_error.message());
}
if (!is_regular_file) {
return common::Result<std::string>::failure(
"SHA-256 input is not a regular file: " + PathForError(file_path));
}
std::ifstream input(file_path, std::ios::binary);
if (!input) {
return common::Result<std::string>::failure(
"failed to open SHA-256 input: " + PathForError(file_path));
}
using DigestContext = std::unique_ptr<EVP_MD_CTX, decltype(&EVP_MD_CTX_free)>;
DigestContext digest_context(EVP_MD_CTX_new(), &EVP_MD_CTX_free);
if (!digest_context) {
return common::Result<std::string>::failure(
"OpenSSL failed to allocate SHA-256 digest context for " + PathForError(file_path));
}
if (EVP_DigestInit_ex(digest_context.get(), EVP_sha256(), nullptr) != 1) {
return common::Result<std::string>::failure(
"OpenSSL failed to initialize SHA-256 for " + PathForError(file_path));
}
std::array<char, kDigestReadBufferSize> buffer{};
while (input) {
input.read(buffer.data(), static_cast<std::streamsize>(buffer.size()));
const std::streamsize bytes_read = input.gcount();
if (bytes_read > 0 &&
EVP_DigestUpdate(digest_context.get(), buffer.data(),
static_cast<std::size_t>(bytes_read)) != 1) {
return common::Result<std::string>::failure(
"OpenSSL failed to update SHA-256 for " + PathForError(file_path));
}
}
if (!input.eof()) {
return common::Result<std::string>::failure(
"failed while reading SHA-256 input: " + PathForError(file_path));
}
std::array<unsigned char, EVP_MAX_MD_SIZE> digest{};
unsigned int digest_size = 0;
if (EVP_DigestFinal_ex(digest_context.get(), digest.data(), &digest_size) != 1) {
return common::Result<std::string>::failure(
"OpenSSL failed to finalize SHA-256 for " + PathForError(file_path));
}
std::ostringstream hexadecimal_digest;
hexadecimal_digest << std::hex << std::setfill('0');
for (unsigned int index = 0; index < digest_size; ++index) {
hexadecimal_digest << std::setw(2) << static_cast<unsigned int>(digest[index]);
}
return common::Result<std::string>::success(hexadecimal_digest.str());
}
} // namespace rm_control::modules::trajectory
This diff is collapsed.
This diff is collapsed.
#include "flight_robot/realman/realman_arm_adapter.h"
#include "flight_robot/linker_hand/linker_hand_adapter.h"
#include "rm_control/common/robot_types.h"
#include "rm_control/core/o6_bus_scanner.h"
#include <iomanip>
#include <iostream>
namespace {
void PrintProbeResponse(const rm_control::core::O6BusScanResponse &response) {
std::cout << "O6 candidate 0x" << std::hex << std::uppercase
<< static_cast<int>(response.device_id) << std::dec << std::nouppercase
<< ": multi=" << (response.multiple_read_succeeded ? "ok" : "failed");
if (response.multiple_read_succeeded) {
std::cout << " values=[";
for (std::size_t index = 0U; index < response.identity_registers.size(); ++index) {
std::cout << (index == 0U ? "" : ",")
<< response.identity_registers[index];
}
std::cout << ']';
} else {
std::cout << " error={" << response.multiple_read_error << '}';
}
std::cout << ", single=" << (response.single_read_succeeded ? "ok" : "failed");
if (response.single_read_succeeded) {
std::cout << " value=" << response.single_freedom_register;
} else {
std::cout << " error={" << response.single_read_error << '}';
}
std::cout << ", cross_match="
<< (response.single_and_multiple_reads_match ? "true" : "false")
<< ", confirmed_o6="
<< (response.matches_o6_identity_shape ? "true" : "false") << '\n';
}
void PrintRightHandReadiness(
const rm_control::interfaces::DexterousHandReadiness &readiness) {
std::cout << "O6 right-hand readiness confirmed: device=0x27, freedom="
<< readiness.freedom << ", direction=" << readiness.direction
<< ", version=" << readiness.hand_version << ", positions=[";
for (std::size_t index = 0U; index < readiness.joint_positions.size(); ++index) {
std::cout << (index == 0U ? "" : ",")
<< readiness.joint_positions[index];
}
std::cout << "]\n";
}
} // namespace
int main() {
flight_robot::realman::RealmanArmAdapter robot;
const auto connected = robot.connect(rm_control::common::RobotConnectionConfig{});
if (!connected.ok) {
std::cerr << "RM75 connection failed: " << connected.error_message << '\n';
return 1;
}
const auto information = robot.getRobotInfo();
if (!information.ok || information.value.controller_version != 3) {
std::cerr << "O6 probe requires a connected generation-3 RM75: "
<< (information.ok ? "unexpected controller generation"
: information.error_message) << '\n';
return 1;
}
rm_control::interfaces::ToolModbusBusConfiguration configuration;
configuration.power_cycle_tool = true;
configuration.power_off_delay_ms = 500;
configuration.startup_delay_ms = 2000;
const auto configured = robot.Configure(configuration);
if (!configured.ok) {
std::cerr << "O6 tool bus initialization failed: "
<< configured.error_message << '\n';
return 1;
}
std::cout << "Tool bus ready: port=" << configured.value.port
<< ", mode=" << configured.value.mode
<< ", baudrate=" << configured.value.baudrate
<< ", voltage_type=" << configured.value.voltage_type
<< ", timeout_units=" << configured.value.timeout_units << '\n';
const auto report = rm_control::core::ScanO6IdentityRegisters(
robot, rm_control::core::O6BusScanConfig{});
if (!report.ok) {
std::cerr << "O6 targeted probe failed: " << report.error_message << '\n';
return 1;
}
for (const auto &response : report.value.responses) {
PrintProbeResponse(response);
}
flight_robot::linker_hand::LinkerHandAdapter hand(
robot, rm_control::interfaces::DexterousHandSide::Right);
const auto readiness = hand.ReadReadiness();
if (readiness.ok) {
PrintRightHandReadiness(readiness.value);
} else {
std::cerr << "O6 single-register readiness failed: "
<< readiness.error_message << '\n';
}
std::cout << "O6 probe summary: attempted="
<< report.value.attempted_device_count
<< ", confirmed=" << report.value.confirmed_o6_device_count
<< ", unconfirmed=" << report.value.failed_device_count << '\n';
return readiness.ok ? 0 : 2;
}
#include "flight_robot/linker_hand/linker_hand_adapter.h"
#include "flight_robot/realman/realman_arm_adapter.h"
#include "rm_control/common/robot_types.h"
#include <chrono>
#include <cstdlib>
#include <iostream>
#include <string>
#include <thread>
namespace {
constexpr rm_control::interfaces::O6JointPose kOpenPose{
255U, 255U, 0U, 255U, 255U, 255U};
constexpr rm_control::interfaces::O6JointPose kGripPose{
140U, 100U, 0U, 55U, 55U, 55U};
constexpr rm_control::interfaces::O6JointPose kSpeedCommands{
20U, 20U, 20U, 20U, 20U, 20U};
constexpr rm_control::interfaces::O6JointPose kTorqueCommands{
20U, 20U, 20U, 20U, 20U, 20U};
constexpr std::uint16_t kPositionTolerance = 5U;
void PrintPose(const char *label,
const rm_control::interfaces::O6JointPose &pose) {
std::cout << label << "=[";
for (std::size_t index = 0U; index < pose.size(); ++index) {
std::cout << (index == 0U ? "" : ",") << pose[index];
}
std::cout << "]\n";
}
bool PoseReached(const rm_control::interfaces::O6JointPose &actual,
const rm_control::interfaces::O6JointPose &target) {
for (std::size_t index = 0U; index < actual.size(); ++index) {
const int delta = std::abs(static_cast<int>(actual[index]) -
static_cast<int>(target[index]));
if (delta > static_cast<int>(kPositionTolerance)) {
return false;
}
}
return true;
}
rm_control::common::Result<void> InitializeToolBus(
flight_robot::realman::RealmanArmAdapter &robot) {
rm_control::interfaces::ToolModbusBusConfiguration configuration;
configuration.power_cycle_tool = false;
configuration.power_off_delay_ms = 500;
configuration.startup_delay_ms = 2000;
const auto configured = robot.Configure(configuration);
return configured.ok
? rm_control::common::Result<void>::success()
: rm_control::common::Result<void>::failure(configured.error_message);
}
rm_control::common::Result<rm_control::interfaces::DexterousHandReadiness>
WaitForGrip(flight_robot::linker_hand::LinkerHandAdapter &hand) {
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::seconds(20);
while (std::chrono::steady_clock::now() < deadline) {
const auto readiness = hand.ReadReadiness();
if (!readiness.ok || PoseReached(readiness.value.joint_positions, kGripPose)) {
return readiness;
}
PrintPose("O6 moving", readiness.value.joint_positions);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
return rm_control::common::Result<
rm_control::interfaces::DexterousHandReadiness>::failure(
"O6 did not reach the conservative grip pose within 20 seconds");
}
rm_control::common::Result<rm_control::interfaces::DexterousHandReadiness>
WaitForOpen(flight_robot::linker_hand::LinkerHandAdapter &hand) {
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::seconds(20);
while (std::chrono::steady_clock::now() < deadline) {
const auto readiness = hand.ReadReadiness();
if (!readiness.ok || PoseReached(readiness.value.joint_positions, kOpenPose)) {
return readiness;
}
PrintPose("O6 opening", readiness.value.joint_positions);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
return rm_control::common::Result<
rm_control::interfaces::DexterousHandReadiness>::failure(
"O6 did not reach the open byte-order probe within 20 seconds");
}
rm_control::common::Result<void> CommandPose(
flight_robot::linker_hand::LinkerHandAdapter &hand,
const rm_control::interfaces::O6JointPose &pose) {
return hand.CommandMotion(
rm_control::interfaces::O6MotionCommand{
pose, kSpeedCommands, kTorqueCommands});
}
} // namespace
int main(int argc, char **argv) {
if (argc != 2 || std::string(argv[1]) != "--live-grip") {
std::cerr << "Usage: " << (argc > 0 ? argv[0] : "rm_o6_grip_demo")
<< " --live-grip\n";
return 64;
}
flight_robot::realman::RealmanArmAdapter robot;
const auto connected = robot.connect(rm_control::common::RobotConnectionConfig{});
const auto initialized = connected.ok
? InitializeToolBus(robot)
: rm_control::common::Result<void>::failure(connected.error_message);
if (!initialized.ok) {
std::cerr << "O6 grip demo initialization failed: "
<< initialized.error_message << '\n';
return 1;
}
flight_robot::linker_hand::LinkerHandAdapter hand(
robot, rm_control::interfaces::DexterousHandSide::Right);
const auto before = hand.ReadReadiness();
if (!before.ok) {
std::cerr << "O6 pre-grip readiness failed: " << before.error_message << '\n';
return 2;
}
PrintPose("O6 before grip", before.value.joint_positions);
const auto opened_command = CommandPose(hand, kOpenPose);
const auto opened = opened_command.ok
? WaitForOpen(hand)
: rm_control::common::Result<
rm_control::interfaces::DexterousHandReadiness>::failure(
opened_command.error_message);
if (!opened.ok) {
std::cerr << "O6 safe open probe failed: " << opened.error_message << '\n';
return 3;
}
PrintPose("O6 open reached", opened.value.joint_positions);
std::this_thread::sleep_for(std::chrono::seconds(1));
const auto commanded = CommandPose(hand, kGripPose);
if (!commanded.ok) {
std::cerr << "O6 grip command failed: " << commanded.error_message << '\n';
return 4;
}
const auto after = WaitForGrip(hand);
if (!after.ok) {
std::cerr << "O6 grip verification failed: " << after.error_message << '\n';
return 5;
}
PrintPose("O6 grip reached", after.value.joint_positions);
return 0;
}
/**
* @file trajectory_convert_main.cpp
* @brief RM75 轨迹 CSV 转 C++ waypoint 工具入口文件。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 本文件是离线轨迹转换工具入口,只负责 CLI 参数解析结果处理、调用 trajectory
* 模块读取 CSV、导出 C++ waypoint 文件并返回退出码。它不连接真实机械臂,
* 也不执行任何运动。
*
* 设计原则:
* - 参数解析放在 config 层;
* - CSV 读取和 C++ 文件生成放在 modules/trajectory 层;
* - `main()` 不做字符串路径拆解;
* - 所有失败路径输出明确错误。
*
* @dependencies
* - rm_control/config/trajectory_convert_cli_config.h
* - rm_control/modules/trajectory/trajectory_file_store.h
*/
#include "rm_control/config/trajectory_convert_cli_config.h"
#include "rm_control/modules/trajectory/trajectory_file_store.h" #include "rm_control/modules/trajectory/trajectory_file_store.h"
#include <iostream> #include <iostream>
#include <string>
namespace {
void printUsage(const char *program_name) {
std::cout << "Usage: " << program_name << " <input_csv> [output_cpp]\n"
<< "\n"
<< "Converts recorded CSV into a C++ TCP waypoint table. It does not connect to the robot.\n";
}
std::string fileStem(const std::string &path) {
const size_t slash = path.find_last_of('/');
const size_t begin = slash == std::string::npos ? 0 : slash + 1;
const size_t dot = path.find_last_of('.');
const size_t end = dot == std::string::npos || dot < begin ? path.size() : dot;
return path.substr(begin, end - begin);
}
std::string defaultOutputPath(const std::string &input_csv) {
const std::string session_name = rm_control::modules::trajectory::sanitizeSessionName(fileStem(input_csv));
auto files = rm_control::modules::trajectory::makeTrajectoryFileSet(
rm_control::modules::trajectory::defaultTrajectoryStorageRoot(), session_name);
if (!files.ok) {
return session_name + "_tcp_path.cpp";
}
return files.value.converted_cpp_path;
}
} // namespace
/**
* @brief 启动离线轨迹转换工具。
*
* @author mashiro
* @date 2026-07-09
* @last_modified 2026-07-09
*
* @details
* 该函数先解析 CLI 参数,再加载轨迹 CSV,最后导出 C++ TCP waypoint 表。
* 由于该工具只处理文件,不连接真实机械臂,因此可作为轨迹录制后的离线验证步骤。
*
* @param[in] argc 命令行参数数量。
* @param[in] argv 命令行参数数组。
*
* @return 0 转换成功或帮助信息输出成功。
* @return 1 CSV 加载失败。
* @return 2 C++ 文件导出失败。
* @return 64 命令行参数错误。
*
* @throws 本函数不主动抛出异常;底层模块通过 `Result` 返回错误。
*
* @note
* 输入 CSV 文件必须符合 trajectory 模块写出的固定列格式。
*
* @warning
* 本函数不会验证 waypoint 是否适合真实运动;后续执行轨迹前必须另做安全检查。
*
* @thread_safety
* 按主线程入口函数设计,不提供多线程重入保证。
*
* @par 边界条件
* 路径为空、文件不存在、CSV 为空、输出目录无权限都会返回非零退出码。
*
* @par 后续维护
* 新增输出格式时应扩展 CLI 配置和 trajectory 模块,不要在入口文件写分支逻辑。
*/
int main(int argc, char **argv) { int main(int argc, char **argv) {
if (argc < 2 || argc > 3) { const auto cli_config = rm_control::config::ParseTrajectoryConvertCliArguments(argc, argv);
printUsage(argv[0]); if (!cli_config.ok) {
std::cerr << "Invalid arguments: " << cli_config.error_message << '\n';
rm_control::config::PrintTrajectoryConvertUsage(argv == nullptr ? nullptr : argv[0], std::cerr);
return 64; return 64;
} }
const std::string first_arg = argv[1] == nullptr ? "" : argv[1]; if (cli_config.value.help_requested) {
if (first_arg == "-h" || first_arg == "--help") { rm_control::config::PrintTrajectoryConvertUsage(argv == nullptr ? nullptr : argv[0], std::cout);
printUsage(argv[0]);
return 0; return 0;
} }
const std::string input_csv = first_arg; const auto samples = rm_control::modules::trajectory::loadTrajectoryCsv(
const std::string output_cpp = argc == 3 ? std::string(argv[2]) : defaultOutputPath(input_csv); cli_config.value.input_csv_path);
auto samples = rm_control::modules::trajectory::loadTrajectoryCsv(input_csv);
if (!samples.ok) { if (!samples.ok) {
std::cerr << "Failed to load CSV: " << samples.error_message << '\n'; std::cerr << "Failed to load CSV: " << samples.error_message << '\n';
return 1; return 1;
} }
const std::string session_name = rm_control::modules::trajectory::sanitizeSessionName(fileStem(input_csv)); const auto export_result = rm_control::modules::trajectory::exportTcpPathCpp(
auto export_result = rm_control::modules::trajectory::exportTcpPathCpp( cli_config.value.output_cpp_path, samples.value, cli_config.value.session_name);
output_cpp, samples.value, session_name);
if (!export_result.ok) { if (!export_result.ok) {
std::cerr << "Failed to export C++: " << export_result.error_message << '\n'; std::cerr << "Failed to export C++: " << export_result.error_message << '\n';
return 2; return 2;
} }
std::cout << "Loaded samples: " << samples.value.size() << '\n' std::cout << "Loaded samples: " << samples.value.size() << '\n'
<< "C++ TCP path: " << output_cpp << '\n'; << "C++ TCP path: " << cli_config.value.output_cpp_path << '\n';
return 0; return 0;
} }
This diff is collapsed.
#pragma once
#include "rm_control/interfaces/dexterous_hand.h"
namespace rm_control::tests {
class MockDexterousHand final : public interfaces::IDexterousHand {
public:
common::Result<interfaces::DexterousHandReadiness> readiness_result =
common::Result<interfaces::DexterousHandReadiness>::failure(
"mock readiness was not configured");
common::Result<void> command_result = common::Result<void>::success();
int readiness_calls = 0;
int command_calls = 0;
interfaces::O6MotionCommand last_motion_command{};
/** @copydoc interfaces::IDexterousHand::ReadReadiness */
common::Result<interfaces::DexterousHandReadiness>
ReadReadiness() override {
++readiness_calls;
return readiness_result;
}
/** @copydoc interfaces::IDexterousHand::CommandMotion */
common::Result<void> CommandMotion(
const interfaces::O6MotionCommand &command) override {
++command_calls;
last_motion_command = command;
return command_result;
}
};
} // namespace rm_control::tests
This diff is collapsed.
#pragma once
#include "rm_control/interfaces/tool_modbus_bus.h"
#include <cstddef>
#include <cstdint>
#include <map>
#include <string>
#include <utility>
#include <vector>
namespace rm_control::tests {
struct InputRegisterReadCall {
std::uint8_t device_id = 0U;
std::uint16_t start_address = 0U;
std::size_t register_count = 0U;
};
struct HoldingRegisterReadCall {
std::uint8_t device_id = 0U;
std::uint16_t start_address = 0U;
std::size_t register_count = 0U;
};
struct HoldingRegisterWriteCall {
std::uint8_t device_id = 0U;
std::uint16_t start_address = 0U;
std::vector<std::uint16_t> values;
};
class MockToolModbusBus final : public interfaces::IToolModbusBus {
public:
common::Result<interfaces::ToolModbusBusStatus> status_result =
common::Result<interfaces::ToolModbusBusStatus>::success(
interfaces::ToolModbusBusStatus{true, 1, 1, 115200, 3, 10});
std::map<std::uint16_t, common::Result<std::vector<std::uint16_t>>>
input_register_results;
std::map<std::uint16_t, common::Result<std::uint16_t>>
single_input_register_results;
std::map<std::uint16_t, common::Result<std::vector<std::uint16_t>>>
holding_register_results;
std::map<std::uint16_t, common::Result<void>> write_results_by_start_address;
common::Result<void> write_result = common::Result<void>::success();
int status_calls = 0;
int configure_calls = 0;
interfaces::ToolModbusBusConfiguration last_configuration;
std::vector<InputRegisterReadCall> read_calls;
std::vector<InputRegisterReadCall> single_read_calls;
std::vector<HoldingRegisterReadCall> holding_read_calls;
std::vector<HoldingRegisterWriteCall> write_calls;
std::vector<std::string> operation_calls;
/** @copydoc interfaces::IToolModbusBus::Configure */
common::Result<interfaces::ToolModbusBusStatus> Configure(
const interfaces::ToolModbusBusConfiguration &configuration) override {
++configure_calls;
last_configuration = configuration;
operation_calls.push_back("configure");
return status_result;
}
/** @copydoc interfaces::IToolModbusBus::GetStatus */
common::Result<interfaces::ToolModbusBusStatus> GetStatus() override {
++status_calls;
operation_calls.push_back("get_status");
return status_result;
}
/** @copydoc interfaces::IToolModbusBus::ReadInputRegister */
common::Result<std::uint16_t> ReadInputRegister(
std::uint8_t device_id,
std::uint16_t address) override {
single_read_calls.push_back(InputRegisterReadCall{device_id, address, 1U});
operation_calls.push_back("read_single_input:" + std::to_string(address));
const auto configured = single_input_register_results.find(address);
if (configured != single_input_register_results.end()) {
return configured->second;
}
for (const auto &group : input_register_results) {
if (!group.second.ok && address == group.first) {
return common::Result<std::uint16_t>::failure(
group.second.error_message);
}
if (!group.second.ok || address < group.first) {
continue;
}
const std::size_t offset = address - group.first;
if (offset < group.second.value.size()) {
return common::Result<std::uint16_t>::success(
group.second.value[offset]);
}
}
return common::Result<std::uint16_t>::failure(
"mock single input register was not configured");
}
/** @copydoc interfaces::IToolModbusBus::ReadInputRegisters */
common::Result<std::vector<std::uint16_t>> ReadInputRegisters(
std::uint8_t device_id,
std::uint16_t start_address,
std::size_t register_count) override {
read_calls.push_back(
InputRegisterReadCall{device_id, start_address, register_count});
operation_calls.push_back(
"read_input:" + std::to_string(start_address));
const auto configured = input_register_results.find(start_address);
if (configured == input_register_results.end()) {
return common::Result<std::vector<std::uint16_t>>::failure(
"mock input register group was not configured");
}
return configured->second;
}
/** @copydoc interfaces::IToolModbusBus::ReadHoldingRegisters */
common::Result<std::vector<std::uint16_t>> ReadHoldingRegisters(
std::uint8_t device_id,
std::uint16_t start_address,
std::size_t register_count) override {
holding_read_calls.push_back(
HoldingRegisterReadCall{device_id, start_address, register_count});
operation_calls.push_back(
"read_holding:" + std::to_string(start_address));
const auto configured = holding_register_results.find(start_address);
if (configured == holding_register_results.end()) {
return common::Result<std::vector<std::uint16_t>>::failure(
"mock holding register group was not configured");
}
return configured->second;
}
/** @copydoc interfaces::IToolModbusBus::WriteHoldingRegisters */
common::Result<void> WriteHoldingRegisters(
std::uint8_t device_id,
std::uint16_t start_address,
const std::vector<std::uint16_t> &values) override {
write_calls.push_back(
HoldingRegisterWriteCall{device_id, start_address, values});
operation_calls.push_back(
"write_holding:" + std::to_string(start_address));
const auto configured = write_results_by_start_address.find(start_address);
if (configured != write_results_by_start_address.end()) {
return configured->second;
}
return write_result;
}
};
} // namespace rm_control::tests
This diff is collapsed.
This diff is collapsed.
#include "rm_control/config/drag_teach_safety_config.h"
#include <filesystem>
#include <fstream>
#include <iostream>
#include <string>
#ifndef RM_TEST_OUTPUT_DIR
#define RM_TEST_OUTPUT_DIR "."
#endif
namespace {
struct TestContext {
int failures = 0;
void Expect(bool condition, const std::string &message) {
if (!condition) {
++failures;
std::cerr << "[FAIL] " << message << '\n';
}
}
};
std::filesystem::path WriteConfig(const std::string &name, const std::string &text) {
const std::filesystem::path directory =
std::filesystem::path(RM_TEST_OUTPUT_DIR) / "drag_teach_safety_config";
std::filesystem::create_directories(directory);
const std::filesystem::path path = directory / name;
std::ofstream output(path, std::ios::trunc);
output << text;
return path;
}
std::string ValidConfigText() {
return R"({
"hardware_execution_enabled": false,
"emergency_stop_verified": false,
"safety_limits_loaded": true,
"workspace_and_virtual_wall_verified": true,
"native_replay_without_speed_control_accepted": false,
"trajectory_sample_period_confirmed": false,
"vendor_raw_units_per_degree_confirmed": false,
"max_speed_percent": 20,
"vendor_raw_units_per_degree": 1000,
"maximum_origin_move_delta_deg": 30.0,
"max_joint_jump_deg": 5.0,
"max_joint_velocity_deg_per_second": 30.0,
"max_joint_acceleration_deg_per_second_squared": 100.0,
"maximum_absolute_force_newton": 100.0,
"maximum_absolute_torque_newton_meter": 20.0,
"maximum_o6_speed_command": 64,
"maximum_o6_torque_command": 80
})";
}
void TestLoadsExplicitDisabledConfiguration(TestContext *context) {
const auto path = WriteConfig("valid.json", ValidConfigText());
const auto result = rm_control::config::LoadDragTeachSafetyConfig(path);
context->Expect(result.ok, "valid safety config should load");
if (!result.ok) {
return;
}
context->Expect(!result.value.hardware_execution_enabled,
"hardware execution must remain explicitly disabled");
context->Expect(result.value.max_speed_percent == 20, "max speed should be parsed");
context->Expect(result.value.vendor_raw_units_per_degree == 1000,
"vendor raw-units-per-degree ratio should be parsed");
context->Expect(!result.value.trajectory_sample_period_confirmed &&
!result.value.vendor_raw_units_per_degree_confirmed,
"unconfirmed vendor timing and units must remain false");
context->Expect(result.value.maximum_origin_move_delta_deg == 30.0F,
"maximum origin move delta should be parsed");
context->Expect(result.value.maximum_o6_speed_command == 64U,
"maximum O6 speed command should be parsed");
context->Expect(result.value.maximum_o6_torque_command == 80U,
"maximum O6 torque command should be parsed");
}
void TestRejectsMissingRequiredField(TestContext *context) {
const auto path = WriteConfig(
"missing.json",
R"({"hardware_execution_enabled": false})");
const auto result = rm_control::config::LoadDragTeachSafetyConfig(path);
context->Expect(!result.ok, "missing safety fields must be rejected");
}
void TestRejectsInvalidLimit(TestContext *context) {
std::string text = ValidConfigText();
const std::string original = "\"max_speed_percent\": 20";
text.replace(text.find(original), original.size(), "\"max_speed_percent\": 0");
const auto path = WriteConfig("invalid_limit.json", text);
const auto result = rm_control::config::LoadDragTeachSafetyConfig(path);
context->Expect(!result.ok, "zero maximum speed must be rejected");
}
void TestRejectsO6CommandLimitAboveProtocolRange(TestContext *context) {
std::string text = ValidConfigText();
const std::string original = "\"maximum_o6_torque_command\": 80";
text.replace(text.find(original), original.size(),
"\"maximum_o6_torque_command\": 256");
const auto path = WriteConfig("invalid_o6_limit.json", text);
const auto result = rm_control::config::LoadDragTeachSafetyConfig(path);
context->Expect(!result.ok, "O6 torque command limit above 255 must be rejected");
}
} // namespace
int main() {
TestContext context;
TestLoadsExplicitDisabledConfiguration(&context);
TestRejectsMissingRequiredField(&context);
TestRejectsInvalidLimit(&context);
TestRejectsO6CommandLimitAboveProtocolRange(&context);
if (context.failures == 0) {
std::cout << "[PASS] rm_drag_teach_safety_config_tests\n";
return 0;
}
std::cerr << context.failures << " test(s) failed\n";
return 1;
}
#include "mocks/mock_robot_arm.h"
#include "rm_control/core/drag_teach_service.h"
#include <chrono>
#include <filesystem>
#include <iostream>
#include <string>
#include <thread>
#ifndef RM_TEST_OUTPUT_DIR
#define RM_TEST_OUTPUT_DIR "."
#endif
namespace {
struct TestContext {
int failures = 0;
void Expect(bool condition, const std::string &message) {
if (!condition) {
++failures;
std::cerr << "[FAIL] " << message << '\n';
}
}
};
rm_control::core::HardwareExecutionGateInput AllowedGate() {
rm_control::core::HardwareExecutionGateInput gate;
gate.live_requested = true;
gate.hardware_execution_enabled = true;
gate.arm_connected = true;
gate.hand_connected = true;
gate.emergency_stop_verified = true;
gate.safety_limits_loaded = true;
gate.operator_confirmed = true;
gate.trajectory_validated = true;
gate.trajectory_sample_period_confirmed = true;
gate.vendor_raw_units_per_degree_confirmed = true;
gate.workspace_and_virtual_wall_verified = true;
gate.configured_max_speed_percent = 20;
gate.native_replay_without_speed_control_accepted = true;
gate.native_replay_speed_within_config_verified = true;
return gate;
}
rm_control::core::DragTeachServiceConfig ServiceConfig(const std::string &name) {
rm_control::core::DragTeachServiceConfig config;
config.storage_root =
std::filesystem::path(RM_TEST_OUTPUT_DIR) / "drag_teach_service";
config.session_name = name;
config.sample_period = std::chrono::milliseconds(10);
return config;
}
bool WaitForSampleCount(rm_control::core::DragTeachService *service,
std::size_t minimum_sample_count) {
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(500);
while (std::chrono::steady_clock::now() < deadline) {
if (service->recordedSampleCount() >= minimum_sample_count) {
return true;
}
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
return false;
}
bool WaitForState(rm_control::core::DragTeachService *service,
rm_control::core::DragTeachServiceState expected) {
const auto deadline = std::chrono::steady_clock::now() +
std::chrono::milliseconds(500);
while (std::chrono::steady_clock::now() < deadline) {
if (service->state() == expected) {
return true;
}
std::this_thread::sleep_for(std::chrono::milliseconds(2));
}
return false;
}
void RecordAndSave(rm_control::core::DragTeachService *service,
const std::string &name,
TestContext *context) {
const auto start = service->StartRecording(ServiceConfig(name), AllowedGate());
context->Expect(start.ok, "StartRecording should succeed with complete gate");
if (!start.ok) {
return;
}
context->Expect(WaitForSampleCount(service, 3),
"recording should produce three samples before timeout");
const auto stop = service->StopRecording();
context->Expect(stop.ok, "StopRecording should validate stationary trajectory");
if (!stop.ok) {
return;
}
context->Expect(stop.value.recorded_sample_count >= 2,
"recording should contain at least two samples");
const auto artifact = service->SaveRawTrajectory();
context->Expect(artifact.ok, "SaveRawTrajectory should create manifest artifact");
if (!artifact.ok) {
return;
}
context->Expect(artifact.value.raw_file_present, "mock raw file should be present");
context->Expect(artifact.value.raw_file_sha256.size() == 64,
"raw file SHA-256 should have 64 hex characters");
context->Expect(std::filesystem::exists(artifact.value.paths.manifest),
"manifest should exist");
}
void TestStartRequiresHandConnection(TestContext *context) {
rm_control::tests::MockRobotArm robot;
rm_control::core::DragTeachService service(robot);
auto gate = AllowedGate();
gate.hand_connected = false;
const auto result = service.StartRecording(ServiceConfig("missing_hand"), gate);
context->Expect(!result.ok, "StartRecording must reject missing hand connection");
context->Expect(robot.start_calls == 0, "rejected start must not call hardware");
}
void TestFullLifecycleAndContinueRecheck(TestContext *context) {
rm_control::tests::MockRobotArm robot;
rm_control::core::DragTeachService service(robot);
RecordAndSave(&service, "full_lifecycle", context);
if (service.state() != rm_control::core::DragTeachServiceState::Saved) {
return;
}
const auto origin = service.MoveToTrajectoryOrigin(AllowedGate());
const auto replay = service.ReplayLastDragTrajectory(AllowedGate());
const auto pause = service.PauseReplay();
context->Expect(origin.ok && replay.ok && pause.ok,
"origin, replay and pause should follow valid lifecycle");
auto denied_gate = AllowedGate();
denied_gate.live_requested = false;
const auto denied_continue = service.ContinueReplay(denied_gate);
context->Expect(!denied_continue.ok, "ContinueReplay must recheck live gate");
context->Expect(robot.continue_calls == 0,
"denied ContinueReplay must not call hardware");
const auto continued = service.ContinueReplay(AllowedGate());
const auto stopped = service.StopReplay();
context->Expect(continued.ok && stopped.ok,
"authorized continue and safety stop should succeed");
context->Expect(robot.origin_calls == 1 && robot.replay_calls == 1 &&
robot.pause_calls == 1 && robot.continue_calls == 1 &&
robot.stop_replay_calls == 1,
"adapter replay methods should each be called once");
}
void TestWrongModelRejectedBeforeHardwareStart(TestContext *context) {
rm_control::tests::MockRobotArm robot;
robot.robot_info.is_rm75 = false;
rm_control::core::DragTeachService service(robot);
const auto result =
service.StartRecording(ServiceConfig("wrong_model"), AllowedGate());
context->Expect(!result.ok, "non-RM75 model must be rejected");
context->Expect(robot.start_calls == 0, "wrong model must not start drag teach");
}
void TestJointLimitViolationRejectedBeforeHardwareStart(TestContext *context) {
rm_control::tests::MockRobotArm robot;
robot.arm_state.joints_deg[0] = 400.0F;
rm_control::core::DragTeachService service(robot);
const auto result =
service.StartRecording(ServiceConfig("joint_limit"), AllowedGate());
context->Expect(!result.ok, "current joint outside controller limits must be rejected");
context->Expect(robot.start_calls == 0,
"joint limit violation must not start drag teach");
}
void TestSamplingFailureRetriesFailedStop(TestContext *context) {
rm_control::tests::MockRobotArm robot;
robot.fail_current_state_on_or_after_call = 2;
robot.stop_recording_failures_remaining = 1;
rm_control::core::DragTeachService service(robot);
const auto start = service.StartRecording(
ServiceConfig("sampling_failure"), AllowedGate());
context->Expect(start.ok, "sampling failure test should initially start");
if (!start.ok) {
return;
}
context->Expect(WaitForState(&service, rm_control::core::DragTeachServiceState::Faulted),
"sampling read failure should enter Faulted");
const auto stopped = service.StopRecording();
context->Expect(!stopped.ok, "sampling failure should be reported to caller");
context->Expect(robot.stop_recording_calls == 2,
"failed sampling-thread stop must be retried by StopRecording");
}
void TestUncertainStartAttemptsSafetyStop(TestContext *context) {
rm_control::tests::MockRobotArm robot;
robot.start_recording_failures_remaining = 1;
rm_control::core::DragTeachService service(robot);
const auto result = service.StartRecording(
ServiceConfig("uncertain_start"), AllowedGate());
context->Expect(!result.ok, "failed start must be reported");
context->Expect(robot.start_calls == 1,
"failed start should call the adapter exactly once");
context->Expect(robot.stop_recording_calls == 1,
"uncertain start must immediately attempt stopDragTeach");
}
} // namespace
int main() {
TestContext context;
TestStartRequiresHandConnection(&context);
TestFullLifecycleAndContinueRecheck(&context);
TestWrongModelRejectedBeforeHardwareStart(&context);
TestJointLimitViolationRejectedBeforeHardwareStart(&context);
TestSamplingFailureRetriesFailedStop(&context);
TestUncertainStartAttemptsSafetyStop(&context);
if (context.failures == 0) {
std::cout << "[PASS] rm_drag_teach_service_tests\n";
return 0;
}
std::cerr << context.failures << " test(s) failed\n";
return 1;
}
#include "rm_control/modules/trajectory/drag_trajectory_validator.h"
#include <cstddef>
#include <iostream>
#include <limits>
#include <string>
#include <vector>
namespace {
struct TestContext {
int failures = 0;
void Expect(bool condition, const std::string &message) {
if (!condition) {
++failures;
std::cerr << "[FAIL] " << message << '\n';
}
}
};
rm_control::common::TrajectorySample MakeSample(std::size_t sample_index,
long long elapsed_ms,
float joint_1_deg) {
rm_control::common::TrajectorySample sample;
sample.sample_index = sample_index;
sample.elapsed_ms = elapsed_ms;
sample.joints_deg = {joint_1_deg, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F};
return sample;
}
rm_control::modules::trajectory::DragTrajectoryValidationLimits MakePermissiveLimits() {
rm_control::modules::trajectory::DragTrajectoryValidationLimits limits;
limits.maximum_joint_jump_deg = 100.0;
limits.maximum_joint_velocity_deg_per_second = 1000.0;
limits.maximum_joint_acceleration_deg_per_second_squared = 10000.0;
return limits;
}
void TestReportsPeakJointMotionForValidTrajectory(TestContext *context) {
const std::vector<rm_control::common::TrajectorySample> samples = {
MakeSample(0, 0, 0.0F),
MakeSample(1, 1000, 1.0F),
MakeSample(2, 2000, 3.0F),
};
const auto result = rm_control::modules::trajectory::ValidateDragTrajectory(
samples, MakePermissiveLimits());
context->Expect(result.ok, "valid seven-joint trajectory should pass");
if (!result.ok) {
return;
}
context->Expect(result.value.sample_count == 3, "report sample count should match input");
context->Expect(result.value.duration_ms == 2000,
"report duration should use elapsed milliseconds");
context->Expect(result.value.observed_maximum_joint_jump_deg == 2.0,
"report should contain two-degree maximum jump");
context->Expect(result.value.observed_maximum_joint_velocity_deg_per_second == 2.0,
"report should contain two-degree-per-second maximum velocity");
context->Expect(
result.value.observed_maximum_joint_acceleration_deg_per_second_squared == 1.0,
"report should calculate acceleration between interval midpoints");
}
void TestRejectsEmptyAndWrongJointCountTrajectories(TestContext *context) {
const std::vector<rm_control::common::TrajectorySample> empty_samples;
auto result = rm_control::modules::trajectory::ValidateDragTrajectory(
empty_samples, MakePermissiveLimits());
context->Expect(!result.ok, "empty trajectory must be rejected");
auto sample = MakeSample(0, 0, 0.0F);
sample.joints_deg.pop_back();
result = rm_control::modules::trajectory::ValidateDragTrajectory(
{sample}, MakePermissiveLimits());
context->Expect(!result.ok, "RM75 trajectory point with six joints must be rejected");
}
void TestRejectsNonMonotonicTimestamps(TestContext *context) {
const std::vector<rm_control::common::TrajectorySample> samples = {
MakeSample(0, 100, 0.0F),
MakeSample(1, 100, 1.0F),
};
const auto result = rm_control::modules::trajectory::ValidateDragTrajectory(
samples, MakePermissiveLimits());
context->Expect(!result.ok,
"equal timestamps must be rejected to prevent division by zero");
}
void TestRejectsNonFiniteJointAndTcpValues(TestContext *context) {
auto non_finite_joint = MakeSample(0, 0, 0.0F);
non_finite_joint.joints_deg[2] = std::numeric_limits<float>::quiet_NaN();
auto result = rm_control::modules::trajectory::ValidateDragTrajectory(
{non_finite_joint, MakeSample(1, 1000, 0.0F)}, MakePermissiveLimits());
context->Expect(!result.ok, "non-finite joint value must be rejected");
auto non_finite_pose = MakeSample(0, 0, 0.0F);
non_finite_pose.tcp_pose.z = std::numeric_limits<float>::infinity();
result = rm_control::modules::trajectory::ValidateDragTrajectory(
{non_finite_pose, MakeSample(1, 1000, 0.0F)}, MakePermissiveLimits());
context->Expect(!result.ok, "non-finite TCP pose value must be rejected");
}
void TestRejectsJumpVelocityAndAccelerationLimitViolations(TestContext *context) {
auto limits = MakePermissiveLimits();
limits.maximum_joint_jump_deg = 1.0;
auto result = rm_control::modules::trajectory::ValidateDragTrajectory(
{MakeSample(0, 0, 0.0F), MakeSample(1, 1000, 2.0F)}, limits);
context->Expect(!result.ok, "joint jump above configured maximum must be rejected");
limits = MakePermissiveLimits();
limits.maximum_joint_velocity_deg_per_second = 1.0;
result = rm_control::modules::trajectory::ValidateDragTrajectory(
{MakeSample(0, 0, 0.0F), MakeSample(1, 1000, 2.0F)}, limits);
context->Expect(!result.ok, "joint velocity above configured maximum must be rejected");
limits = MakePermissiveLimits();
limits.maximum_joint_acceleration_deg_per_second_squared = 1.0;
result = rm_control::modules::trajectory::ValidateDragTrajectory(
{MakeSample(0, 0, 0.0F), MakeSample(1, 1000, 0.0F),
MakeSample(2, 2000, 2.0F)},
limits);
context->Expect(!result.ok,
"joint acceleration above configured maximum must be rejected");
}
void TestRejectsInvalidLimits(TestContext *context) {
auto limits = MakePermissiveLimits();
limits.maximum_joint_jump_deg = 0.0;
const auto result = rm_control::modules::trajectory::ValidateDragTrajectory(
{MakeSample(0, 0, 0.0F)}, limits);
context->Expect(!result.ok, "zero safety limit must be rejected");
}
} // namespace
int main() {
TestContext context;
TestReportsPeakJointMotionForValidTrajectory(&context);
TestRejectsEmptyAndWrongJointCountTrajectories(&context);
TestRejectsNonMonotonicTimestamps(&context);
TestRejectsNonFiniteJointAndTcpValues(&context);
TestRejectsJumpVelocityAndAccelerationLimitViolations(&context);
TestRejectsInvalidLimits(&context);
if (context.failures == 0) {
std::cout << "[PASS] rm_drag_trajectory_validator_tests\n";
return 0;
}
std::cerr << context.failures << " test(s) failed\n";
return 1;
}
#include "rm_control/core/hardware_execution_gate.h"
#include <array>
#include <iostream>
#include <string>
namespace {
struct TestContext {
int failures = 0;
void Expect(bool condition, const std::string &message) {
if (!condition) {
++failures;
std::cerr << "[FAIL] " << message << '\n';
}
}
};
rm_control::core::HardwareExecutionGateInput MakeAllowedInput() {
rm_control::core::HardwareExecutionGateInput input;
input.live_requested = true;
input.hardware_execution_enabled = true;
input.arm_connected = true;
input.hand_connected = true;
input.emergency_stop_verified = true;
input.safety_limits_loaded = true;
input.operator_confirmed = true;
input.trajectory_validated = true;
input.trajectory_sample_period_confirmed = true;
input.vendor_raw_units_per_degree_confirmed = true;
input.workspace_and_virtual_wall_verified = true;
input.configured_max_speed_percent = 20;
input.native_replay_without_speed_control_accepted = true;
input.native_replay_speed_within_config_verified = true;
return input;
}
void TestAllowsExecutionWhenEveryBaseConditionIsSatisfied(TestContext *context) {
const auto decision =
rm_control::core::EvaluateHardwareExecutionGate(MakeAllowedInput());
context->Expect(decision.execution_allowed,
"complete base safety input should allow execution");
context->Expect(decision.rejection_reasons.empty(),
"allowed decision should contain no rejection");
}
void TestRejectsEachMissingBaseCondition(TestContext *context) {
using Input = rm_control::core::HardwareExecutionGateInput;
constexpr std::array<bool Input::*, 9> kRequiredConditions = {
&Input::live_requested,
&Input::hardware_execution_enabled,
&Input::arm_connected,
&Input::hand_connected,
&Input::emergency_stop_verified,
&Input::safety_limits_loaded,
&Input::operator_confirmed,
&Input::trajectory_validated,
&Input::workspace_and_virtual_wall_verified,
};
for (const auto required_condition : kRequiredConditions) {
auto input = MakeAllowedInput();
input.*required_condition = false;
const auto decision = rm_control::core::EvaluateHardwareExecutionGate(input);
context->Expect(!decision.execution_allowed,
"each missing base condition must reject execution");
context->Expect(!decision.rejection_reasons.empty(),
"a missing base condition must explain the rejection");
}
}
void TestRejectsUnconfirmedVendorMetadataWhenRequired(TestContext *context) {
rm_control::core::HardwareExecutionGateRequirements requirements;
requirements.require_confirmed_trajectory_sample_period = true;
requirements.require_confirmed_vendor_raw_units_per_degree = true;
auto input = MakeAllowedInput();
input.trajectory_sample_period_confirmed = false;
auto decision =
rm_control::core::EvaluateHardwareExecutionGate(input, requirements);
context->Expect(!decision.execution_allowed,
"required unconfirmed sample period must reject playback");
input = MakeAllowedInput();
input.vendor_raw_units_per_degree_confirmed = false;
decision = rm_control::core::EvaluateHardwareExecutionGate(input, requirements);
context->Expect(!decision.execution_allowed,
"required unconfirmed raw-unit ratio must reject playback");
}
void TestRejectsNativeReplayWithoutExplicitAcceptance(TestContext *context) {
auto input = MakeAllowedInput();
input.native_replay_without_speed_control_accepted = false;
const rm_control::core::HardwareExecutionGateRequirements requirements{
0, true};
const auto decision =
rm_control::core::EvaluateHardwareExecutionGate(input, requirements);
context->Expect(!decision.execution_allowed,
"native replay must require acceptance of missing speed control");
}
void TestRejectsNativeReplayWithoutIndependentSpeedVerification(
TestContext *context) {
auto input = MakeAllowedInput();
input.native_replay_speed_within_config_verified = false;
const rm_control::core::HardwareExecutionGateRequirements requirements{
0, true, true};
const auto decision =
rm_control::core::EvaluateHardwareExecutionGate(input, requirements);
context->Expect(!decision.execution_allowed,
"native replay must verify actual speed against the configured limit");
}
void TestAllowsNativeOperationsWhenTheirSpecificRequirementsAreSatisfied(
TestContext *context) {
const auto input = MakeAllowedInput();
const rm_control::core::HardwareExecutionGateRequirements origin_requirements{
rm_control::core::kNativeTrajectoryOriginRequiredSpeedPercent, false};
const rm_control::core::HardwareExecutionGateRequirements replay_requirements{0, true};
const auto origin_decision =
rm_control::core::EvaluateHardwareExecutionGate(input, origin_requirements);
const auto replay_decision =
rm_control::core::EvaluateHardwareExecutionGate(input, replay_requirements);
context->Expect(origin_decision.execution_allowed,
"native origin motion should pass at the configured 20 percent limit");
context->Expect(replay_decision.execution_allowed,
"native replay should pass after accepting its missing speed control");
}
void TestRejectsNativeOriginWhenRequiredSpeedExceedsConfiguredMaximum(
TestContext *context) {
auto input = MakeAllowedInput();
input.configured_max_speed_percent = 10;
const rm_control::core::HardwareExecutionGateRequirements requirements{
rm_control::core::kNativeTrajectoryOriginRequiredSpeedPercent, false};
const auto result =
rm_control::core::RequireHardwareExecutionPermission(input, requirements);
context->Expect(!result.ok,
"native 20 percent origin motion must fail below configured maximum");
context->Expect(result.error_message.find("exceeds configured maximum") !=
std::string::npos,
"speed rejection should include actionable context");
}
void TestRejectsInvalidSpeedRanges(TestContext *context) {
auto input = MakeAllowedInput();
input.configured_max_speed_percent = 101;
auto decision = rm_control::core::EvaluateHardwareExecutionGate(input);
context->Expect(!decision.execution_allowed,
"configured speed above 100 must be rejected");
input = MakeAllowedInput();
const rm_control::core::HardwareExecutionGateRequirements requirements{-1, false};
decision = rm_control::core::EvaluateHardwareExecutionGate(input, requirements);
context->Expect(!decision.execution_allowed,
"negative required speed must be rejected");
}
} // namespace
int main() {
TestContext context;
TestAllowsExecutionWhenEveryBaseConditionIsSatisfied(&context);
TestRejectsEachMissingBaseCondition(&context);
TestRejectsUnconfirmedVendorMetadataWhenRequired(&context);
TestRejectsNativeReplayWithoutExplicitAcceptance(&context);
TestRejectsNativeReplayWithoutIndependentSpeedVerification(&context);
TestAllowsNativeOperationsWhenTheirSpecificRequirementsAreSatisfied(&context);
TestRejectsNativeOriginWhenRequiredSpeedExceedsConfiguredMaximum(&context);
TestRejectsInvalidSpeedRanges(&context);
if (context.failures == 0) {
std::cout << "[PASS] rm_hardware_execution_gate_tests\n";
return 0;
}
std::cerr << context.failures << " test(s) failed\n";
return 1;
}
This diff is collapsed.
#include "flight_robot/realman/modbus_register_codec.h"
#include <cstdint>
#include <iostream>
#include <string>
#include <vector>
namespace {
struct TestContext {
int failures = 0;
void Expect(bool condition, const std::string &message) {
if (!condition) {
++failures;
std::cerr << "[FAIL] " << message << '\n';
}
}
};
void TestEncodePreservesZeroByteAndUint16Boundaries(TestContext *context) {
const std::vector<std::uint16_t> registers{
0x0000U, 0x00FFU, 0x0100U, 0xFFFFU};
const auto result =
flight_robot::realman::EncodeModbusRegistersAsRmApiHighLowBytes(
registers);
context->Expect(result.ok, "boundary registers should encode successfully");
context->Expect(
result.ok && result.value ==
(std::vector<int>{0x00, 0x00, 0x00, 0xFF,
0x01, 0x00, 0xFF, 0xFF}),
"registers must encode as num * 2 high-low bytes without truncation");
}
void TestDecodeUnsignedBytesPreservesRegisterBoundaries(TestContext *context) {
const std::vector<int> bytes{
0x00, 0x00, 0x00, 0xFF, 0x01, 0x00, 0xFF, 0xFF};
const auto result =
flight_robot::realman::DecodeModbusRegistersFromRmApiHighLowBytes(
bytes);
context->Expect(result.ok, "unsigned RM API bytes should decode");
context->Expect(
result.ok && result.value ==
(std::vector<std::uint16_t>{0x0000U, 0x00FFU, 0x0100U, 0xFFFFU}),
"unsigned high-low byte pairs must decode to exact uint16 values");
}
void TestDecodeSignedInt8BytesPreservesRawBits(TestContext *context) {
const std::vector<int> bytes{0, -1, -128, 0, -1, -1};
const auto result =
flight_robot::realman::DecodeModbusRegistersFromRmApiHighLowBytes(
bytes);
context->Expect(result.ok, "signed int8 RM API bytes should decode");
context->Expect(
result.ok && result.value ==
(std::vector<std::uint16_t>{0x00FFU, 0x8000U, 0xFFFFU}),
"signed int8 bytes must normalize without changing their raw bits");
}
void TestEmptyEncodeAndDecodeAreRejected(TestContext *context) {
const auto encoded =
flight_robot::realman::EncodeModbusRegistersAsRmApiHighLowBytes({});
const auto decoded =
flight_robot::realman::DecodeModbusRegistersFromRmApiHighLowBytes({});
context->Expect(!encoded.ok, "empty register sequence must not encode");
context->Expect(!decoded.ok, "empty byte sequence must not decode");
}
void TestOddByteCountIsRejected(TestContext *context) {
const auto result =
flight_robot::realman::DecodeModbusRegistersFromRmApiHighLowBytes(
std::vector<int>{0, 1, 2});
context->Expect(!result.ok, "odd RM API byte count must be rejected");
context->Expect(result.error_message.find("even byte count") !=
std::string::npos,
"odd byte count failure must include dimension context");
}
void TestByteBelowSignedInt8RangeIsRejected(TestContext *context) {
const auto result =
flight_robot::realman::DecodeModbusRegistersFromRmApiHighLowBytes(
std::vector<int>{-129, 0});
context->Expect(!result.ok, "RM API byte -129 must be rejected");
context->Expect(result.error_message.find("index 0") != std::string::npos,
"invalid low-bound byte must report its index");
}
void TestByteAboveUnsignedByteRangeIsRejected(TestContext *context) {
const auto result =
flight_robot::realman::DecodeModbusRegistersFromRmApiHighLowBytes(
std::vector<int>{0, 256});
context->Expect(!result.ok, "RM API byte 256 must be rejected");
context->Expect(result.error_message.find("index 1") != std::string::npos,
"invalid high-bound byte must report its index");
}
} // namespace
int main() {
TestContext context;
TestEncodePreservesZeroByteAndUint16Boundaries(&context);
TestDecodeUnsignedBytesPreservesRegisterBoundaries(&context);
TestDecodeSignedInt8BytesPreservesRawBits(&context);
TestEmptyEncodeAndDecodeAreRejected(&context);
TestOddByteCountIsRejected(&context);
TestByteBelowSignedInt8RangeIsRejected(&context);
TestByteAboveUnsignedByteRangeIsRejected(&context);
if (context.failures == 0) {
std::cout << "[PASS] rm_modbus_register_codec_tests\n";
return 0;
}
std::cerr << context.failures << " test(s) failed\n";
return 1;
}
#include "rm_control/core/o6_bus_scanner.h"
#include <cstdint>
#include <iostream>
#include <string>
#include <vector>
namespace {
class FakeScanningToolBus final : public rm_control::interfaces::IToolModbusBus {
public:
std::vector<std::uint8_t> requested_device_ids;
std::vector<std::string> operations;
rm_control::common::Result<rm_control::interfaces::ToolModbusBusStatus>
Configure(const rm_control::interfaces::ToolModbusBusConfiguration &) override {
return GetStatus();
}
rm_control::common::Result<rm_control::interfaces::ToolModbusBusStatus>
GetStatus() override {
return rm_control::common::Result<
rm_control::interfaces::ToolModbusBusStatus>::success(
{true, 1, 1, 115200, 3, 1});
}
rm_control::common::Result<std::uint16_t> ReadInputRegister(
std::uint8_t device_id,
std::uint16_t) override {
operations.push_back("single:" + std::to_string(device_id));
if (device_id == 0x27U) {
return rm_control::common::Result<std::uint16_t>::success(6U);
}
return rm_control::common::Result<std::uint16_t>::failure(
"mock single read timeout");
}
rm_control::common::Result<std::vector<std::uint16_t>> ReadInputRegisters(
std::uint8_t device_id,
std::uint16_t,
std::size_t) override {
requested_device_ids.push_back(device_id);
operations.push_back("multiple:" + std::to_string(device_id));
if (device_id == 0x27U) {
return rm_control::common::Result<std::vector<std::uint16_t>>::success(
{6U, 101U, 1U, 2U, 3U, static_cast<std::uint16_t>('R')});
}
return rm_control::common::Result<std::vector<std::uint16_t>>::failure(
"mock no response");
}
rm_control::common::Result<std::vector<std::uint16_t>> ReadHoldingRegisters(
std::uint8_t, std::uint16_t, std::size_t) override {
return rm_control::common::Result<std::vector<std::uint16_t>>::failure(
"not used by scanner");
}
rm_control::common::Result<void> WriteHoldingRegisters(
std::uint8_t,
std::uint16_t,
const std::vector<std::uint16_t> &) override {
return rm_control::common::Result<void>::failure(
"scanner must never write");
}
};
int TestScanCollectsResponsesAndMarksO6() {
FakeScanningToolBus bus;
rm_control::core::O6BusScanConfig config;
const auto result = rm_control::core::ScanO6IdentityRegisters(bus, config);
if (!result.ok || result.value.attempted_device_count != 2U ||
result.value.failed_device_count != 1U ||
result.value.confirmed_o6_device_count != 1U ||
result.value.responses.size() != 2U ||
!result.value.responses[0].matches_o6_identity_shape ||
result.value.responses[1].matches_o6_identity_shape ||
result.value.responses[0].device_id != 0x27U ||
result.value.responses[1].device_id != 0x28U ||
bus.operations !=
(std::vector<std::string>{"multiple:39", "single:39",
"multiple:40", "single:40"})) {
std::cerr << "[FAIL] probe should use both FC04 paths once per O6 ID\n";
return 1;
}
return 0;
}
int TestInvalidRangeIsRejectedWithoutRead() {
FakeScanningToolBus bus;
rm_control::core::O6BusScanConfig config;
config.device_ids = {0x27U};
const auto result = rm_control::core::ScanO6IdentityRegisters(bus, config);
if (result.ok || !bus.requested_device_ids.empty()) {
std::cerr << "[FAIL] invalid scan range must fail before bus access\n";
return 1;
}
return 0;
}
} // namespace
int main() {
const int failures = TestScanCollectsResponsesAndMarksO6() +
TestInvalidRangeIsRejectedWithoutRead();
if (failures == 0) {
std::cout << "[PASS] rm_o6_bus_scanner_tests\n";
return 0;
}
return 1;
}
This diff is collapsed.
This diff is collapsed.
#include "rm_control/modules/trajectory/trajectory_file_store.h" #include "rm_control/modules/trajectory/trajectory_file_store.h"
#include <filesystem>
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
#include <sstream> #include <sstream>
...@@ -12,17 +13,22 @@ ...@@ -12,17 +13,22 @@
namespace { namespace {
int g_failures = 0; struct TestContext {
int failures = 0;
void expect(bool condition, const std::string &message) { void Expect(bool condition, const std::string &message) {
if (!condition) { if (!condition) {
++g_failures; ++failures;
std::cerr << "[FAIL] " << message << '\n'; std::cerr << "[FAIL] " << message << '\n';
}
} }
} };
rm_control::common::TrajectorySample makeSample(long long elapsed_ms, float j2_deg) { rm_control::common::TrajectorySample makeSample(std::size_t sample_index,
long long elapsed_ms,
float j2_deg) {
rm_control::common::TrajectorySample sample; rm_control::common::TrajectorySample sample;
sample.sample_index = sample_index;
sample.elapsed_ms = elapsed_ms; sample.elapsed_ms = elapsed_ms;
sample.joints_deg = {0.0F, j2_deg, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F}; sample.joints_deg = {0.0F, j2_deg, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F};
sample.tcp_pose.x = 0.1F; sample.tcp_pose.x = 0.1F;
...@@ -41,63 +47,101 @@ std::string readTextFile(const std::string &path) { ...@@ -41,63 +47,101 @@ std::string readTextFile(const std::string &path) {
return buffer.str(); return buffer.str();
} }
void testStoragePathsAndCsvRoundTrip() { void testStoragePathsAndCsvRoundTrip(TestContext *context) {
const std::string root = std::string(RM_TEST_OUTPUT_DIR) + "/trajectory_store_test"; const std::string root = std::string(RM_TEST_OUTPUT_DIR) + "/trajectory_store_test";
auto files = rm_control::modules::trajectory::makeTrajectoryFileSet(root, "demo session"); auto files = rm_control::modules::trajectory::makeTrajectoryFileSet(root, "demo session");
expect(files.ok, "file set should be created"); context->Expect(files.ok, "file set should be created");
if (!files.ok) { if (!files.ok) {
return; return;
} }
expect(files.value.session_name == "demo_session", "session name should be sanitized"); context->Expect(files.value.session_name == "demo_session",
"session name should be sanitized");
std::vector<rm_control::common::TrajectorySample> samples; std::vector<rm_control::common::TrajectorySample> samples;
samples.push_back(makeSample(0, 0.0F)); samples.push_back(makeSample(0, 0, 0.0F));
samples.push_back(makeSample(100, 1.0F)); samples.push_back(makeSample(1, 100, 1.0F));
auto write_result = rm_control::modules::trajectory::writeTrajectoryCsv(files.value.csv_path, samples); auto write_result = rm_control::modules::trajectory::writeTrajectoryCsv(files.value.csv_path, samples);
expect(write_result.ok, "CSV should be written"); context->Expect(write_result.ok, "CSV should be written");
const auto overwrite_result =
rm_control::modules::trajectory::writeTrajectoryCsvWithoutOverwrite(
std::filesystem::path(files.value.csv_path), samples);
context->Expect(!overwrite_result.ok,
"new-session CSV writer must refuse an existing target");
auto loaded = rm_control::modules::trajectory::loadTrajectoryCsv(files.value.csv_path); auto loaded = rm_control::modules::trajectory::loadTrajectoryCsv(files.value.csv_path);
expect(loaded.ok, "CSV should be loaded"); context->Expect(loaded.ok, "CSV should be loaded");
if (!loaded.ok) { if (!loaded.ok) {
return; return;
} }
expect(loaded.value.size() == 2, "loaded sample count should match"); context->Expect(loaded.value.size() == 2, "loaded sample count should match");
expect(loaded.value[1].joints_deg[1] == 1.0F, "loaded J2 should match"); context->Expect(loaded.value[1].sample_index == 1,
"loaded sample index should match");
context->Expect(loaded.value[1].source == "realman_rm75_drag_teach",
"loaded trajectory source should match");
context->Expect(loaded.value[1].joints_deg[1] == 1.0F,
"loaded J2 should match");
} }
void testExportTcpPathCpp() { void testExportTcpPathCpp(TestContext *context) {
const std::string root = std::string(RM_TEST_OUTPUT_DIR) + "/trajectory_export_test"; const std::string root = std::string(RM_TEST_OUTPUT_DIR) + "/trajectory_export_test";
auto files = rm_control::modules::trajectory::makeTrajectoryFileSet(root, "tcp demo"); auto files = rm_control::modules::trajectory::makeTrajectoryFileSet(root, "tcp demo");
expect(files.ok, "file set should be created for export"); context->Expect(files.ok, "file set should be created for export");
if (!files.ok) { if (!files.ok) {
return; return;
} }
std::vector<rm_control::common::TrajectorySample> samples; std::vector<rm_control::common::TrajectorySample> samples;
samples.push_back(makeSample(0, 0.0F)); samples.push_back(makeSample(0, 0, 0.0F));
samples.push_back(makeSample(100, 1.0F)); samples.push_back(makeSample(1, 100, 1.0F));
auto export_result = rm_control::modules::trajectory::exportTcpPathCpp( auto export_result = rm_control::modules::trajectory::exportTcpPathCpp(
files.value.converted_cpp_path, samples, files.value.session_name); files.value.converted_cpp_path, samples, files.value.session_name);
expect(export_result.ok, "C++ TCP path should be exported"); context->Expect(export_result.ok, "C++ TCP path should be exported");
const std::string text = readTextFile(files.value.converted_cpp_path); const std::string text = readTextFile(files.value.converted_cpp_path);
expect(text.find("struct TcpWaypoint") != std::string::npos, "export should contain TcpWaypoint"); context->Expect(text.find("struct TcpWaypoint") != std::string::npos,
expect(text.find("kTcpPath_tcp_demo") != std::string::npos, "export should contain sanitized variable"); "export should contain TcpWaypoint");
context->Expect(text.find("kTcpPath_tcp_demo") != std::string::npos,
"export should contain sanitized variable");
}
void testLegacyCsvGetsExplicitLegacySource(TestContext *context) {
const std::filesystem::path directory =
std::filesystem::path(RM_TEST_OUTPUT_DIR) / "legacy_trajectory_store_test";
std::filesystem::create_directories(directory);
const std::filesystem::path csv_path = directory / "legacy.csv";
std::ofstream output(csv_path, std::ios::trunc);
output << "sample_index,elapsed_ms,"
"j1_deg,j2_deg,j3_deg,j4_deg,j5_deg,j6_deg,j7_deg,"
"tcp_x_m,tcp_y_m,tcp_z_m,tcp_rx_rad,tcp_ry_rad,tcp_rz_rad\n"
<< "0,0,0,0,0,0,0,0,0,0,0,0,0,0,0\n"
<< "1,100,0,0,0,0,0,0,0,0,0,0,0,0,0\n";
output.close();
const auto loaded =
rm_control::modules::trajectory::loadTrajectoryCsv(csv_path.string());
context->Expect(loaded.ok, "legacy 15-column CSV should remain readable");
if (loaded.ok) {
context->Expect(loaded.value[0].source == "legacy_rm75_csv",
"legacy CSV must not be mislabeled as a current capture");
}
} }
} // namespace } // namespace
int main() { int main() {
testStoragePathsAndCsvRoundTrip(); TestContext context;
testExportTcpPathCpp(); testStoragePathsAndCsvRoundTrip(&context);
testExportTcpPathCpp(&context);
testLegacyCsvGetsExplicitLegacySource(&context);
if (g_failures == 0) { if (context.failures == 0) {
std::cout << "[PASS] rm_trajectory_file_store_tests\n"; std::cout << "[PASS] rm_trajectory_file_store_tests\n";
return 0; return 0;
} }
std::cerr << g_failures << " test(s) failed\n"; std::cerr << context.failures << " test(s) failed\n";
return 1; return 1;
} }
This diff is collapsed.
#include "rm_control/modules/trajectory/trajectory_playback_report_store.h"
#include <filesystem>
#include <fstream>
#include <iostream>
#include <sstream>
#include <string>
#ifndef RM_TEST_OUTPUT_DIR
#define RM_TEST_OUTPUT_DIR "."
#endif
namespace {
struct TestContext {
int failures = 0;
void Expect(bool condition, const std::string &message) {
if (!condition) {
++failures;
std::cerr << "[FAIL] " << message << '\n';
}
}
};
rm_control::modules::trajectory::ControllerManagedPlaybackReport ValidReport() {
using Report =
rm_control::modules::trajectory::ControllerManagedPlaybackReport;
Report report;
report.report_identifier = "20260711_120000";
report.source_file = "source.txt";
report.source_file_sha256 = std::string(64U, 'a');
report.trajectory_point_count = 3U;
report.sample_period_ms = 100;
report.source_duration_ms = 200;
report.plan_speed_percent = 5;
report.controller_program_id = 7;
report.observed_execution_duration_ms = 450.0;
report.program_status_poll_count = 4U;
report.minimum_poll_period_ms = 99.0;
report.maximum_poll_period_ms = 102.0;
report.average_poll_period_ms = 100.3;
report.maximum_absolute_poll_jitter_ms = 2.0;
report.final_controller_state = 0;
report.o6_device_id = 0x27U;
report.target_positions = {140U, 100U, 55U, 55U, 55U, 55U};
report.speed_commands = {20U, 20U, 20U, 20U, 20U, 20U};
report.torque_commands = {30U, 30U, 30U, 30U, 30U, 30U};
report.final_positions = report.target_positions;
return report;
}
std::string ReadText(const std::filesystem::path &path) {
std::ifstream input(path);
std::ostringstream text;
text << input.rdbuf();
return text.str();
}
void TestWritesStructuredReportWithoutOverwrite(TestContext *context) {
const std::filesystem::path directory =
std::filesystem::path(RM_TEST_OUTPUT_DIR) / "playback_report_store";
std::filesystem::remove_all(directory);
const auto first =
rm_control::modules::trajectory::WriteControllerManagedPlaybackReportWithoutOverwrite(
ValidReport(), directory);
context->Expect(first.ok, "valid controller-managed report should be written");
if (!first.ok) {
return;
}
const std::string text = ReadText(first.value);
context->Expect(text.find("host_program_state_polling_not_point_dispatch") !=
std::string::npos,
"report must state the scope of polling jitter");
context->Expect(text.find("\"target_positions\": [140, 100, 55") !=
std::string::npos,
"report must preserve the O6 target positions");
const auto duplicate =
rm_control::modules::trajectory::WriteControllerManagedPlaybackReportWithoutOverwrite(
ValidReport(), directory);
context->Expect(!duplicate.ok,
"same report identifier must not overwrite an existing file");
}
void TestRejectsInvalidMetadataBeforeWriting(TestContext *context) {
auto report = ValidReport();
report.source_file_sha256 = "not-a-sha256";
const std::filesystem::path directory =
std::filesystem::path(RM_TEST_OUTPUT_DIR) / "invalid_playback_report";
std::filesystem::remove_all(directory);
const auto result =
rm_control::modules::trajectory::WriteControllerManagedPlaybackReportWithoutOverwrite(
report, directory);
context->Expect(!result.ok, "invalid report metadata must be rejected");
context->Expect(!std::filesystem::exists(directory),
"invalid report must not create an output directory");
}
void TestWritesRoundTripMetadata(TestContext *context) {
auto report = ValidReport();
report.report_identifier = "20260711_120001";
report.returned_from_trajectory_end = true;
report.return_source_file = "return_source.txt";
report.return_source_file_sha256 = std::string(64U, 'b');
report.return_controller_program_id = 8;
report.return_observed_execution_duration_ms = 430.0;
report.return_program_status_poll_count = 5U;
report.return_minimum_poll_period_ms = 98.0;
report.return_maximum_poll_period_ms = 103.0;
report.return_average_poll_period_ms = 100.1;
report.return_maximum_absolute_poll_jitter_ms = 3.0;
report.return_final_controller_state = 0;
const std::filesystem::path directory =
std::filesystem::path(RM_TEST_OUTPUT_DIR) / "round_trip_playback_report";
std::filesystem::remove_all(directory);
const auto result =
rm_control::modules::trajectory::WriteControllerManagedPlaybackReportWithoutOverwrite(
report, directory);
context->Expect(result.ok, "valid round-trip report should be written");
if (!result.ok) {
return;
}
const std::string text = ReadText(result.value);
context->Expect(text.find("\"returned_from_trajectory_end\": true") !=
std::string::npos,
"report must identify the reverse-return workflow");
context->Expect(text.find("\"return_controller_program_id\": 8") !=
std::string::npos,
"report must preserve the return controller program id");
context->Expect(text.find(std::string(64U, 'b')) != std::string::npos,
"report must preserve the return trajectory SHA-256");
}
} // namespace
int main() {
TestContext context;
TestWritesStructuredReportWithoutOverwrite(&context);
TestRejectsInvalidMetadataBeforeWriting(&context);
TestWritesRoundTripMetadata(&context);
if (context.failures == 0) {
std::cout << "[PASS] rm_trajectory_playback_report_store_tests\n";
return 0;
}
std::cerr << context.failures << " test(s) failed\n";
return 1;
}
#include "rm_control/modules/trajectory/vendor_drag_trajectory_parser.h"
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <limits>
#include <string>
#include <utility>
#include <vector>
#ifndef RM_TEST_OUTPUT_DIR
#error "RM_TEST_OUTPUT_DIR must be defined so tests cannot write into the source tree"
#endif
namespace {
namespace trajectory = rm_control::modules::trajectory;
class VendorDragTrajectoryParserTestSuite {
public:
int Run() {
std::error_code filesystem_error;
std::filesystem::create_directories(TestOutputRoot(), filesystem_error);
Expect(!filesystem_error,
"test output root should be created under RM_TEST_OUTPUT_DIR: " +
filesystem_error.message());
TestParsesStrictSevenJointJsonLines();
TestUsesExplicitRawUnitScale();
TestRejectsInvalidArgumentsAndTimestampOverflow();
TestRejectsMalformedVendorLines();
TestRejectsEmptyAndNonRegularInputs();
return failures_;
}
private:
int failures_ = 0;
void Expect(bool condition, const std::string &message) {
if (!condition) {
++failures_;
std::cerr << "[FAIL] " << message << '\n';
}
}
std::filesystem::path TestOutputRoot() const {
return std::filesystem::path(RM_TEST_OUTPUT_DIR) /
"vendor_drag_trajectory_parser_tests";
}
std::filesystem::path TestFile(const std::string &name) const {
return TestOutputRoot() / name;
}
bool WriteBinaryFile(const std::filesystem::path &path, const std::string &content) {
std::ofstream output(path, std::ios::binary | std::ios::trunc);
output.write(content.data(), static_cast<std::streamsize>(content.size()));
return static_cast<bool>(output);
}
bool NearlyEqual(float actual, float expected) const {
return std::abs(actual - expected) <= 0.00001F;
}
void TestParsesStrictSevenJointJsonLines() {
const std::string content =
"{\"point\":[1000,-2000,0,500,250,-750,3000]}\r\n"
" { \"point\" : [2000,-1000,1000,1500,1250,250,4000] } \r\n";
const auto path = TestFile("valid_crlf.txt");
Expect(WriteBinaryFile(path, content), "valid CRLF fixture should be written");
const auto result = trajectory::ParseVendorDragTrajectoryJsonLines(path, 20);
Expect(result.ok, "valid vendor JSONL should parse: " + result.error_message);
if (!result.ok) {
return;
}
Expect(result.value.source_file == path, "result should preserve the source path");
Expect(result.value.trajectory_point_count == 2U,
"result should report two trajectory points");
Expect(result.value.samples.size() == 2U, "result should contain two samples");
Expect(result.value.source_file_size_bytes == content.size(),
"result should report the original byte size");
Expect(result.value.source_file_sha256 ==
"b2274aa15734a7373e528fef19d4f3676b1e8f22a03d60dd56c36a2aa8997080",
"result should report the SHA-256 of the exact CRLF bytes");
Expect(result.value.timestamp_is_derived,
"vendor timestamps should be explicitly marked derived");
Expect(result.value.sample_period_ms == 20,
"result should preserve the explicit sample period");
Expect(result.value.raw_units_per_degree == 1000,
"default vendor scale should be 1000 raw units per degree");
Expect(result.value.source == trajectory::kRealmanVendorDragTrajectorySource,
"result should identify the vendor source format");
const auto &first = result.value.samples[0];
const auto &second = result.value.samples[1];
Expect(first.sample_index == 0U && second.sample_index == 1U,
"sample indexes should be contiguous and start at zero");
Expect(first.elapsed_ms == 0 && second.elapsed_ms == 20,
"timestamps should be derived from the explicit period");
Expect(first.joints_deg.size() == 7U && second.joints_deg.size() == 7U,
"every RM75 sample should contain seven joints");
Expect(NearlyEqual(first.joints_deg[0], 1.0F) &&
NearlyEqual(first.joints_deg[1], -2.0F) &&
NearlyEqual(first.joints_deg[6], 3.0F),
"raw integer joints should convert to degree without changing order");
Expect(first.source == trajectory::kRealmanVendorDragTrajectorySource &&
second.source == trajectory::kRealmanVendorDragTrajectorySource,
"every sample should retain vendor source provenance");
}
void TestUsesExplicitRawUnitScale() {
const auto path = TestFile("explicit_scale.txt");
Expect(WriteBinaryFile(path, "{\"point\":[2000,0,0,0,0,0,0]}\n"),
"explicit-scale fixture should be written");
const auto result = trajectory::ParseVendorDragTrajectoryJsonLines(path, 5, 2000);
Expect(result.ok, "positive explicit raw unit scale should parse");
if (result.ok) {
Expect(result.value.raw_units_per_degree == 2000,
"result should preserve the explicit raw scale");
Expect(NearlyEqual(result.value.samples[0].joints_deg[0], 1.0F),
"explicit raw scale should control degree conversion");
}
}
void TestRejectsInvalidArgumentsAndTimestampOverflow() {
const auto path = TestFile("arguments.txt");
const std::string three_points =
"{\"point\":[0,0,0,0,0,0,0]}\n"
"{\"point\":[0,0,0,0,0,0,0]}\n"
"{\"point\":[0,0,0,0,0,0,0]}\n";
Expect(WriteBinaryFile(path, three_points), "argument fixture should be written");
Expect(!trajectory::ParseVendorDragTrajectoryJsonLines({}, 1).ok,
"empty input path should be rejected");
Expect(!trajectory::ParseVendorDragTrajectoryJsonLines(path, 0).ok,
"zero sample period should be rejected");
Expect(!trajectory::ParseVendorDragTrajectoryJsonLines(path, -1).ok,
"negative sample period should be rejected");
Expect(!trajectory::ParseVendorDragTrajectoryJsonLines(path, 1, 0).ok,
"zero raw units per degree should be rejected");
Expect(!trajectory::ParseVendorDragTrajectoryJsonLines(path, 1, -1000).ok,
"negative raw units per degree should be rejected");
Expect(!trajectory::ParseVendorDragTrajectoryJsonLines(
path, std::numeric_limits<std::int64_t>::max()).ok,
"derived timestamp multiplication overflow should be rejected");
}
void TestRejectsMalformedVendorLines() {
const std::vector<std::pair<std::string, std::string>> invalid_files = {
{"blank_line.txt",
"{\"point\":[0,0,0,0,0,0,0]}\n\n{\"point\":[0,0,0,0,0,0,0]}\n"},
{"unknown_key.txt", "{\"joints\":[0,0,0,0,0,0,0]}\n"},
{"six_joints.txt", "{\"point\":[0,0,0,0,0,0]}\n"},
{"eight_joints.txt", "{\"point\":[0,0,0,0,0,0,0,0]}\n"},
{"floating_joint.txt", "{\"point\":[0,0,0,0,0,0,1.5]}\n"},
{"leading_zero.txt", "{\"point\":[00,0,0,0,0,0,0]}\n"},
{"positive_sign.txt", "{\"point\":[+1,0,0,0,0,0,0]}\n"},
{"extra_field.txt", "{\"point\":[0,0,0,0,0,0,0],\"time\":1}\n"},
{"array_root.txt", "[[0,0,0,0,0,0,0]]\n"},
{"int64_overflow.txt",
"{\"point\":[9223372036854775808,0,0,0,0,0,0]}\n"},
{"negative_int64_overflow.txt",
"{\"point\":[-9223372036854775809,0,0,0,0,0,0]}\n"},
{"trailing_object.txt",
"{\"point\":[0,0,0,0,0,0,0]}{\"point\":[0,0,0,0,0,0,0]}\n"},
};
for (const auto &fixture : invalid_files) {
const auto path = TestFile(fixture.first);
Expect(WriteBinaryFile(path, fixture.second),
"malformed fixture should be written: " + fixture.first);
const auto result = trajectory::ParseVendorDragTrajectoryJsonLines(path, 10);
Expect(!result.ok, "malformed vendor line should be rejected: " + fixture.first);
if (!result.ok && fixture.first != "blank_line.txt") {
Expect(result.error_message.find("line 1") != std::string::npos,
"format error should include physical line context: " + fixture.first);
}
}
}
void TestRejectsEmptyAndNonRegularInputs() {
const auto empty_path = TestFile("empty.txt");
Expect(WriteBinaryFile(empty_path, ""), "empty fixture should be written");
Expect(!trajectory::ParseVendorDragTrajectoryJsonLines(empty_path, 10).ok,
"empty vendor trajectory should be rejected");
Expect(!trajectory::ParseVendorDragTrajectoryJsonLines(TestOutputRoot(), 10).ok,
"directory input should be rejected");
Expect(!trajectory::ParseVendorDragTrajectoryJsonLines(
TestFile("missing.txt"), 10).ok,
"missing input should be rejected");
}
};
} // namespace
int main() {
VendorDragTrajectoryParserTestSuite suite;
const int failures = suite.Run();
if (failures == 0) {
std::cout << "[PASS] rm_vendor_drag_trajectory_parser_tests\n";
return 0;
}
std::cerr << failures << " test(s) failed\n";
return 1;
}
#pragma once
#include "rm_control/interfaces/dexterous_hand.h"
namespace flight_robot::linker_hand {
/**
* @brief 通过 RM75 工具端 Modbus RTU 实现 O6 统一灵巧手接口。
*
* @reason 将 O6 功能码、寄存器地址、方向和安全值校验集中在厂商适配边界,业务状态机只依赖
* IDexterousHand,且不会接触 RM_API2 类型。
*
* @author 唐永康
* @date 2026-07-11
*
* @calls IToolModbusBus 的状态、输入/保持寄存器读取和保持寄存器写入能力。
* @safety ReadReadiness 只读;CommandMotion 可能触发真实 O6 运动并必须受上层门控。
* @note 协议字段来源为 `docs/O6机械手485协议简要说明.xlsx`,当前只支持 O6 六自由度手。
*/
class LinkerHandAdapter final : public rm_control::interfaces::IDexterousHand {
public:
/**
* @brief 构造指定左右手的 O6 工具端 Modbus 适配器。
*
* @reason 左右手决定从站地址和 identity direction,构造时固定可防止运行中错误切换设备。
*
* @author 唐永康
* @date 2026-07-10
*
* @param tool_bus 已由 Application 注入的 RM75 工具端总线;适配器不拥有该对象。
* @param side 目标 O6 手侧;Right 对应 0x27/'R',Left 对应 0x28/'L'。
* @return 构造函数无显式返回值。
*
* @calls 不访问总线,首次通信发生在 ReadReadiness 或 CommandMotion。
* @safety 构造过程不触发硬件通信或运动。
* @note tool_bus 生命周期必须长于本适配器。
*/
LinkerHandAdapter(
rm_control::interfaces::IToolModbusBus &tool_bus,
rm_control::interfaces::DexterousHandSide side);
LinkerHandAdapter(const LinkerHandAdapter &) = delete;
LinkerHandAdapter &operator=(const LinkerHandAdapter &) = delete;
LinkerHandAdapter(LinkerHandAdapter &&) = delete;
LinkerHandAdapter &operator=(LinkerHandAdapter &&) = delete;
/**
* @brief 销毁 O6 协议适配器。
*
* @reason 适配器只借用总线引用,因此析构不应改变工具电压、通信模式或手部姿态。
*
* @author 唐永康
* @date 2026-07-10
*
* @return 析构函数无返回值且不抛出异常。
*
* @calls 不调用总线或厂商接口。
* @safety 不触发硬件动作,也不自动断电或松手。
* @note 总线资源由其所有者负责清理。
*/
~LinkerHandAdapter() noexcept override = default;
/**
* @brief 读取并验证当前 O6 的工具总线和全部 readiness 输入寄存器。
*
* @reason 适配器集中执行 O6 地址、身份方向、六自由度、位置、转矩、速度、温度和
* 故障规则,使上层只接收完整合规的 readiness,而不会误用部分读取结果。
*
* @author 唐永康
* @date 2026-07-11
*
* @return 全部读取与校验成功时返回 `ready=true` 的快照;否则返回首个带上下文的失败。
*
* @calls IToolModbusBus::GetStatus 和 IToolModbusBus::ReadInputRegister。
* @safety 仅使用功能码 04 读取状态,不触发 O6 或 RM75 运动。
* @note 不自动重试;调用方不得把失败前读取的部分字段作为安全门控依据。
*/
rm_control::common::Result<rm_control::interfaces::DexterousHandReadiness>
ReadReadiness() override;
/**
* @brief 按转矩、速度、最后位置的顺序下发 O6 FC16 动作命令。
*
* @reason O6 的 FC04 地址返回实时转矩和实时速度,并不是 FC16 命令回读;因此只校验
* 三组命令范围和每次 FC16 返回码,动作后的实际位置由上层 ReadReadiness 确认。
*
* @author 唐永康
* @date 2026-07-11
*
* @param command 六关节位置、速度和转矩命令,每项有效范围 0..255;调用期间只读。
* @return 全部输入合规且三次 FC16 写入均成功时成功,否则返回首个错误。
*
* @calls IToolModbusBus::WriteHoldingRegisters。
* @safety 可能立即触发真实 O6 动作,只能在上层 Live、安全限制和操作者确认门控后调用。
* @note 不读取 readiness 且不自动重试;转矩或速度任一步失败时不会下发位置。
*/
rm_control::common::Result<void> CommandMotion(
const rm_control::interfaces::O6MotionCommand &command) override;
private:
rm_control::interfaces::IToolModbusBus &tool_bus_;
rm_control::interfaces::DexterousHandSide side_;
};
} // namespace flight_robot::linker_hand
#pragma once
#include "rm_control/common/result.h"
#include <cstdint>
#include <vector>
namespace flight_robot::realman {
/**
* @brief 将 Modbus 16 位寄存器编码为 RM_API2 使用的高字节、低字节 int 序列。
*
* @reason RM_API2 的多寄存器接口以 `int*` 承载 `num * 2` 个字节;集中编码可避免每个
* 厂商调用各自实现位移和掩码,并允许完全脱离硬件验证边界值。
*
* @author 唐永康
* @date 2026-07-11
*
* @param register_values 按 Modbus 地址递增排列的 16 位寄存器值;不得为空。
* @return 成功时每个寄存器生成两个 0..255 的 int,顺序为高字节后低字节;输入为空或
* 长度无法安全扩展为两倍时返回失败。
*
* @calls 只使用标准库容器和整数位运算,不调用 RM_API2。
* @safety 纯数据转换,不连接设备,也不触发机械臂或 O6 动作。
* @note 返回缓冲区长度严格等于 `register_values.size() * 2`。
*/
rm_control::common::Result<std::vector<int>>
EncodeModbusRegistersAsRmApiHighLowBytes(
const std::vector<std::uint16_t> &register_values);
/**
* @brief 将 RM_API2 返回的高低字节 int 序列解码为 Modbus 16 位寄存器。
*
* @reason RM_API2 文档把 `int*` 元素描述为 int8,不同实现可能返回有符号 int8 或
* 0..255 原始字节;统一验证和归一化可拒绝掩码会静默截断的非法整数。
*
* @author 唐永康
* @date 2026-07-11
*
* @param rm_api_high_low_bytes 高字节、低字节交替排列的 RM_API2 int 序列;不得为空。
* @return 成功时返回按地址递增的寄存器;长度为奇数或元素超出 -128..255 时返回失败。
*
* @calls 只使用标准库容器和整数位运算,不调用 RM_API2。
* @safety 纯数据转换,不连接设备,也不触发机械臂或 O6 动作。
* @note -128..-1 按有符号 int8 的同位无符号字节归一化,编码函数始终输出 0..255。
*/
rm_control::common::Result<std::vector<std::uint16_t>>
DecodeModbusRegistersFromRmApiHighLowBytes(
const std::vector<int> &rm_api_high_low_bytes);
} // namespace flight_robot::realman
#pragma once
#include "rm_control/interfaces/robot_arm.h"
#include "rm_control/interfaces/tool_modbus_bus.h"
#include <memory>
namespace flight_robot::realman {
/**
* @brief 将统一机械臂接口映射到睿尔曼 RM_API2。
*
* @reason 使用 PImpl 隔离厂商 SDK 类型,确保业务层和兼容头不包含 rm_interface.h。
*
* @author 唐永康
* @date 2026-07-10
*
* @calls RM_API2 连接、状态、运动、拖动示教和原生轨迹复现接口。
* @safety 本类包含真实运动能力;调用方必须先通过上层安全门控。
* @note 当前只支持七自由度 RM75-6F,不提供其他机械臂型号兼容承诺。
*/
class RealmanArmAdapter final : public rm_control::interfaces::IRobotArm,
public rm_control::interfaces::IToolModbusBus {
public:
/**
* @brief 构造未连接的睿尔曼适配器。
* @reason PImpl 在构造期准备私有状态,同时不初始化 SDK 或连接设备。
* @author 唐永康
* @date 2026-07-10
* @return 构造函数无返回值。
* @calls 不调用厂商接口。
* @safety 不触发运动或硬件通信。
* @note 调用 connect 前所有硬件接口都会返回未连接错误。
*/
RealmanArmAdapter();
RealmanArmAdapter(const RealmanArmAdapter &) = delete;
RealmanArmAdapter &operator=(const RealmanArmAdapter &) = delete;
RealmanArmAdapter(RealmanArmAdapter &&) = delete;
RealmanArmAdapter &operator=(RealmanArmAdapter &&) = delete;
/**
* @brief 析构适配器并释放可能存在的 SDK 连接。
* @reason RAII 保证异常路径和提前返回路径不会泄漏厂商句柄。
* @author 唐永康
* @date 2026-07-10
* @return 析构函数无返回值且不抛出异常。
* @calls disconnect、厂商断开与 SDK 销毁接口。
* @safety 只执行资源清理,不主动发送运动指令。
* @note SDK 清理错误仅记录到标准错误流。
*/
~RealmanArmAdapter() noexcept override;
/** @copydoc rm_control::interfaces::IRobotArm::connect */
rm_control::common::Result<void>
connect(const rm_control::common::RobotConnectionConfig &config) override;
/** @copydoc rm_control::interfaces::IRobotArm::disconnect */
void disconnect() override;
/** @copydoc rm_control::interfaces::IRobotArm::isConnected */
bool isConnected() const noexcept override;
/** @copydoc rm_control::interfaces::IRobotArm::getRobotInfo */
rm_control::common::Result<rm_control::common::RobotInfo> getRobotInfo() override;
/** @copydoc rm_control::interfaces::IRobotArm::getRobotSoftwareInfo */
rm_control::common::Result<rm_control::common::RobotSoftwareInfo>
getRobotSoftwareInfo() override;
/** @copydoc rm_control::interfaces::IRobotArm::getCurrentArmState */
rm_control::common::Result<rm_control::common::ArmState> getCurrentArmState() override;
/** @copydoc rm_control::interfaces::IRobotArm::getForceData */
rm_control::common::Result<rm_control::common::ForceData> getForceData() override;
/** @copydoc rm_control::interfaces::IRobotArm::getRobotSafetyStatus */
rm_control::common::Result<rm_control::common::RobotSafetyStatus>
getRobotSafetyStatus() override;
/** @copydoc rm_control::interfaces::IRobotArm::getJointLimits */
rm_control::common::Result<rm_control::common::RobotJointLimits>
getJointLimits() override;
/** @copydoc rm_control::interfaces::IRobotArm::moveJ */
rm_control::common::Result<void> moveJ(const std::vector<float> &joints_deg,
int speed_percent,
int blend_radius,
int trajectory_connect,
bool blocking) override;
/** @copydoc rm_control::interfaces::IRobotArm::moveL */
rm_control::common::Result<void> moveL(const rm_control::common::Pose &pose,
int speed_percent,
int blend_radius,
int trajectory_connect,
bool blocking) override;
/** @copydoc rm_control::interfaces::IRobotArm::startDragTeach */
rm_control::common::Result<void>
startDragTeach(const rm_control::common::DragTeachStartOptions &options) override;
/** @copydoc rm_control::interfaces::IRobotArm::stopDragTeach */
rm_control::common::Result<void> stopDragTeach() override;
/** @copydoc rm_control::interfaces::IRobotArm::saveDragTeachTrajectory */
rm_control::common::VendorTrajectorySaveAttempt
saveDragTeachTrajectory(const std::filesystem::path &path) override;
/** @copydoc rm_control::interfaces::IRobotArm::moveToDragTrajectoryOrigin */
rm_control::common::Result<void> moveToDragTrajectoryOrigin(bool blocking) override;
/** @copydoc rm_control::interfaces::IRobotArm::replayLastDragTrajectory */
rm_control::common::Result<void> replayLastDragTrajectory(bool blocking) override;
/** @copydoc rm_control::interfaces::IRobotArm::pauseDragTrajectoryReplay */
rm_control::common::Result<void> pauseDragTrajectoryReplay() override;
/** @copydoc rm_control::interfaces::IRobotArm::continueDragTrajectoryReplay */
rm_control::common::Result<void> continueDragTrajectoryReplay() override;
/** @copydoc rm_control::interfaces::IRobotArm::stopDragTrajectoryReplay */
rm_control::common::Result<void> stopDragTrajectoryReplay() override;
/** @copydoc rm_control::interfaces::IRobotArm::saveVendorDragTrajectoryProgram */
rm_control::common::Result<void> saveVendorDragTrajectoryProgram(
const rm_control::common::VendorDragTrajectoryExecutionRequest &request) override;
/** @copydoc rm_control::interfaces::IRobotArm::runControllerProgram */
rm_control::common::Result<void> runControllerProgram(
const rm_control::common::ControllerProgramRunRequest &request) override;
/** @copydoc rm_control::interfaces::IRobotArm::executeVendorDragTrajectoryFile */
rm_control::common::Result<void> executeVendorDragTrajectoryFile(
const rm_control::common::VendorDragTrajectoryExecutionRequest &request) override;
/** @copydoc rm_control::interfaces::IRobotArm::getControllerProgramRunState */
rm_control::common::Result<rm_control::common::ControllerProgramRunState>
getControllerProgramRunState() override;
/** @copydoc rm_control::interfaces::IRobotArm::slowStopMotion */
rm_control::common::Result<void> slowStopMotion() override;
/** @copydoc rm_control::interfaces::IRobotArm::isControllerProgramSlotAvailable */
rm_control::common::Result<bool> isControllerProgramSlotAvailable(
int controller_program_id) override;
/** @copydoc rm_control::interfaces::IToolModbusBus::GetStatus */
rm_control::common::Result<rm_control::interfaces::ToolModbusBusStatus>
GetStatus() override;
/** @copydoc rm_control::interfaces::IToolModbusBus::Configure */
rm_control::common::Result<rm_control::interfaces::ToolModbusBusStatus>
Configure(
const rm_control::interfaces::ToolModbusBusConfiguration &configuration) override;
/** @copydoc rm_control::interfaces::IToolModbusBus::ReadInputRegister */
rm_control::common::Result<std::uint16_t> ReadInputRegister(
std::uint8_t device_id,
std::uint16_t address) override;
/** @copydoc rm_control::interfaces::IToolModbusBus::ReadInputRegisters */
rm_control::common::Result<std::vector<std::uint16_t>> ReadInputRegisters(
std::uint8_t device_id,
std::uint16_t start_address,
std::size_t register_count) override;
/** @copydoc rm_control::interfaces::IToolModbusBus::ReadHoldingRegisters */
rm_control::common::Result<std::vector<std::uint16_t>> ReadHoldingRegisters(
std::uint8_t device_id,
std::uint16_t start_address,
std::size_t register_count) override;
/** @copydoc rm_control::interfaces::IToolModbusBus::WriteHoldingRegisters */
rm_control::common::Result<void> WriteHoldingRegisters(
std::uint8_t device_id,
std::uint16_t start_address,
const std::vector<std::uint16_t> &values) override;
private:
class Implementation;
std::unique_ptr<Implementation> implementation_;
};
} // namespace flight_robot::realman
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment