feat: add gRPC motor control over Modbus TCP

Add synchronous and streaming MotorService APIs backed by the PLC Modbus TCP runtime and protocol driver. Extend AUBO JSON commands and isolate vendor libstdc++ paths while keeping build-tree tests runnable.
This commit is contained in:
xtkuang 2026-07-30 15:09:07 +08:00
parent f443a7ce53
commit 28f1dd1bf8
36 changed files with 10736 additions and 5 deletions

View File

@ -9,7 +9,29 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
#set(CMAKE_CXX_STANDARD_REQUIRED True)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
# Preserve the project's production-build behavior: tests are opt-in via
# -DBUILD_TESTING=ON, while still registering them with CTest when requested.
option(BUILD_TESTING "Build the test targets" OFF)
include(CTest)
if(BUILD_TESTING AND UNIX AND NOT APPLE)
# Test executables can still inherit the AUBO imported target's build-tree
# RUNPATH. Keep the active toolchain runtime ahead of that vendor path.
execute_process(
COMMAND ${CMAKE_CXX_COMPILER} -print-file-name=libstdc++.so.6
OUTPUT_VARIABLE CMVR_TEST_SYSTEM_LIBSTDCXX
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(EXISTS "${CMVR_TEST_SYSTEM_LIBSTDCXX}")
get_filename_component(
CMVR_TEST_SYSTEM_LIBSTDCXX
"${CMVR_TEST_SYSTEM_LIBSTDCXX}"
REALPATH
)
else()
unset(CMVR_TEST_SYSTEM_LIBSTDCXX)
endif()
endif()
# Install to <source>/output
set(CMAKE_INSTALL_PREFIX "${CMAKE_SOURCE_DIR}/output" CACHE PATH "" FORCE)
@ -18,9 +40,6 @@ set(CMAKE_INSTALL_PREFIX "${CMAKE_SOURCE_DIR}/output" CACHE PATH "" FORCE)
set(CMAKE_BUILD_RPATH "\$ORIGIN:\$ORIGIN/../lib")
set(CMAKE_INSTALL_RPATH "\$ORIGIN:\$ORIGIN/../lib")
# Use transitive RPATH so CLion can run build-tree test executables without
# manually setting LD_LIBRARY_PATH for indirect third-party dependencies.
add_link_options(-Wl,--disable-new-dtags)
set(CMAKE_BUILD_WITH_INSTALL_RPATH OFF)
set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE)
@ -31,6 +50,9 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
include(FindExternalLib)
set(ARCH "x86")
setup_external_libs(${ARCH})
if(BUILD_TESTING AND CMVR_EXTERNAL_LIBRARY_DIRS)
list(JOIN CMVR_EXTERNAL_LIBRARY_DIRS ":" CMVR_TEST_EXTERNAL_LIBRARY_PATH)
endif()
# setup_external_libs
message(STATUS "CMAKE_EXE_LINKER_FLAGS: ${CMAKE_EXE_LINKER_FLAGS}")
message(STATUS "CMAKE_SHARED_LINKER_FLAGS: ${CMAKE_SHARED_LINKER_FLAGS}")

117
README.md
View File

@ -138,3 +138,120 @@ $IGH_ETHERCAT_ROOT/bin/ethercat pdos
sudo script/ethercat/stop_ethercat.sh eno1
sudo script/ethercat/stop_ethercat.sh eno1 --restore-network
```
## MotorService 与 Modbus TCP PLC
工程包含从 gRPC `MotorService`、`MotorManager`、`AbstractMotor` 到
`ModbusTcpMotorBusRuntime` 的 CMVR PLC v1 电机控制链x86-64 的 libmodbus
3.1.11 已放在 `dependency/x86/third_party/modbus/3.1.11`
PLC 对接时特别注意:
- `host` 必须配置为 IPv4 字面量PLC boot ID 必须非零且每次重启变化;
- owner 决策、命令 ACK 都必须回显对应 session重连不得执行旧 mailbox
- PLC 在进程启动时可以离线;连接 supervisor 会继续退避重试,离线期间状态
返回不可用且运动命令不会写 mailbox
- 状态区按 odd/even seqlock 发布,上位机使用
sequence-before → 64-word block → sequence-after 三段读取验证;
- 每次 `OpenCyclicPosition/Velocity` 创建新 stream epochPLC 必须原子清零
`last_applied_cyclic_sequence`、旧样本去重状态和 cyclic watchdog确认
`StreamActive` 与正确 mode 后才 ACK重开后的首样本序列从 `1` 开始并必须
重新应用;
- 活动 cyclic 流跨 `connection_epoch` 后不会自动重开;旧流的当前和后续
setpoint 均被拒绝并在 Quick Stop 后终止,客户端必须新建 gRPC 流。断链前
或断链期间 pending 的 setpoint 不会应用到新 session
- 任何清理 Quick Stop 未确认时MotorService 都会 fail-closed 锁存,并在
成功执行 `setEnabled(true)` 前拒绝新的运动命令;
- Modbus Quick Stop 只是功能性停止,不能替代硬接线急停或驱动器 STO。
- 当前 Modbus 后端只提供 x86-64 的 libmodbus 3.1.11`dependency/arm`
尚无对应库,因此 ARM 构建暂不支持该后端。
完整 gRPC 语义、配置样例、寄存器表、TIA Portal 要求、构建测试和安全边界见
[`docs/motor_service_modbus_tcp.md`](docs/motor_service_modbus_tcp.md)。
## AUBO 控制柜 IO
`AuboArm` 通过通用的 `executeJsonCommand` 接口提供控制柜 Standard 数字 IO
读写。第一版支持以下命令:
| `operation` | 说明 | 必填字段 |
| --- | --- | --- |
| `get_di` | 读取控制柜数字输入 | `index` |
| `get_do` | 读取控制柜数字输出及其 runstate | `index` |
| `set_do` | 设置控制柜数字输出 | `index`、`value` |
JSON 命令示例:
```json
{"command":"cabinet_io","operation":"get_di","index":0}
{"command":"cabinet_io","operation":"get_do","index":0}
{"command":"cabinet_io","operation":"set_do","index":0,"value":true}
```
其中 `index``0` 开始,运行时会根据控制器返回的 IO 数量检查范围。
`set_do.value` 必须是 JSON 布尔值 `true``false`,不接受 `0/1` 或字符串。
`set_do` 成功响应中的 `requested_value` 表示 SDK 已接受的请求值;需要确认控制器
当前输出状态时,再调用一次 `get_do` 读取实际值。
成功响应示例:
```json
{
"success": true,
"command": "cabinet_io",
"operation": "get_di",
"index": 0,
"count": 16,
"value": false
}
```
### 通过 gRPC 调用
该功能复用 `cmvr.api.SystemService/ExecuteJsonCommand`。默认配置中的 gRPC
端口是 `50052`,读取 DI0
```shell
grpcurl -plaintext \
-d '{
"header":{"deviceId":"aubo_arm"},
"requestJson":"{\"command\":\"cabinet_io\",\"operation\":\"get_di\",\"index\":0}"
}' \
127.0.0.1:50052 \
cmvr.api.SystemService/ExecuteJsonCommand
```
设置 DO0 为高电平:
```shell
grpcurl -plaintext \
-d '{
"header":{"deviceId":"aubo_arm"},
"requestJson":"{\"command\":\"cabinet_io\",\"operation\":\"set_do\",\"index\":0,\"value\":true}"
}' \
127.0.0.1:50052 \
cmvr.api.SystemService/ExecuteJsonCommand
```
使用源码树默认配置时,先在 `cmvr-es/config/manager/device_manager.pb.txt` 中把
`aubo_arm``enable` 改为 `true`,并在
`cmvr-es/config/devices/arm/aubo_arm.pb.txt` 中配置正确的控制器地址和登录信息,
然后重新安装配置并启动安装产物:
```shell
cmake --install build
./output/bin/cmvr_es
```
`output/bin/cmvr_es` 读取的是 `output/bin/config/`;如果进程使用显式配置路径,
应修改该配置根下的对应文件。
设备未启用或初始化失败时gRPC 会返回 `Device not found: aubo_arm`
### 安全约束
- 该接口只访问控制柜 Standard 数字 IO不操作工具端 IO、可配置 IO 或安全 IO。
- `set_do` 不会修改控制器的输出 runstate。只有目标通道的 runstate 为
`StandardOutputRunState::None` 时才允许写入,否则返回
`output_managed_by_runstate`
- 接口不会调用会重置全部输出配置的 `setDigitalOutputRunstateDefault()`
- 模拟量 IO 涉及 domain、单位和量程第一版暂不通过该 JSON 接口开放。

View File

@ -55,7 +55,17 @@ function(setup_external_libs ARCH)
# ---- library dirs ----
if(EXISTS "${FULL_PATH}/lib")
file(GLOB _BUNDLED_LIBSTDCXX_FILES
"${FULL_PATH}/lib/libstdc++.so"
"${FULL_PATH}/lib/libstdc++.so.*"
)
if(_BUNDLED_LIBSTDCXX_FILES)
message(STATUS
"${LIB_NAME}: excluding vendor lib directory from global "
"link paths because it contains a private libstdc++")
else()
list(APPEND LIBRARY_DIRS "${FULL_PATH}/lib")
endif()
set(HAS_LIB TRUE)
# Collect shared libs for install: *.so and *.so.*
@ -129,6 +139,7 @@ function(setup_external_libs ARCH)
list(REMOVE_DUPLICATES LIBRARY_DIRS)
link_directories(${LIBRARY_DIRS})
endif()
set(CMVR_EXTERNAL_LIBRARY_DIRS "${LIBRARY_DIRS}" PARENT_SCOPE)
# ---- install third-party shared libs into <prefix>/lib ----
if(INSTALL_SO_FILES)

View File

@ -0,0 +1,56 @@
motor {
id: "plc_motors"
motor_groups {
id: "plc_axis_group"
bus_type: MOTOR_BUS_MODBUS_TCP
vendor: MOTOR_VENDOR_PLC_GENERIC
protocol: MOTOR_PROTOCOL_CMVR_PLC_V1
modbus_tcp {
host: "192.168.0.10"
port: 502
unit_id: 1
connect_timeout_ms: 500
io_timeout_ms: 100
heartbeat_period_ms: 100
communication_watchdog_ms: 1000
cyclic_watchdog_ms: 500
status_poll_period_ms: 20
reconnect_min_ms: 100
reconnect_max_ms: 2000
command_ack_timeout_ms: 500
protocol_major: 1
protocol_minor: 0
axes { motor_id: 1 axis_index: 0 }
axes { motor_id: 2 axis_index: 1 }
}
joint_limits {
enable: true
source: JOINT_LIMIT_SOURCE_CUSTOM
joints {
joint_name: "PLC_AXIS_1"
q_lb: -3.141592653589793
q_ub: 3.141592653589793
qd: 1.0
qdd: 2.0
}
joints {
joint_name: "PLC_AXIS_2"
q_lb: -1.5707963267948966
q_ub: 1.5707963267948966
qd: 0.5
qdd: 1.0
}
}
motors {
motors { id: 1 joint_name: "PLC_AXIS_1" }
motors { id: 2 joint_name: "PLC_AXIS_2" }
}
}
}

View File

@ -82,6 +82,14 @@ device_manager {
enable: false
}
devices {
id: "plc_motors"
type: DEVICE_TYPE_MOTOR_SYSTEM
config_file: "devices/motor/plc_motors.pb.txt"
# Configure the PLC endpoint and complete the safety checkout before enabling.
enable: false
}
devices {
id: "right_arm"
type: DEVICE_TYPE_ROBOT_ARM

View File

@ -46,7 +46,54 @@ target_link_libraries(aubo_arm
cmvr_es::proto
PRIVATE
glog
jsoncpp
)
add_library(cmvr_es::device::aubo_arm ALIAS aubo_arm)
install(TARGETS aubo_arm LIBRARY DESTINATION lib)
if(BUILD_TESTING)
enable_testing()
add_executable(aubo_arm_json_command_test
tests/aubo_arm_json_command_test.cpp
)
target_link_libraries(aubo_arm_json_command_test
PRIVATE
cmvr_es::device::aubo_arm
cmvr_es::proto
)
add_test(
NAME aubo_arm_json_command_test
COMMAND aubo_arm_json_command_test
)
set_tests_properties(aubo_arm_json_command_test PROPERTIES
TIMEOUT 10
ENVIRONMENT "LD_LIBRARY_PATH=${CMVR_TEST_EXTERNAL_LIBRARY_PATH}"
)
if(UNIX AND NOT APPLE)
# The imported AUBO target still contributes its vendor directory to
# direct consumers' build-tree RUNPATH. Put the system runtime first
# for this test; installed artifacts exclude the vendor libstdc++.
execute_process(
COMMAND ${CMAKE_CXX_COMPILER} -print-file-name=libstdc++.so.6
OUTPUT_VARIABLE AUBO_TEST_SYSTEM_LIBSTDCXX
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(EXISTS "${AUBO_TEST_SYSTEM_LIBSTDCXX}")
get_filename_component(
AUBO_TEST_SYSTEM_LIBSTDCXX_REAL
"${AUBO_TEST_SYSTEM_LIBSTDCXX}"
REALPATH
)
get_filename_component(
AUBO_TEST_SYSTEM_LIBSTDCXX_DIR
"${AUBO_TEST_SYSTEM_LIBSTDCXX_REAL}"
DIRECTORY
)
set_property(
TARGET aubo_arm_json_command_test
PROPERTY BUILD_RPATH "${AUBO_TEST_SYSTEM_LIBSTDCXX_DIR}"
)
endif()
endif()
endif()

View File

@ -1,12 +1,15 @@
#include "devices/arm/aubo_arm/aubo_arm.h"
#include <algorithm>
#include <cctype>
#include <chrono>
#include <cstring>
#include <exception>
#include <thread>
#include <tuple>
#include "common/base/logging/logger.h"
#include "json/json.h"
#include "aubo_sdk/rpc.h"
@ -44,6 +47,103 @@ using arcs::aubo_sdk::RobotInterfacePtr;
constexpr int kAuboServoMode = 3;
enum class CabinetIoOperation {
GetDigitalInput,
GetDigitalOutput,
SetDigitalOutput,
};
std::string lowerString(std::string value)
{
std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return value;
}
bool parseJsonCommand(const std::string& request_json,
Json::Value& root,
std::string& error)
{
Json::CharReaderBuilder builder;
std::unique_ptr<Json::CharReader> reader(builder.newCharReader());
return reader->parse(
request_json.data(),
request_json.data() + request_json.size(),
&root,
&error);
}
std::string compactJson(const Json::Value& value)
{
Json::StreamWriterBuilder builder;
builder[std::string("indentation")] = "";
return Json::writeString(builder, value);
}
Json::Value& jsonMember(Json::Value& root, const char* name)
{
return *root.demand(name, name + std::strlen(name));
}
const Json::Value* findJsonMember(const Json::Value& root, const char* name)
{
return root.find(name, name + std::strlen(name));
}
bool requiredJsonString(const Json::Value& root,
const char* name,
std::string& value)
{
const Json::Value* member = findJsonMember(root, name);
if (!member || !member->isString() || member->asString().empty()) {
return false;
}
value = member->asString();
return true;
}
bool requiredJsonInt(const Json::Value& root, const char* name, int& value)
{
const Json::Value* member = findJsonMember(root, name);
if (!member || !member->isInt()) {
return false;
}
value = member->asInt();
return true;
}
bool requiredJsonBool(const Json::Value& root, const char* name, bool& value)
{
const Json::Value* member = findJsonMember(root, name);
if (!member || !member->isBool()) {
return false;
}
value = member->asBool();
return true;
}
bool parseCabinetIoOperation(const std::string& name, CabinetIoOperation& operation)
{
const std::string normalized = lowerString(name);
if (normalized == "get_di") {
operation = CabinetIoOperation::GetDigitalInput;
} else if (normalized == "get_do") {
operation = CabinetIoOperation::GetDigitalOutput;
} else if (normalized == "set_do") {
operation = CabinetIoOperation::SetDigitalOutput;
} else {
return false;
}
return true;
}
std::string standardOutputRunstateName(
const arcs::common_interface::StandardOutputRunState runstate)
{
return arcs::common_interface::toString(runstate);
}
RobotInterfacePtr getPrimaryRobotInterface(const std::shared_ptr<arcs::aubo_sdk::RpcClient>& rpc_client,
const std::string& context,
Result& result)
@ -179,6 +279,142 @@ bool AuboArm::stop()
return stopMotion().ok();
}
bool AuboArm::executeJsonCommand(const std::string& request_json,
std::string& response_json)
{
Json::Value response(Json::objectValue);
jsonMember(response, "success") = false;
const auto fail = [&](const std::string& error_code,
const std::string& error_message) {
jsonMember(response, "success") = false;
jsonMember(response, "error_code") = error_code;
jsonMember(response, "error_message") = error_message;
response_json = compactJson(response);
return false;
};
Json::Value root;
std::string parse_error;
if (!parseJsonCommand(request_json, root, parse_error)) {
return fail("invalid_json", "invalid json: " + parse_error);
}
if (!root.isObject()) {
return fail("invalid_json", "invalid json: root must be an object");
}
std::string command;
if (!requiredJsonString(root, "command", command)) {
return fail("invalid_argument",
"field 'command' is required and must be a non-empty string");
}
command = lowerString(command);
if (command != "cabinet_io") {
return fail("unsupported_command", "unsupported json command: " + command);
}
jsonMember(response, "command") = command;
std::string operation_name;
if (!requiredJsonString(root, "operation", operation_name)) {
return fail("invalid_argument",
"field 'operation' is required and must be a non-empty string");
}
operation_name = lowerString(operation_name);
CabinetIoOperation operation{};
if (!parseCabinetIoOperation(operation_name, operation)) {
return fail("invalid_operation",
"unsupported cabinet_io operation: " + operation_name);
}
jsonMember(response, "operation") = operation_name;
int index = -1;
if (!requiredJsonInt(root, "index", index) || index < 0) {
return fail("invalid_argument",
"field 'index' is required and must be a non-negative JSON integer");
}
jsonMember(response, "index") = index;
bool output_value = false;
if (operation == CabinetIoOperation::SetDigitalOutput &&
!requiredJsonBool(root, "value", output_value)) {
return fail("invalid_argument",
"field 'value' is required for set_do and must be a JSON boolean");
}
std::lock_guard lock(mutex_);
const auto ready = ensureConnected_("cabinet_io");
if (!ready.ok()) {
return fail("not_connected", ready.message);
}
try {
Result interface_result;
auto robot_interface =
getPrimaryRobotInterface(sdk_->rpc_client, "cabinet_io", interface_result);
if (!interface_result.ok() || !robot_interface) {
return fail("robot_interface_unavailable", interface_result.message);
}
auto io = robot_interface->getIoControl();
if (!io) {
return fail("io_interface_unavailable",
"[AuboArm] cabinet_io failed: IO interface is null");
}
const bool is_input = operation == CabinetIoOperation::GetDigitalInput;
const int count = is_input
? io->getStandardDigitalInputNum()
: io->getStandardDigitalOutputNum();
jsonMember(response, "count") = count;
if (index >= count) {
return fail(
"index_out_of_range",
"[AuboArm] cabinet_io index out of range: index=" +
std::to_string(index) + ", count=" + std::to_string(count));
}
if (operation == CabinetIoOperation::GetDigitalInput) {
jsonMember(response, "value") = io->getStandardDigitalInput(index);
} else {
const auto runstate = io->getStandardDigitalOutputRunstate(index);
jsonMember(response, "runstate") = standardOutputRunstateName(runstate);
jsonMember(response, "runstate_code") = static_cast<int>(runstate);
if (operation == CabinetIoOperation::GetDigitalOutput) {
jsonMember(response, "value") =
io->getStandardDigitalOutput(index);
} else {
if (runstate != arcs::common_interface::StandardOutputRunState::None) {
return fail(
"output_managed_by_runstate",
"[AuboArm] cabinet_io set_do rejected: output is managed by "
"controller runstate; configure this channel as None before writing");
}
const int ret = io->setStandardDigitalOutput(index, output_value);
jsonMember(response, "sdk_return_code") = ret;
if (ret != 0) {
return fail(
"sdk_command_failed",
"[AuboArm] cabinet_io set_do failed: sdk ret=" +
std::to_string(ret));
}
jsonMember(response, "requested_value") = output_value;
}
}
jsonMember(response, "success") = true;
response_json = compactJson(response);
return true;
} catch (const arcs::common_interface::AuboException& e) {
jsonMember(response, "sdk_return_code") = e.code();
return fail("sdk_exception",
std::string("[AuboArm] cabinet_io failed: ") + e.what());
} catch (const std::exception& e) {
return fail("sdk_exception",
std::string("[AuboArm] cabinet_io failed: ") + e.what());
}
}
ArmState AuboArm::getRobotState() const
{
ArmState state;
@ -788,6 +1024,7 @@ Result AuboArm::stopServoMode()
Result AuboArm::connect(const std::string& ip, const int port)
{
std::lock_guard lock(mutex_);
if (connected_.load()) {
return Result::success();
}
@ -855,6 +1092,7 @@ Result AuboArm::connect(const std::string& ip, const int port)
Result AuboArm::disconnect()
{
std::lock_guard lock(mutex_);
try {
if (sdk_ && sdk_->rpc_client) {
if (sdk_->rpc_client->hasLogined()) {

View File

@ -21,6 +21,8 @@ public:
std::string typeName() const override { return "AuboARM"; }
bool init() override;
bool stop() override;
bool executeJsonCommand(const std::string& request_json,
std::string& response_json) override;
RobotModel getRobotModel() const override { return model_; }
std::size_t getDof() const override { return model_.dof; }

View File

@ -0,0 +1,84 @@
#include "devices/arm/aubo_arm/aubo_arm.h"
#include <iostream>
#include <string>
namespace {
#define CHECK_TRUE(condition) \
do { \
if (!(condition)) { \
std::cerr << "CHECK_TRUE failed at line " << __LINE__ << ": " \
<< #condition << std::endl; \
return 1; \
} \
} while (false)
bool contains(const std::string& value, const std::string& expected)
{
return value.find(expected) != std::string::npos;
}
cmvr::config::RobotArmConfig makeConfig()
{
cmvr::config::RobotArmConfig config;
config.set_id("aubo_arm_json_test");
auto* vendor = config.mutable_vendor();
vendor->set_brand(cmvr::config::VENDOR_ROBOT_ARM_BRAND_AUBO_ARM);
vendor->set_model("AuboTest");
vendor->set_dof(6);
return config;
}
} // namespace
int main()
{
cmvr::device::AuboArm arm(makeConfig());
cmvr::device::AbstractDevice* device = &arm;
std::string response;
CHECK_TRUE(!device->executeJsonCommand("{", response));
CHECK_TRUE(contains(response, R"("error_code":"invalid_json")"));
CHECK_TRUE(!device->executeJsonCommand("[]", response));
CHECK_TRUE(contains(response, R"("error_code":"invalid_json")"));
CHECK_TRUE(!device->executeJsonCommand(
R"({"command":"ptz","operation":"get_di","index":0})", response));
CHECK_TRUE(contains(response, R"("error_code":"unsupported_command")"));
CHECK_TRUE(!device->executeJsonCommand(
R"({"command":"cabinet_io","operation":"get_ai","index":0})", response));
CHECK_TRUE(contains(response, R"("error_code":"invalid_operation")"));
CHECK_TRUE(!device->executeJsonCommand(
R"({"command":"cabinet_io","operation":"get_di","index":-1})", response));
CHECK_TRUE(contains(response, R"("error_code":"invalid_argument")"));
CHECK_TRUE(!device->executeJsonCommand(
R"({"command":"cabinet_io","operation":"set_do","index":0,"value":1})",
response));
CHECK_TRUE(contains(response, R"("error_code":"invalid_argument")"));
CHECK_TRUE(!device->executeJsonCommand(
R"({"command":"cabinet_io","operation":"set_do","index":0})", response));
CHECK_TRUE(contains(response, R"("error_code":"invalid_argument")"));
CHECK_TRUE(!device->executeJsonCommand(
R"({"command":"cabinet_io","operation":"get_di","index":0})", response));
CHECK_TRUE(contains(response, R"("error_code":"not_connected")"));
CHECK_TRUE(!device->executeJsonCommand(
R"({"command":"cabinet_io","operation":"get_do","index":0})", response));
CHECK_TRUE(contains(response, R"("operation":"get_do")"));
CHECK_TRUE(contains(response, R"("error_code":"not_connected")"));
CHECK_TRUE(!device->executeJsonCommand(
R"({"command":"cabinet_io","operation":"set_do","index":0,"value":true})",
response));
CHECK_TRUE(contains(response, R"("operation":"set_do")"));
CHECK_TRUE(contains(response, R"("error_code":"not_connected")"));
return 0;
}

View File

@ -17,4 +17,52 @@ add_subdirectory(drivers/ti5_canopen)
add_subdirectory(drivers/mujoco)
add_subdirectory(bus_runtime)
add_subdirectory(drivers/ethercat_motor)
add_subdirectory(drivers/modbus_plc_motor)
if(BUILD_TESTING)
enable_testing()
set(CMVR_MOTOR_LIBMODBUS_ROOT
${CMAKE_SOURCE_DIR}/dependency/${ARCH}/third_party/modbus/3.1.11)
add_executable(modbus_tcp_motor_bus_runtime_test
bus_runtime/modbus_tcp/tests/modbus_tcp_motor_bus_runtime_test.cpp
)
target_include_directories(modbus_tcp_motor_bus_runtime_test
PRIVATE
${CMAKE_SOURCE_DIR}/cmvr-es
${CMVR_MOTOR_LIBMODBUS_ROOT}/include
)
target_link_directories(modbus_tcp_motor_bus_runtime_test
PRIVATE
${CMVR_MOTOR_LIBMODBUS_ROOT}/lib
)
target_link_libraries(modbus_tcp_motor_bus_runtime_test
PRIVATE
cmvr_es::device::motor_bus_runtime
cmvr_es::device::modbus_plc_motor_driver
cmvr_es::proto
modbus
gtest
gtest_main
pthread
glog
)
target_compile_definitions(modbus_tcp_motor_bus_runtime_test
PRIVATE
CMVR_PLC_MOTOR_SAMPLE_CONFIG_PATH="${CMAKE_SOURCE_DIR}/cmvr-es/config/devices/motor/plc_motors.pb.txt"
)
add_test(NAME modbus_tcp_motor_bus_runtime_test
COMMAND modbus_tcp_motor_bus_runtime_test)
set(_modbus_motor_test_environment
"LD_LIBRARY_PATH=${CMVR_TEST_EXTERNAL_LIBRARY_PATH}"
)
if(CMVR_TEST_SYSTEM_LIBSTDCXX)
list(APPEND _modbus_motor_test_environment
"LD_PRELOAD=${CMVR_TEST_SYSTEM_LIBSTDCXX}")
endif()
set_tests_properties(modbus_tcp_motor_bus_runtime_test PROPERTIES
TIMEOUT 15
ENVIRONMENT "${_modbus_motor_test_environment}"
)
endif()
add_subdirectory(manager)

View File

@ -2,20 +2,29 @@ add_library(motor_bus_runtime SHARED
can/src/can_motor_bus_runtime.cpp
mujoco/src/mujoco_motor_bus_runtime.cpp
ethercat/src/ethercat_motor_bus_runtime.cpp
modbus_tcp/src/modbus_tcp_client.cpp
modbus_tcp/src/modbus_tcp_motor_bus_runtime.cpp
)
set(IGH_ETHERCAT_ROOT
${CMAKE_SOURCE_DIR}/dependency/x86/third_party/ethercat/v1.7.0
)
set(CMVR_LIBMODBUS_ROOT
${CMAKE_SOURCE_DIR}/dependency/${ARCH}/third_party/modbus/3.1.11
)
target_include_directories(motor_bus_runtime
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}
PRIVATE
${IGH_ETHERCAT_ROOT}/include
${CMVR_LIBMODBUS_ROOT}/include
)
target_link_directories(motor_bus_runtime PRIVATE ${IGH_ETHERCAT_ROOT}/lib)
target_link_directories(motor_bus_runtime PRIVATE
${IGH_ETHERCAT_ROOT}/lib
${CMVR_LIBMODBUS_ROOT}/lib
)
target_link_libraries(motor_bus_runtime
PUBLIC
@ -24,6 +33,7 @@ target_link_libraries(motor_bus_runtime
cmvr_es::mujoco_world
PRIVATE
ethercat
modbus
cmvr_es::device::canbus
glog
)

View File

@ -0,0 +1,209 @@
#ifndef CMVR_ES_CMVR_PLC_REGISTER_MAP_H
#define CMVR_ES_CMVR_PLC_REGISTER_MAP_H
#include <array>
#include <cstddef>
#include <cstdint>
namespace cmvr::device::cmvr_plc {
constexpr std::uint16_t kMagicCm = 0x434d;
constexpr std::uint16_t kMagicVr = 0x5652;
constexpr std::uint16_t kProtocolMajor = 1;
constexpr std::uint16_t kProtocolMinor = 0;
constexpr double kPositionScale = 1000000.0;
constexpr double kVelocityScale = 1000000.0;
constexpr double kAccelerationScale = 1000000.0;
constexpr double kTorqueScale = 1000.0;
constexpr int kGlobalRegisterCount = 32;
constexpr int kMagicCmOffset = 0;
constexpr int kMagicVrOffset = 1;
constexpr int kProtocolMajorOffset = 2;
constexpr int kProtocolMinorOffset = 3;
constexpr int kAxisCountOffset = 4;
constexpr int kPlcGlobalStateOffset = 5;
constexpr int kPlcBootIdOffset = 6;
constexpr int kCmvrSessionIdOffset = 8;
constexpr int kCmvrHeartbeatOffset = 10;
constexpr int kPlcHeartbeatOffset = 12;
constexpr int kCommunicationWatchdogOffset = 14;
constexpr int kGlobalErrorOffset = 16;
constexpr int kOwnerStateOffset = 17;
constexpr int kOwnerSessionIdOffset = 18;
constexpr int kAxisFirstOffset = 100;
constexpr int kAxisRegisterStride = 128;
constexpr int kAxisControlRegisterCount = 64;
constexpr int kAxisStatusRelativeOffset = 64;
constexpr int kAxisStatusRegisterCount = 64;
constexpr int axisBase(const std::uint32_t axis_index)
{
return kAxisFirstOffset + static_cast<int>(axis_index) * kAxisRegisterStride;
}
enum class CommandCode : std::uint16_t {
Nop = 0,
SetZero = 1,
MoveToZero = 2,
ProfilePosition = 3,
ProfileVelocity = 4,
OpenCyclicPosition = 5,
CyclicPositionSample = 6,
OpenCyclicVelocity = 7,
CyclicVelocitySample = 8,
CloseCyclicStream = 9,
QuickStop = 10,
Enable = 11,
Disable = 12,
};
enum class CommandState : std::uint16_t {
Idle = 0,
Received = 1,
Validating = 2,
Accepted = 3,
Running = 4,
TargetReached = 5,
Completed = 6,
Rejected = 7,
Failed = 8,
TimedOut = 9,
QuickStopped = 10,
CommunicationLost = 11,
};
enum class ResultCode : std::uint16_t {
Ok = 0,
InvalidCommand = 1,
InvalidParameter = 2,
AxisNotReady = 3,
AxisBusy = 4,
NotEnabled = 5,
PositionLimit = 6,
VelocityLimit = 7,
AccelerationLimit = 8,
ZeroNotValid = 9,
DriveFault = 10,
CommandTimeout = 11,
SequenceError = 12,
SessionMismatch = 13,
CommunicationWatchdog = 14,
CyclicWatchdog = 15,
Unsupported = 16,
InternalError = 17,
};
enum StatusFlag : std::uint16_t {
Enabled = 1U << 0U,
Moving = 1U << 1U,
TargetReached = 1U << 2U,
Fault = 1U << 3U,
QuickStopActive = 1U << 4U,
CommunicationWatchdogExpired = 1U << 5U,
CyclicWatchdogExpired = 1U << 6U,
ZeroValid = 1U << 7U,
StreamActive = 1U << 8U,
CommandBusy = 1U << 9U,
};
constexpr std::size_t kCommandPayloadRegisterCount = 62;
constexpr int kCommitSequenceRelativeOffset = 62;
constexpr std::size_t kPayloadSequence = 0;
constexpr std::size_t kCommandCode = 2;
constexpr std::size_t kCommandFlags = 3;
constexpr std::size_t kTargetPosition = 4;
constexpr std::size_t kTargetVelocity = 6;
constexpr std::size_t kAcceleration = 8;
constexpr std::size_t kTargetTorque = 10;
constexpr std::size_t kPositionTolerance = 12;
constexpr std::size_t kVelocityTolerance = 14;
constexpr std::size_t kCommandTimeout = 16;
constexpr std::size_t kStreamWatchdog = 18;
constexpr std::size_t kCyclicSampleSequence = 20;
constexpr std::size_t kClientMonotonicTime = 22;
constexpr std::size_t kExpectedZeroEpoch = 24;
constexpr std::size_t kDisconnectAction = 26;
constexpr std::size_t kCommandSessionId = 27;
constexpr std::size_t kPayloadSequenceMirror = 60;
constexpr std::size_t kAckSequence = 0;
constexpr std::size_t kActiveSequence = 2;
constexpr std::size_t kCommandState = 4;
constexpr std::size_t kResultCode = 5;
constexpr std::size_t kAxisState = 6;
constexpr std::size_t kCurrentMode = 7;
constexpr std::size_t kActualPosition = 8;
constexpr std::size_t kActualVelocity = 10;
constexpr std::size_t kActualTorque = 12;
constexpr std::size_t kTargetPositionStatus = 14;
constexpr std::size_t kTargetVelocityStatus = 16;
constexpr std::size_t kStatusFlags = 18;
constexpr std::size_t kDriveStatusword = 19;
constexpr std::size_t kFaultCode = 20;
constexpr std::size_t kZeroEpoch = 22;
constexpr std::size_t kLastAppliedCyclicSequence = 24;
constexpr std::size_t kStateSequence = 26;
constexpr std::size_t kPlcMonotonicTime = 28;
constexpr std::size_t kHeartbeatAge = 30;
constexpr std::size_t kAckSessionId = 32;
constexpr std::size_t kStateSequenceMirror = 62;
enum class OwnerState : std::uint16_t {
None = 0,
Accepting = 1,
Accepted = 2,
Rejected = 3,
};
inline void encodeUint32(std::uint16_t* registers,
const std::size_t offset,
const std::uint32_t value)
{
registers[offset] = static_cast<std::uint16_t>(value >> 16U);
registers[offset + 1] = static_cast<std::uint16_t>(value & 0xffffU);
}
inline void encodeInt32(std::uint16_t* registers,
const std::size_t offset,
const std::int32_t value)
{
encodeUint32(registers, offset, static_cast<std::uint32_t>(value));
}
inline std::uint32_t decodeUint32(const std::uint16_t* registers,
const std::size_t offset)
{
return (static_cast<std::uint32_t>(registers[offset]) << 16U) |
static_cast<std::uint32_t>(registers[offset + 1]);
}
inline std::int32_t decodeInt32(const std::uint16_t* registers,
const std::size_t offset)
{
return static_cast<std::int32_t>(decodeUint32(registers, offset));
}
inline bool isTerminal(const CommandState state)
{
return state == CommandState::Completed ||
state == CommandState::Rejected ||
state == CommandState::Failed ||
state == CommandState::TimedOut ||
state == CommandState::QuickStopped ||
state == CommandState::CommunicationLost;
}
inline bool isFailure(const CommandState state)
{
return state == CommandState::Rejected ||
state == CommandState::Failed ||
state == CommandState::TimedOut ||
state == CommandState::CommunicationLost;
}
} // namespace cmvr::device::cmvr_plc
#endif // CMVR_ES_CMVR_PLC_REGISTER_MAP_H

View File

@ -0,0 +1,47 @@
#ifndef CMVR_ES_MODBUS_TCP_CLIENT_H
#define CMVR_ES_MODBUS_TCP_CLIENT_H
#include <cstdint>
#include <atomic>
#include <string>
#include <vector>
struct _modbus;
using modbus_t = struct _modbus;
namespace cmvr::device {
class ModbusTcpClient {
public:
ModbusTcpClient() = default;
~ModbusTcpClient();
ModbusTcpClient(const ModbusTcpClient&) = delete;
ModbusTcpClient& operator=(const ModbusTcpClient&) = delete;
bool open(const std::string& host,
std::uint16_t port,
std::uint8_t unit_id,
std::uint32_t connect_timeout_ms,
std::uint32_t response_timeout_ms);
void close();
void shutdown();
bool connected() const { return context_ != nullptr; }
bool readHoldingRegisters(int address, int count, std::vector<std::uint16_t>& values);
bool writeHoldingRegisters(int address, const std::uint16_t* values, int count);
bool writeHoldingRegisters(int address, const std::vector<std::uint16_t>& values);
const std::string& lastError() const { return last_error_; }
private:
void setLastErrnoError_(const char* operation);
modbus_t* context_{nullptr};
std::atomic<int> socket_fd_{-1};
std::string last_error_;
};
} // namespace cmvr::device
#endif // CMVR_ES_MODBUS_TCP_CLIENT_H

View File

@ -0,0 +1,173 @@
#ifndef CMVR_ES_MODBUS_TCP_MOTOR_BUS_RUNTIME_H
#define CMVR_ES_MODBUS_TCP_MOTOR_BUS_RUNTIME_H
#include <array>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include "devices/motor/bus_runtime/abstract_motor_bus_runtime.h"
#include "devices/motor/bus_runtime/modbus_tcp/include/cmvr_plc_register_map.h"
#include "devices/motor/bus_runtime/modbus_tcp/include/modbus_tcp_client.h"
namespace cmvr::device {
struct ModbusTcpMotorBusRuntimeTestAccess;
struct CmvrPlcAxisCommand {
cmvr_plc::CommandCode code{cmvr_plc::CommandCode::Nop};
std::uint16_t flags{0};
std::int32_t target_position{0};
std::int32_t target_velocity{0};
std::int32_t acceleration{0};
std::int32_t target_torque{0};
std::int32_t position_tolerance{0};
std::int32_t velocity_tolerance{0};
std::uint32_t command_timeout_ms{0};
std::uint32_t stream_watchdog_ms{0};
std::uint32_t cyclic_sample_sequence{0};
std::uint32_t client_monotonic_time_ms{0};
std::uint32_t expected_zero_epoch{0};
std::uint16_t disconnect_action{0};
};
struct CmvrPlcAxisStatus {
std::uint32_t ack_sequence{0};
std::uint32_t active_sequence{0};
cmvr_plc::CommandState command_state{cmvr_plc::CommandState::Idle};
cmvr_plc::ResultCode result_code{cmvr_plc::ResultCode::Ok};
std::uint16_t axis_state{0};
std::uint16_t current_mode{0};
std::int32_t actual_position{0};
std::int32_t actual_velocity{0};
std::int32_t actual_torque{0};
std::int32_t target_position{0};
std::int32_t target_velocity{0};
std::uint16_t status_flags{0};
std::uint16_t drive_statusword{0};
std::uint32_t fault_code{0};
std::uint32_t zero_epoch{0};
std::uint32_t last_applied_cyclic_sequence{0};
std::uint32_t state_sequence{0};
std::uint32_t plc_monotonic_time_ms{0};
std::uint32_t heartbeat_age_ms{0};
std::uint32_t ack_session_id{0};
};
class ModbusTcpMotorBusRuntime final : public AbstractMotorBusRuntime {
public:
ModbusTcpMotorBusRuntime();
~ModbusTcpMotorBusRuntime() override;
bool init(const config::MotorGroupConfig& group_cfg) override;
bool start() override;
void stop() override;
config::MotorBusType busType() const override { return config::MOTOR_BUS_MODBUS_TCP; }
bool hasMotor(std::uint8_t motor_id) const;
bool axisForMotor(std::uint8_t motor_id, std::uint32_t& axis_index) const;
bool connected() const { return connected_.load(); }
std::uint32_t sessionId() const { return session_id_.load(); }
std::uint32_t plcBootId() const { return plc_boot_id_.load(); }
std::uint64_t connectionEpoch() const { return connection_epoch_.load(); }
std::uint32_t streamWatchdogMs() const { return stream_watchdog_ms_; }
std::uint32_t commandAckTimeoutMs() const { return command_ack_timeout_ms_; }
const std::string& id() const { return id_; }
std::string lastError() const;
bool readAxisStatus(std::uint8_t motor_id, CmvrPlcAxisStatus& status);
bool submitAxisCommand(std::uint8_t motor_id,
const CmvrPlcAxisCommand& command,
bool wait_for_terminal_state = false,
std::optional<std::uint64_t>
expected_connection_epoch = std::nullopt);
bool submitAxisSafetyCommand(std::uint8_t motor_id,
const CmvrPlcAxisCommand& command);
private:
friend struct ModbusTcpMotorBusRuntimeTestAccess;
bool connectAndHandshakeLocked_();
void markDisconnectedLocked_(const std::string& error);
bool readRegistersLocked_(int address, int count, std::vector<std::uint16_t>& values);
bool writeRegistersLocked_(int address, const std::uint16_t* values, int count);
bool writeHeartbeatLocked_();
bool readAxisStatusByIndex_(std::uint32_t axis_index, CmvrPlcAxisStatus& status);
bool waitForCommand_(std::uint32_t axis_index,
std::uint32_t command_sequence,
std::uint32_t command_session_id,
std::uint64_t connection_epoch,
std::uint64_t cancel_generation,
bool cancel_on_safety_preemption,
const CmvrPlcAxisCommand& command,
bool wait_for_terminal_state,
const CmvrPlcAxisStatus& initial_status);
bool submitAxisCommandImpl_(std::uint8_t motor_id,
const CmvrPlcAxisCommand& command,
bool wait_for_terminal_state,
bool safety_priority,
std::optional<std::uint64_t>
expected_connection_epoch);
bool writeClientHeartbeatLocked_();
std::shared_ptr<std::mutex> axisMutex_(std::uint8_t motor_id) const;
std::shared_ptr<std::mutex> safetyMutex_(std::uint8_t motor_id) const;
void workerLoop_();
static std::uint32_t randomNonZeroSessionId_();
static std::uint32_t monotonicMilliseconds_();
std::string id_;
config::ModbusTcpConfig config_;
std::unordered_map<std::uint8_t, std::uint32_t> motor_axes_;
mutable std::unordered_map<std::uint8_t, std::shared_ptr<std::mutex>> axis_mutexes_;
mutable std::unordered_map<std::uint8_t, std::shared_ptr<std::mutex>> safety_mutexes_;
std::unordered_map<std::uint8_t, std::uint32_t> command_sequences_;
std::unordered_map<std::uint8_t, std::uint64_t> cancel_generations_;
// Monotonic admission counter protected by state_mutex_. Besides being
// useful when diagnosing queueing, it gives concurrency tests an exact
// synchronization point after an invocation has captured its safety
// generation and connection epoch, but before it waits on a command
// serialization mutex.
std::uint64_t command_admission_count_{0};
mutable std::mutex io_mutex_;
mutable std::mutex state_mutex_;
mutable std::mutex lifecycle_mutex_;
ModbusTcpClient client_;
std::string last_error_;
std::thread worker_;
std::mutex worker_wait_mutex_;
std::condition_variable worker_wait_cv_;
std::atomic<bool> running_{false};
std::atomic<bool> connected_{false};
std::atomic<std::uint32_t> plc_boot_id_{0};
std::atomic<std::uint64_t> connection_epoch_{0};
std::atomic<std::uint32_t> session_id_{0};
std::uint32_t last_session_id_{0};
std::uint32_t heartbeat_counter_{0};
std::chrono::steady_clock::time_point last_client_heartbeat_write_at_{};
std::uint32_t plc_heartbeat_counter_{0};
std::chrono::steady_clock::time_point plc_heartbeat_changed_at_{};
std::uint32_t connect_timeout_ms_{500};
std::uint32_t io_timeout_ms_{100};
std::uint32_t heartbeat_period_ms_{100};
std::uint32_t communication_watchdog_ms_{500};
std::uint32_t status_poll_period_ms_{20};
std::uint32_t reconnect_min_ms_{100};
std::uint32_t reconnect_max_ms_{2000};
std::uint32_t command_ack_timeout_ms_{500};
std::uint32_t stream_watchdog_ms_{500};
std::uint16_t protocol_major_{cmvr_plc::kProtocolMajor};
std::uint16_t protocol_minor_{cmvr_plc::kProtocolMinor};
};
} // namespace cmvr::device
#endif // CMVR_ES_MODBUS_TCP_MOTOR_BUS_RUNTIME_H

View File

@ -0,0 +1,188 @@
#include "devices/motor/bus_runtime/modbus_tcp/include/modbus_tcp_client.h"
#include <cerrno>
#include <limits>
#include <fcntl.h>
#include <netdb.h>
#include <poll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <modbus/modbus.h>
namespace cmvr::device {
ModbusTcpClient::~ModbusTcpClient()
{
close();
}
bool ModbusTcpClient::open(const std::string& host,
const std::uint16_t port,
const std::uint8_t unit_id,
const std::uint32_t connect_timeout_ms,
const std::uint32_t response_timeout_ms)
{
close();
context_ = modbus_new_tcp(host.c_str(), static_cast<int>(port));
if (!context_) {
last_error_ = "modbus_new_tcp failed";
return false;
}
if (modbus_set_slave(context_, static_cast<int>(unit_id)) == -1) {
setLastErrnoError_("modbus_set_slave");
close();
return false;
}
const auto timeout = response_timeout_ms == 0 ? 200U : response_timeout_ms;
if (modbus_set_response_timeout(context_, timeout / 1000U,
(timeout % 1000U) * 1000U) == -1 ||
modbus_set_byte_timeout(context_, timeout / 1000U,
(timeout % 1000U) * 1000U) == -1) {
setLastErrnoError_("modbus_set_timeout");
close();
return false;
}
addrinfo hints{};
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_NUMERICHOST;
addrinfo* addresses = nullptr;
const auto service = std::to_string(port);
const auto resolve_result =
getaddrinfo(host.c_str(), service.c_str(), &hints, &addresses);
if (resolve_result != 0) {
last_error_ = std::string("host must be a numeric IPv4 address: ") +
gai_strerror(resolve_result);
close();
return false;
}
int connected_fd = -1;
for (auto* address = addresses; address; address = address->ai_next) {
const int fd = ::socket(address->ai_family, address->ai_socktype, address->ai_protocol);
if (fd < 0) {
continue;
}
const int old_flags = fcntl(fd, F_GETFL, 0);
if (old_flags < 0 || fcntl(fd, F_SETFL, old_flags | O_NONBLOCK) < 0) {
::close(fd);
continue;
}
socket_fd_.store(fd);
const int rc = ::connect(fd, address->ai_addr, address->ai_addrlen);
if (rc == 0 || errno == EINPROGRESS) {
pollfd descriptor{fd, POLLOUT, 0};
const auto timeout = static_cast<int>(
connect_timeout_ms == 0 ? 1000U : connect_timeout_ms);
if (rc == 0 || poll(&descriptor, 1, timeout) > 0) {
int socket_error = 0;
socklen_t error_size = sizeof(socket_error);
if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &error_size) == 0 &&
socket_error == 0) {
if (fcntl(fd, F_SETFL, old_flags) == 0) {
connected_fd = fd;
break;
}
}
}
}
socket_fd_.store(-1);
::close(fd);
}
freeaddrinfo(addresses);
if (connected_fd < 0 || modbus_set_socket(context_, connected_fd) == -1) {
if (connected_fd >= 0) {
::close(connected_fd);
}
last_error_ = "Modbus TCP connect timed out or failed";
close();
return false;
}
socket_fd_.store(connected_fd);
last_error_.clear();
return true;
}
void ModbusTcpClient::close()
{
socket_fd_.store(-1);
if (context_) {
modbus_close(context_);
modbus_free(context_);
context_ = nullptr;
}
}
void ModbusTcpClient::shutdown()
{
const auto fd = socket_fd_.load();
if (fd >= 0) {
::shutdown(fd, SHUT_RDWR);
}
}
bool ModbusTcpClient::readHoldingRegisters(
const int address,
const int count,
std::vector<std::uint16_t>& values)
{
if (!context_) {
last_error_ = "Modbus TCP connection is closed";
return false;
}
if (address < 0 || count <= 0 || count > MODBUS_MAX_READ_REGISTERS) {
last_error_ = "invalid holding-register read range";
return false;
}
values.assign(static_cast<std::size_t>(count), 0);
const auto rc = modbus_read_registers(context_, address, count, values.data());
if (rc != count) {
setLastErrnoError_("modbus_read_registers");
return false;
}
last_error_.clear();
return true;
}
bool ModbusTcpClient::writeHoldingRegisters(
const int address,
const std::uint16_t* values,
const int count)
{
if (!context_) {
last_error_ = "Modbus TCP connection is closed";
return false;
}
if (address < 0 || !values || count <= 0 || count > MODBUS_MAX_WRITE_REGISTERS) {
last_error_ = "invalid holding-register write range";
return false;
}
const auto rc = modbus_write_registers(context_, address, count, values);
if (rc != count) {
setLastErrnoError_("modbus_write_registers");
return false;
}
last_error_.clear();
return true;
}
bool ModbusTcpClient::writeHoldingRegisters(
const int address,
const std::vector<std::uint16_t>& values)
{
if (values.size() > static_cast<std::size_t>(std::numeric_limits<int>::max())) {
last_error_ = "holding-register write is too large";
return false;
}
return writeHoldingRegisters(address, values.data(), static_cast<int>(values.size()));
}
void ModbusTcpClient::setLastErrnoError_(const char* operation)
{
last_error_ = std::string(operation) + ": " + modbus_strerror(errno);
}
} // namespace cmvr::device

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
add_library(modbus_plc_motor_driver SHARED
src/cmvr_plc_motor_protocol.cpp
src/modbus_plc_motor.cpp
)
target_include_directories(modbus_plc_motor_driver
PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/include
)
target_link_libraries(modbus_plc_motor_driver
PUBLIC
cmvr_es::device::motor_core
cmvr_es::device::motor_bus_runtime
PRIVATE
cmvr_es::proto
glog
)
add_library(cmvr_es::device::modbus_plc_motor_driver ALIAS modbus_plc_motor_driver)
install(TARGETS modbus_plc_motor_driver LIBRARY DESTINATION lib)

View File

@ -0,0 +1,89 @@
#ifndef CMVR_ES_CMVR_PLC_MOTOR_PROTOCOL_H
#define CMVR_ES_CMVR_PLC_MOTOR_PROTOCOL_H
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <unordered_map>
#include "devices/motor/bus_runtime/modbus_tcp/include/modbus_tcp_motor_bus_runtime.h"
#include "devices/motor/motor_protocol_interface.h"
namespace cmvr::device {
class CmvrPlcMotorProtocol final : public MotorProtocolInterface {
public:
explicit CmvrPlcMotorProtocol(std::shared_ptr<ModbusTcpMotorBusRuntime> bus_runtime);
~CmvrPlcMotorProtocol() override = default;
bool initNode(std::uint8_t node_id) override;
void setMode(std::uint8_t node_id, msgs::RunMode mode) override;
msgs::RunMode getMode(std::uint8_t node_id) override;
void setLimitQdd(std::uint8_t node_id, double u_qdd, double l_qdd) override;
void setLimitQd(std::uint8_t node_id, double qd) override;
void setLimitQ(std::uint8_t node_id, double ub, double lb) override;
bool calibrateZeroQ(std::uint8_t node_id) override;
bool reachedTargetQ(std::uint8_t node_id) override;
bool commandProfilePosition(std::uint8_t node_id,
double target_q,
double max_qd,
double max_qdd) override;
bool commandProfileVelocity(std::uint8_t node_id,
double target_qd,
double max_qdd) override;
bool commandCyclicPosition(std::uint8_t node_id,
double target_q,
double target_qd) override;
bool commandCyclicVelocity(std::uint8_t node_id, double target_qd) override;
bool commandCyclicTorque(std::uint8_t node_id, double target_tau) override;
void setMotorConversion(std::uint8_t node_id,
double encoder_counts_per_rev,
double gear_ratio) override;
bool torqueOn(std::uint8_t node_id) override;
bool torqueOff(std::uint8_t node_id) override;
bool brakeRelease(std::uint8_t node_id) override;
bool quickStop(std::uint8_t node_id) override;
double getQ(std::uint8_t node_id) override;
double getQd(std::uint8_t node_id) override;
private:
struct NodeState {
msgs::RunMode requested_mode{msgs::RUN_MODE_UNSPECIFIED};
double limit_q_lb{0.0};
double limit_q_ub{0.0};
double limit_qd{0.0};
double limit_qdd{0.0};
bool cyclic_stream_open{false};
bool cyclic_reconnect_latched{false};
std::uint32_t cyclic_sequence{0};
std::uint64_t cyclic_generation{0};
std::uint64_t cyclic_connection_epoch{0};
bool profile_feedback_bound{false};
std::uint64_t profile_connection_epoch{0};
};
bool submitSimple_(std::uint8_t node_id,
cmvr_plc::CommandCode code,
bool wait_for_terminal,
std::uint32_t timeout_ms);
bool ensureCyclicOpen_(std::uint8_t node_id,
NodeState& state,
msgs::RunMode mode);
static std::optional<std::int32_t> toMicroUnits_(double value);
static double fromMicroUnits_(std::int32_t value);
static bool isSupportedMode_(msgs::RunMode mode);
bool validatePosition_(const NodeState& state, double position) const;
bool validateVelocity_(const NodeState& state, double velocity) const;
bool validateAcceleration_(const NodeState& state, double acceleration) const;
bool readMotionStatus_(std::uint8_t node_id, CmvrPlcAxisStatus& status);
NodeState& nodeStateLocked_(std::uint8_t node_id);
std::shared_ptr<ModbusTcpMotorBusRuntime> bus_runtime_;
std::mutex nodes_mutex_;
std::unordered_map<std::uint8_t, NodeState> nodes_;
};
} // namespace cmvr::device
#endif // CMVR_ES_CMVR_PLC_MOTOR_PROTOCOL_H

View File

@ -0,0 +1,21 @@
#ifndef CMVR_ES_MODBUS_PLC_MOTOR_H
#define CMVR_ES_MODBUS_PLC_MOTOR_H
#include "cmvr/config/motor_config/motor_config.pb.h"
#include "devices/motor/abstract_motor.h"
namespace cmvr::device {
class ModbusPlcMotor final : public AbstractMotor {
public:
explicit ModbusPlcMotor(const config::MotorConfigItem& config);
std::string typeName() const override { return "ModbusPlcMotor"; }
bool init() override;
bool torqueOff() override;
bool quickStop() override;
};
} // namespace cmvr::device
#endif // CMVR_ES_MODBUS_PLC_MOTOR_H

View File

@ -0,0 +1,582 @@
#include "devices/motor/drivers/modbus_plc_motor/include/cmvr_plc_motor_protocol.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include "common/base/logging/logger.h"
namespace cmvr::device {
namespace {
// MotorService accepts waits up to 10 minutes. The PLC timeout is a ceiling;
// shorter RPC timeouts still actively QuickStop from the service layer.
constexpr std::uint32_t kProfileCommandTimeoutCeilingMs = 600000;
constexpr std::uint32_t kSafetyCommandTimeoutMs = 5000;
bool statusAllowsMotionFeedback(const CmvrPlcAxisStatus& status)
{
constexpr std::uint16_t kFatalFlags =
cmvr_plc::StatusFlag::Fault |
cmvr_plc::StatusFlag::CommunicationWatchdogExpired |
cmvr_plc::StatusFlag::CyclicWatchdogExpired;
return (status.status_flags & kFatalFlags) == 0U &&
status.result_code == cmvr_plc::ResultCode::Ok &&
static_cast<std::uint16_t>(status.command_state) <=
static_cast<std::uint16_t>(
cmvr_plc::CommandState::CommunicationLost) &&
!cmvr_plc::isFailure(status.command_state);
}
} // namespace
CmvrPlcMotorProtocol::CmvrPlcMotorProtocol(
std::shared_ptr<ModbusTcpMotorBusRuntime> bus_runtime)
: bus_runtime_(std::move(bus_runtime))
{
comm_proto = CommProto::CUSTOM;
}
bool CmvrPlcMotorProtocol::initNode(const std::uint8_t node_id)
{
if (!bus_runtime_ || !bus_runtime_->hasMotor(node_id)) {
CMVR_LOG(ERROR) << "[CmvrPlcMotorProtocol] missing PLC axis mapping for motor "
<< static_cast<int>(node_id);
return false;
}
std::lock_guard<std::mutex> lock(nodes_mutex_);
auto& state = nodeStateLocked_(node_id);
state.cyclic_connection_epoch = bus_runtime_->connectionEpoch();
state.cyclic_stream_open = false;
state.cyclic_reconnect_latched = false;
state.cyclic_sequence = 0;
state.cyclic_generation = 0;
state.profile_feedback_bound = false;
state.profile_connection_epoch = 0;
return true;
}
void CmvrPlcMotorProtocol::setMode(
const std::uint8_t node_id,
const msgs::RunMode mode)
{
if (!isSupportedMode_(mode)) {
CMVR_LOG(ERROR) << "[CmvrPlcMotorProtocol] unsupported mode "
<< msgs::RunMode_Name(mode);
return;
}
std::lock_guard<std::mutex> lock(nodes_mutex_);
auto& state = nodeStateLocked_(node_id);
const bool cyclic_mode =
mode == msgs::RUN_MODE_CYCLIC_SYNC_POSITION ||
mode == msgs::RUN_MODE_CYCLIC_SYNC_VELOCITY;
if (cyclic_mode) {
// Every explicit cyclic setMode call is a new upper-layer stream
// generation, even when the mode value itself is unchanged. This is
// the only transition allowed to clear a reconnect latch.
state.requested_mode = mode;
state.cyclic_connection_epoch =
bus_runtime_ ? bus_runtime_->connectionEpoch() : 0U;
state.cyclic_stream_open = false;
state.cyclic_reconnect_latched = false;
state.cyclic_sequence = 0;
if (++state.cyclic_generation == 0U) {
++state.cyclic_generation;
}
return;
}
if (state.requested_mode != mode) {
state.cyclic_stream_open = false;
}
state.requested_mode = mode;
}
msgs::RunMode CmvrPlcMotorProtocol::getMode(const std::uint8_t node_id)
{
CmvrPlcAxisStatus status;
if (bus_runtime_ && bus_runtime_->readAxisStatus(node_id, status)) {
const auto mode = static_cast<msgs::RunMode>(status.current_mode);
if (isSupportedMode_(mode)) {
return mode;
}
}
std::lock_guard<std::mutex> lock(nodes_mutex_);
return nodeStateLocked_(node_id).requested_mode;
}
void CmvrPlcMotorProtocol::setLimitQdd(
const std::uint8_t node_id,
const double u_qdd,
const double l_qdd)
{
std::lock_guard<std::mutex> lock(nodes_mutex_);
nodeStateLocked_(node_id).limit_qdd = std::max(std::abs(u_qdd), std::abs(l_qdd));
}
void CmvrPlcMotorProtocol::setLimitQd(
const std::uint8_t node_id,
const double qd)
{
std::lock_guard<std::mutex> lock(nodes_mutex_);
nodeStateLocked_(node_id).limit_qd = std::abs(qd);
}
void CmvrPlcMotorProtocol::setLimitQ(
const std::uint8_t node_id,
const double ub,
const double lb)
{
std::lock_guard<std::mutex> lock(nodes_mutex_);
auto& state = nodeStateLocked_(node_id);
state.limit_q_ub = ub;
state.limit_q_lb = lb;
}
bool CmvrPlcMotorProtocol::calibrateZeroQ(const std::uint8_t node_id)
{
return submitSimple_(node_id, cmvr_plc::CommandCode::SetZero, true,
kSafetyCommandTimeoutMs);
}
bool CmvrPlcMotorProtocol::reachedTargetQ(const std::uint8_t node_id)
{
CmvrPlcAxisStatus status;
return readMotionStatus_(node_id, status) &&
statusAllowsMotionFeedback(status) &&
(status.status_flags & cmvr_plc::StatusFlag::TargetReached) != 0U;
}
bool CmvrPlcMotorProtocol::commandProfilePosition(
const std::uint8_t node_id,
const double target_q,
const double max_qd,
const double max_qdd)
{
NodeState state;
{
std::lock_guard<std::mutex> lock(nodes_mutex_);
auto& stored = nodeStateLocked_(node_id);
stored.requested_mode = msgs::RUN_MODE_PROFILE_POSITION;
stored.cyclic_stream_open = false;
state = stored;
}
if (!validatePosition_(state, target_q) ||
!validateVelocity_(state, max_qd) ||
!validateAcceleration_(state, max_qdd)) {
CMVR_LOG(ERROR) << "[CmvrPlcMotorProtocol] invalid profile-position command";
return false;
}
const auto position = toMicroUnits_(target_q);
const auto velocity = toMicroUnits_(std::abs(max_qd));
const auto acceleration = toMicroUnits_(std::abs(max_qdd));
if (!position || !velocity || !acceleration) {
return false;
}
CmvrPlcAxisCommand command;
command.code = cmvr_plc::CommandCode::ProfilePosition;
command.target_position = *position;
command.target_velocity = *velocity;
command.acceleration = *acceleration;
command.command_timeout_ms = kProfileCommandTimeoutCeilingMs;
if (!bus_runtime_) {
return false;
}
const auto submission_epoch = bus_runtime_->connectionEpoch();
const auto submitted =
bus_runtime_->submitAxisCommand(
node_id, command, false, submission_epoch);
const auto completed_epoch = bus_runtime_->connectionEpoch();
if (!submitted && completed_epoch == submission_epoch) {
return false;
}
{
std::lock_guard<std::mutex> lock(nodes_mutex_);
auto& stored = nodeStateLocked_(node_id);
// Bind on every cross-epoch outcome, including an ambiguous failed
// submit, so feedback remains invalid until safety cleanup.
stored.profile_feedback_bound = true;
stored.profile_connection_epoch = submission_epoch;
}
return submitted && completed_epoch == submission_epoch;
}
bool CmvrPlcMotorProtocol::commandProfileVelocity(
const std::uint8_t node_id,
const double target_qd,
const double max_qdd)
{
NodeState state;
{
std::lock_guard<std::mutex> lock(nodes_mutex_);
auto& stored = nodeStateLocked_(node_id);
stored.requested_mode = msgs::RUN_MODE_PROFILE_VELOCITY;
stored.cyclic_stream_open = false;
state = stored;
}
if (!validateVelocity_(state, target_qd) ||
!validateAcceleration_(state, max_qdd)) {
CMVR_LOG(ERROR) << "[CmvrPlcMotorProtocol] invalid profile-velocity command";
return false;
}
const auto velocity = toMicroUnits_(target_qd);
const auto acceleration = toMicroUnits_(std::abs(max_qdd));
if (!velocity || !acceleration) {
return false;
}
CmvrPlcAxisCommand command;
command.code = cmvr_plc::CommandCode::ProfileVelocity;
command.target_velocity = *velocity;
command.acceleration = *acceleration;
command.command_timeout_ms = kProfileCommandTimeoutCeilingMs;
if (!bus_runtime_) {
return false;
}
const auto submission_epoch = bus_runtime_->connectionEpoch();
const auto submitted =
bus_runtime_->submitAxisCommand(
node_id, command, false, submission_epoch);
const auto completed_epoch = bus_runtime_->connectionEpoch();
if (!submitted && completed_epoch == submission_epoch) {
return false;
}
{
std::lock_guard<std::mutex> lock(nodes_mutex_);
auto& stored = nodeStateLocked_(node_id);
stored.profile_feedback_bound = true;
stored.profile_connection_epoch = submission_epoch;
}
return submitted && completed_epoch == submission_epoch;
}
bool CmvrPlcMotorProtocol::commandCyclicPosition(
const std::uint8_t node_id,
const double target_q,
const double target_qd)
{
std::lock_guard<std::mutex> lock(nodes_mutex_);
auto& state = nodeStateLocked_(node_id);
if (!validatePosition_(state, target_q) ||
!validateVelocity_(state, target_qd) ||
!ensureCyclicOpen_(node_id, state, msgs::RUN_MODE_CYCLIC_SYNC_POSITION)) {
return false;
}
const auto position = toMicroUnits_(target_q);
const auto velocity = toMicroUnits_(target_qd);
if (!position || !velocity) {
return false;
}
CmvrPlcAxisCommand command;
command.code = cmvr_plc::CommandCode::CyclicPositionSample;
command.target_position = *position;
command.target_velocity = *velocity;
command.stream_watchdog_ms = bus_runtime_->streamWatchdogMs();
if (++state.cyclic_sequence == 0) {
++state.cyclic_sequence;
}
command.cyclic_sample_sequence = state.cyclic_sequence;
const auto submitted =
bus_runtime_->submitAxisCommand(
node_id, command, false, state.cyclic_connection_epoch);
if (submitted) {
state.profile_feedback_bound = false;
state.profile_connection_epoch = 0;
} else if (
bus_runtime_->connectionEpoch() != state.cyclic_connection_epoch) {
state.cyclic_reconnect_latched = true;
state.cyclic_stream_open = false;
state.cyclic_sequence = 0;
state.cyclic_connection_epoch = bus_runtime_->connectionEpoch();
}
return submitted;
}
bool CmvrPlcMotorProtocol::commandCyclicVelocity(
const std::uint8_t node_id,
const double target_qd)
{
std::lock_guard<std::mutex> lock(nodes_mutex_);
auto& state = nodeStateLocked_(node_id);
if (!validateVelocity_(state, target_qd) ||
!ensureCyclicOpen_(node_id, state, msgs::RUN_MODE_CYCLIC_SYNC_VELOCITY)) {
return false;
}
const auto velocity = toMicroUnits_(target_qd);
if (!velocity) {
return false;
}
CmvrPlcAxisCommand command;
command.code = cmvr_plc::CommandCode::CyclicVelocitySample;
command.target_velocity = *velocity;
command.stream_watchdog_ms = bus_runtime_->streamWatchdogMs();
if (++state.cyclic_sequence == 0) {
++state.cyclic_sequence;
}
command.cyclic_sample_sequence = state.cyclic_sequence;
const auto submitted =
bus_runtime_->submitAxisCommand(
node_id, command, false, state.cyclic_connection_epoch);
if (submitted) {
state.profile_feedback_bound = false;
state.profile_connection_epoch = 0;
} else if (
bus_runtime_->connectionEpoch() != state.cyclic_connection_epoch) {
state.cyclic_reconnect_latched = true;
state.cyclic_stream_open = false;
state.cyclic_sequence = 0;
state.cyclic_connection_epoch = bus_runtime_->connectionEpoch();
}
return submitted;
}
bool CmvrPlcMotorProtocol::commandCyclicTorque(
const std::uint8_t node_id,
const double target_tau)
{
(void)target_tau;
CMVR_LOG(ERROR) << "[CmvrPlcMotorProtocol] cyclic torque is unsupported, motor="
<< static_cast<int>(node_id);
return false;
}
void CmvrPlcMotorProtocol::setMotorConversion(
const std::uint8_t node_id,
const double encoder_counts_per_rev,
const double gear_ratio)
{
(void)node_id;
(void)encoder_counts_per_rev;
(void)gear_ratio;
// CMVR PLC v1 exchanges SI quantities in fixed-point micro-units.
}
bool CmvrPlcMotorProtocol::torqueOn(const std::uint8_t node_id)
{
return submitSimple_(node_id, cmvr_plc::CommandCode::Enable, true,
kSafetyCommandTimeoutMs);
}
bool CmvrPlcMotorProtocol::torqueOff(const std::uint8_t node_id)
{
CmvrPlcAxisCommand command;
command.code = cmvr_plc::CommandCode::Disable;
command.command_timeout_ms = kSafetyCommandTimeoutMs;
const auto result =
bus_runtime_ && bus_runtime_->submitAxisSafetyCommand(node_id, command);
std::lock_guard<std::mutex> lock(nodes_mutex_);
auto& state = nodeStateLocked_(node_id);
state.cyclic_stream_open = false;
if (result) {
state.profile_feedback_bound = false;
state.profile_connection_epoch = 0;
}
return result;
}
bool CmvrPlcMotorProtocol::brakeRelease(const std::uint8_t node_id)
{
CMVR_LOG(ERROR) << "[CmvrPlcMotorProtocol] brake release is unsupported, motor="
<< static_cast<int>(node_id);
return false;
}
bool CmvrPlcMotorProtocol::quickStop(const std::uint8_t node_id)
{
CmvrPlcAxisCommand command;
command.code = cmvr_plc::CommandCode::QuickStop;
command.command_timeout_ms = kSafetyCommandTimeoutMs;
const auto result =
bus_runtime_ && bus_runtime_->submitAxisSafetyCommand(node_id, command);
std::lock_guard<std::mutex> lock(nodes_mutex_);
auto& state = nodeStateLocked_(node_id);
state.cyclic_stream_open = false;
if (result) {
state.profile_feedback_bound = false;
state.profile_connection_epoch = 0;
}
return result;
}
double CmvrPlcMotorProtocol::getQ(const std::uint8_t node_id)
{
CmvrPlcAxisStatus status;
if (!readMotionStatus_(node_id, status) ||
!statusAllowsMotionFeedback(status)) {
return std::numeric_limits<double>::quiet_NaN();
}
return fromMicroUnits_(status.actual_position);
}
double CmvrPlcMotorProtocol::getQd(const std::uint8_t node_id)
{
CmvrPlcAxisStatus status;
if (!readMotionStatus_(node_id, status) ||
!statusAllowsMotionFeedback(status)) {
return std::numeric_limits<double>::quiet_NaN();
}
return fromMicroUnits_(status.actual_velocity);
}
bool CmvrPlcMotorProtocol::readMotionStatus_(
const std::uint8_t node_id,
CmvrPlcAxisStatus& status)
{
if (!bus_runtime_) {
return false;
}
std::lock_guard<std::mutex> lock(nodes_mutex_);
const auto& state = nodeStateLocked_(node_id);
const auto read_epoch = bus_runtime_->connectionEpoch();
if (state.profile_feedback_bound &&
state.profile_connection_epoch != read_epoch) {
return false;
}
if (!bus_runtime_->readAxisStatus(node_id, status)) {
return false;
}
// Reject a status transaction that straddled a disconnect/reconnect even
// when no profile binding was active at the first check.
if (bus_runtime_->connectionEpoch() != read_epoch) {
return false;
}
return !state.profile_feedback_bound ||
state.profile_connection_epoch == read_epoch;
}
bool CmvrPlcMotorProtocol::submitSimple_(
const std::uint8_t node_id,
const cmvr_plc::CommandCode code,
const bool wait_for_terminal,
const std::uint32_t timeout_ms)
{
CmvrPlcAxisCommand command;
command.code = code;
command.command_timeout_ms = timeout_ms;
return bus_runtime_ &&
bus_runtime_->submitAxisCommand(node_id, command, wait_for_terminal);
}
bool CmvrPlcMotorProtocol::ensureCyclicOpen_(
const std::uint8_t node_id,
NodeState& state,
const msgs::RunMode mode)
{
if (!bus_runtime_) {
return false;
}
const auto connection_epoch = bus_runtime_->connectionEpoch();
if (state.cyclic_connection_epoch != connection_epoch) {
// A cyclic generation is bound to the PLC ownership epoch in which it
// was created. Never reinterpret a setpoint from that generation as
// the first setpoint of a freshly reconnected PLC stream.
if (state.cyclic_generation != 0U) {
state.cyclic_reconnect_latched = true;
}
state.cyclic_connection_epoch = connection_epoch;
state.cyclic_stream_open = false;
state.cyclic_sequence = 0;
}
if (state.cyclic_reconnect_latched) {
CMVR_LOG(WARNING)
<< "[CmvrPlcMotorProtocol] cyclic stream crossed a PLC session; "
"explicit setMode from a new upper-layer stream is required, motor="
<< static_cast<int>(node_id);
return false;
}
if (state.cyclic_stream_open && state.requested_mode == mode) {
return true;
}
// Preserve direct protocol use that does not call setMode explicitly:
// its first successful Open still establishes a generation. Once that
// generation has observed a reconnect, only explicit setMode can recover.
if (state.cyclic_generation == 0U) {
state.cyclic_generation = 1U;
state.cyclic_connection_epoch = connection_epoch;
}
CmvrPlcAxisCommand command;
command.code = mode == msgs::RUN_MODE_CYCLIC_SYNC_POSITION
? cmvr_plc::CommandCode::OpenCyclicPosition
: cmvr_plc::CommandCode::OpenCyclicVelocity;
command.stream_watchdog_ms = bus_runtime_->streamWatchdogMs();
command.command_timeout_ms = kSafetyCommandTimeoutMs;
if (!bus_runtime_->submitAxisCommand(
node_id, command, false, state.cyclic_connection_epoch)) {
if (bus_runtime_->connectionEpoch() !=
state.cyclic_connection_epoch) {
state.cyclic_reconnect_latched = true;
state.cyclic_stream_open = false;
state.cyclic_sequence = 0;
state.cyclic_connection_epoch =
bus_runtime_->connectionEpoch();
}
return false;
}
state.requested_mode = mode;
state.cyclic_stream_open = true;
state.cyclic_sequence = 0;
return true;
}
std::optional<std::int32_t> CmvrPlcMotorProtocol::toMicroUnits_(const double value)
{
if (!std::isfinite(value)) {
return std::nullopt;
}
const auto scaled = std::round(value * cmvr_plc::kPositionScale);
if (scaled < static_cast<double>(std::numeric_limits<std::int32_t>::min()) ||
scaled > static_cast<double>(std::numeric_limits<std::int32_t>::max())) {
return std::nullopt;
}
return static_cast<std::int32_t>(scaled);
}
double CmvrPlcMotorProtocol::fromMicroUnits_(const std::int32_t value)
{
return static_cast<double>(value) / cmvr_plc::kPositionScale;
}
bool CmvrPlcMotorProtocol::isSupportedMode_(const msgs::RunMode mode)
{
return mode == msgs::RUN_MODE_PROFILE_POSITION ||
mode == msgs::RUN_MODE_PROFILE_VELOCITY ||
mode == msgs::RUN_MODE_CYCLIC_SYNC_POSITION ||
mode == msgs::RUN_MODE_CYCLIC_SYNC_VELOCITY;
}
bool CmvrPlcMotorProtocol::validatePosition_(
const NodeState& state,
const double position) const
{
return std::isfinite(position) &&
(state.limit_q_ub <= state.limit_q_lb ||
(position >= state.limit_q_lb && position <= state.limit_q_ub));
}
bool CmvrPlcMotorProtocol::validateVelocity_(
const NodeState& state,
const double velocity) const
{
return std::isfinite(velocity) &&
(state.limit_qd <= 0.0 || std::abs(velocity) <= state.limit_qd);
}
bool CmvrPlcMotorProtocol::validateAcceleration_(
const NodeState& state,
const double acceleration) const
{
return std::isfinite(acceleration) && acceleration >= 0.0 &&
(state.limit_qdd <= 0.0 || acceleration <= state.limit_qdd);
}
CmvrPlcMotorProtocol::NodeState& CmvrPlcMotorProtocol::nodeStateLocked_(
const std::uint8_t node_id)
{
return nodes_[node_id];
}
} // namespace cmvr::device

View File

@ -0,0 +1,55 @@
#include "devices/motor/drivers/modbus_plc_motor/include/modbus_plc_motor.h"
#include "common/base/logging/logger.h"
#include "devices/motor/drivers/modbus_plc_motor/include/cmvr_plc_motor_protocol.h"
#include <cmath>
namespace cmvr::device {
ModbusPlcMotor::ModbusPlcMotor(const config::MotorConfigItem& config)
{
info_.id = config.id();
info_.joint_name = config.joint_name();
info_.limit_q_lb = config.limit_q_lb();
info_.limit_q_ub = config.limit_q_ub();
info_.limit_qd = config.limit_qd();
info_.limit_qdd = config.limit_qdd();
node_id_ = static_cast<std::uint8_t>(config.id());
}
bool ModbusPlcMotor::init()
{
if (!protocol_ ||
!std::dynamic_pointer_cast<CmvrPlcMotorProtocol>(protocol_)) {
CMVR_LOG(ERROR) << "[ModbusPlcMotor] invalid CMVR PLC protocol for "
<< info_.joint_name;
return false;
}
if (!std::isfinite(info_.limit_q_lb) || !std::isfinite(info_.limit_q_ub) ||
!std::isfinite(info_.limit_qd) || !std::isfinite(info_.limit_qdd) ||
info_.limit_q_ub <= info_.limit_q_lb ||
info_.limit_qd <= 0.0 || info_.limit_qdd <= 0.0) {
CMVR_LOG(ERROR) << "[ModbusPlcMotor] finite position/velocity/acceleration "
"limits are required for "
<< info_.joint_name;
return false;
}
setLimitQ(info_.limit_q_ub, info_.limit_q_lb);
setLimitQd(info_.limit_qd);
setLimitQdd(info_.limit_qdd, -info_.limit_qdd);
return true;
}
bool ModbusPlcMotor::torqueOff()
{
auto protocol = std::dynamic_pointer_cast<CmvrPlcMotorProtocol>(protocol_);
return protocol && protocol->torqueOff(node_id_);
}
bool ModbusPlcMotor::quickStop()
{
auto protocol = std::dynamic_pointer_cast<CmvrPlcMotorProtocol>(protocol_);
return protocol && protocol->quickStop(node_id_);
}
} // namespace cmvr::device

View File

@ -13,6 +13,7 @@ target_link_libraries(motor_manager
cmvr_es::device::ti5_canopen_motor_driver
cmvr_es::device::mujoco_motor_driver
cmvr_es::device::ethercat_motor_driver
cmvr_es::device::modbus_plc_motor_driver
cmvr_es::ik_solver
glog
)

View File

@ -78,6 +78,10 @@ private:
const config::MotorGroupConfig& group_cfg,
const std::vector<config::MotorConfigItem>& motor_cfgs,
const std::shared_ptr<AbstractMotorBusRuntime>& bus_runtime) const;
std::vector<std::shared_ptr<AbstractMotor>> createModbusTcpMotors_(
const config::MotorGroupConfig& group_cfg,
const std::vector<config::MotorConfigItem>& motor_cfgs,
const std::shared_ptr<AbstractMotorBusRuntime>& bus_runtime) const;
private:
config::MotorConfig cfg_;

View File

@ -15,11 +15,14 @@
#include "devices/motor/bus_runtime/can/include/can_motor_bus_runtime.h"
#include "devices/motor/bus_runtime/ethercat/include/ethercat_motor_bus_runtime.h"
#include "devices/motor/bus_runtime/mujoco/include/mujoco_motor_bus_runtime.h"
#include "devices/motor/bus_runtime/modbus_tcp/include/modbus_tcp_motor_bus_runtime.h"
#include "devices/motor/drivers/ethercat_motor/include/cia402/cia402_protocol.h"
#include "devices/motor/drivers/ethercat_motor/include/vendor/eyou/eyou_cia402_pdo_mapping.h"
#include "devices/motor/drivers/ethercat_motor/include/vendor/eyou/eyou_motor.h"
#include "devices/motor/drivers/ethercat_motor/include/vendor/eyou/eyou_motor_adapter.h"
#include "devices/motor/drivers/mujoco/include/mujoco_motor.h"
#include "devices/motor/drivers/modbus_plc_motor/include/cmvr_plc_motor_protocol.h"
#include "devices/motor/drivers/modbus_plc_motor/include/modbus_plc_motor.h"
#include "devices/motor/drivers/ti5_canopen/include/ti5_motor.h"
#include "devices/motor/drivers/ti5_canopen/include/ti5_motor_canopen_protocol.h"
@ -415,6 +418,16 @@ std::shared_ptr<AbstractMotorBusRuntime> MotorManager::createBusRuntime_(
return std::make_shared<CanMotorBusRuntime>();
case config::MOTOR_BUS_MUJOCO:
return std::make_shared<MujocoMotorBusRuntime>();
case config::MOTOR_BUS_MODBUS_TCP:
if (group_cfg.vendor() == config::MOTOR_VENDOR_PLC_GENERIC &&
group_cfg.protocol() == config::MOTOR_PROTOCOL_CMVR_PLC_V1) {
return std::make_shared<ModbusTcpMotorBusRuntime>();
}
CMVR_LOG(ERROR) << "[MotorManager] unsupported Modbus TCP motor: vendor="
<< config::MotorVendor_Name(group_cfg.vendor())
<< ", protocol=" << config::MotorProtocol_Name(group_cfg.protocol())
<< ", group=" << group_cfg.id();
return nullptr;
case config::MOTOR_BUS_ETHERCAT: {
auto runtime = std::make_shared<EthercatMotorBusRuntime>();
if (group_cfg.vendor() == config::MOTOR_VENDOR_EYOU &&
@ -454,6 +467,8 @@ std::vector<std::shared_ptr<AbstractMotor>> MotorManager::createMotors_(
return createMujocoMotors_(group_cfg, motor_cfgs, bus_runtime);
case config::MOTOR_BUS_ETHERCAT:
return createEthercatMotors_(group_cfg, motor_cfgs, bus_runtime);
case config::MOTOR_BUS_MODBUS_TCP:
return createModbusTcpMotors_(group_cfg, motor_cfgs, bus_runtime);
default:
CMVR_LOG(ERROR) << "[MotorManager] unsupported motor bus type: "
<< config::MotorBusType_Name(group_cfg.bus_type())
@ -596,4 +611,50 @@ std::vector<std::shared_ptr<AbstractMotor>> MotorManager::createEthercatMotors_(
return motors;
}
std::vector<std::shared_ptr<AbstractMotor>> MotorManager::createModbusTcpMotors_(
const config::MotorGroupConfig& group_cfg,
const std::vector<config::MotorConfigItem>& motor_cfgs,
const std::shared_ptr<AbstractMotorBusRuntime>& bus_runtime) const
{
auto modbus_runtime =
std::dynamic_pointer_cast<ModbusTcpMotorBusRuntime>(bus_runtime);
if (!modbus_runtime || !group_cfg.has_modbus_tcp()) {
CMVR_LOG(ERROR) << "[MotorManager] missing Modbus TCP runtime/config: "
<< group_cfg.id();
return {};
}
if (group_cfg.vendor() != config::MOTOR_VENDOR_PLC_GENERIC ||
group_cfg.protocol() != config::MOTOR_PROTOCOL_CMVR_PLC_V1) {
CMVR_LOG(ERROR) << "[MotorManager] unsupported Modbus TCP PLC motor: vendor="
<< config::MotorVendor_Name(group_cfg.vendor())
<< ", protocol=" << config::MotorProtocol_Name(group_cfg.protocol());
return {};
}
for (const auto& motor_cfg : motor_cfgs) {
if (motor_cfg.id() < 0 || motor_cfg.id() > 255 ||
!modbus_runtime->hasMotor(static_cast<std::uint8_t>(motor_cfg.id()))) {
CMVR_LOG(ERROR) << "[MotorManager] missing PLC axis mapping for motor id "
<< motor_cfg.id() << " in group: " << group_cfg.id();
return {};
}
}
auto protocol = std::make_shared<CmvrPlcMotorProtocol>(modbus_runtime);
std::vector<std::shared_ptr<AbstractMotor>> motors;
motors.reserve(motor_cfgs.size());
for (const auto& cfg : motor_cfgs) {
auto motor = std::make_shared<ModbusPlcMotor>(cfg);
motor->setProtocol(protocol);
// initNode() and ModbusPlcMotor::init() only establish local mappings and
// limits. The shared TCP connection starts after all motors are created.
if (!motor->init()) {
CMVR_LOG(ERROR) << "[MotorManager] failed to init Modbus PLC motor: "
<< cfg.joint_name();
return {};
}
motors.push_back(std::move(motor));
}
return motors;
}
} // namespace cmvr::device

View File

@ -7,6 +7,7 @@ add_library(service
grpc/src/grpc_head_service.cpp
grpc/src/grpc_dexhand_service.cpp
grpc/src/grpc_arm_service.cpp
grpc/src/grpc_motor_service.cpp
grpc/src/grpc_agv_service.cpp
grpc/src/grpc_hlc_service.cpp
../task/grpc_server_task/src/grpc_server_task.cpp
@ -42,6 +43,76 @@ if(BUILD_TESTING)
COMMAND grpc_camera_stream_policy_test
)
set_tests_properties(grpc_camera_stream_policy_test PROPERTIES TIMEOUT 10)
add_executable(grpc_motor_service_test
grpc/tests/grpc_motor_service_test.cpp
)
target_include_directories(grpc_motor_service_test
PRIVATE
${CMAKE_SOURCE_DIR}/cmvr-es
${CMAKE_SOURCE_DIR}/cmvr-es/manager/device_manager
)
target_link_libraries(grpc_motor_service_test
PRIVATE
service
gtest
gtest_main
pthread
)
add_test(
NAME grpc_motor_service_test
COMMAND grpc_motor_service_test
)
set(_grpc_motor_test_environment
"LD_LIBRARY_PATH=${CMVR_TEST_EXTERNAL_LIBRARY_PATH}"
)
if(CMVR_TEST_SYSTEM_LIBSTDCXX)
list(APPEND _grpc_motor_test_environment
"LD_PRELOAD=${CMVR_TEST_SYSTEM_LIBSTDCXX}")
endif()
set_tests_properties(grpc_motor_service_test PROPERTIES
TIMEOUT 10
ENVIRONMENT "${_grpc_motor_test_environment}"
)
set(_grpc_motor_modbus_e2e_libmodbus_root
"${CMAKE_SOURCE_DIR}/dependency/${ARCH}/third_party/modbus/3.1.11")
add_executable(grpc_motor_service_modbus_e2e_test
grpc/tests/grpc_motor_service_modbus_e2e_test.cpp
)
target_include_directories(grpc_motor_service_modbus_e2e_test
PRIVATE
${CMAKE_SOURCE_DIR}/cmvr-es
${CMAKE_SOURCE_DIR}/cmvr-es/manager/device_manager
${_grpc_motor_modbus_e2e_libmodbus_root}/include
)
target_link_directories(grpc_motor_service_modbus_e2e_test
PRIVATE
${_grpc_motor_modbus_e2e_libmodbus_root}/lib
)
target_link_libraries(grpc_motor_service_modbus_e2e_test
PRIVATE
service
modbus
gtest
gtest_main
pthread
)
add_test(
NAME grpc_motor_service_modbus_e2e_test
COMMAND grpc_motor_service_modbus_e2e_test
)
set(_grpc_motor_modbus_e2e_environment
"LD_LIBRARY_PATH=${CMVR_TEST_EXTERNAL_LIBRARY_PATH}"
)
if(CMVR_TEST_SYSTEM_LIBSTDCXX)
list(APPEND _grpc_motor_modbus_e2e_environment
"LD_PRELOAD=${CMVR_TEST_SYSTEM_LIBSTDCXX}")
endif()
set_tests_properties(grpc_motor_service_modbus_e2e_test PROPERTIES
TIMEOUT 20
ENVIRONMENT "${_grpc_motor_modbus_e2e_environment}"
)
endif()
# --------------------------------------------------------

View File

@ -0,0 +1,197 @@
#pragma once
#include <chrono>
#include <cstdint>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
#include "cmvr/api/motor_service.grpc.pb.h"
#include "devices/motor/abstract_motor.h"
namespace cmvr::device {
class DeviceManager;
}
namespace cmvr::service {
class gRPCMotorServiceImplTestAccess;
// A deliberately thin synchronous gRPC facade over AbstractMotor. It does not
// schedule trajectories or retain asynchronous operations. The small amount of
// state below only prevents two RPCs from owning one motor at the same time and
// lets emergencyStop invalidate an already-running blocking RPC/stream.
class gRPCMotorServiceImpl final : public api::MotorService::Service {
public:
gRPCMotorServiceImpl();
~gRPCMotorServiceImpl() override = default;
grpc::Status setZero(grpc::ServerContext* context,
const api::SetMotorZeroRequest* request,
api::MotorCommandResponse* response) override;
grpc::Status moveToZero(grpc::ServerContext* context,
const api::MoveMotorToZeroRequest* request,
api::MotorCommandResponse* response) override;
grpc::Status profilePosition(grpc::ServerContext* context,
const api::ProfilePositionRequest* request,
api::MotorCommandResponse* response) override;
grpc::Status profileVelocity(grpc::ServerContext* context,
const api::ProfileVelocityRequest* request,
api::MotorCommandResponse* response) override;
grpc::Status streamCyclicPosition(
grpc::ServerContext* context,
grpc::ServerReaderWriter<api::CyclicControlResponse,
api::CyclicPositionRequest>* stream) override;
grpc::Status streamCyclicVelocity(
grpc::ServerContext* context,
grpc::ServerReaderWriter<api::CyclicControlResponse,
api::CyclicVelocityRequest>* stream) override;
grpc::Status emergencyStop(grpc::ServerContext* context,
const api::EmergencyStopRequest* request,
api::MotorCommandResponse* response) override;
grpc::Status getStatus(grpc::ServerContext* context,
const api::GetMotorStatusRequest* request,
api::GetMotorStatusResponse* response) override;
grpc::Status setEnabled(grpc::ServerContext* context,
const api::SetMotorEnabledRequest* request,
api::MotorCommandResponse* response) override;
private:
friend class gRPCMotorServiceImplTestAccess;
struct MotorControlState {
std::mutex mutex;
// Serializes all motion/enable writes with emergency quick-stop. The
// cancel-generation check and the corresponding motor write must occur
// while this mutex is held to prevent stale writes after an E-stop.
std::mutex command_mutex;
// Keeps the two-phase best-effort/final quick-stop sequence exclusive.
// Without this, one concurrent E-stop could clear the shared
// in-progress flag while another E-stop is still dispatching.
std::mutex emergency_mutex;
// Serializes exception cleanup from ownership inspection through the
// final release. A second stale cleanup must re-check ownership only
// after the first cleanup has fully completed.
std::mutex exception_cleanup_mutex;
bool busy{false};
bool emergency_stopped{false};
bool emergency_stop_in_progress{false};
bool exception_cleanup_pending{false};
std::uint64_t cancel_generation{0};
api::MotorControlType active_control{api::MOTOR_CONTROL_NONE};
std::string last_error;
};
struct ResolvedMotor {
std::shared_ptr<device::AbstractMotor> motor;
std::shared_ptr<MotorControlState> control;
};
struct MotorControlEntry {
std::weak_ptr<device::AbstractMotor> owner;
std::shared_ptr<MotorControlState> state;
};
class ControlLease {
public:
ControlLease(std::shared_ptr<MotorControlState> state,
std::uint64_t generation);
~ControlLease();
ControlLease(const ControlLease&) = delete;
ControlLease& operator=(const ControlLease&) = delete;
std::uint64_t generation() const noexcept { return generation_; }
private:
std::shared_ptr<MotorControlState> state_;
std::uint64_t generation_{0};
int uncaught_on_entry_{0};
};
grpc::Status resolveMotor(const api::MotorTarget& target,
ResolvedMotor& resolved) const;
std::shared_ptr<MotorControlState> stateFor(
const std::shared_ptr<device::AbstractMotor>& motor) const;
std::unique_ptr<ControlLease> acquireControl(
const ResolvedMotor& resolved,
api::MotorControlType control,
grpc::Status& failure,
bool allow_emergency_stopped = false) const;
grpc::Status runProfilePosition(grpc::ServerContext* context,
const ResolvedMotor& resolved,
double target_position_rad,
double max_velocity_rad_s,
double acceleration_rad_s2,
const api::MotorWaitOptions& wait,
api::MotorCommandResponse* response);
grpc::Status waitForPosition(grpc::ServerContext* context,
const ResolvedMotor& resolved,
std::uint64_t generation,
double target_position_rad,
const api::MotorWaitOptions& wait,
api::MotorCommandResponse* response,
std::chrono::steady_clock::time_point started);
grpc::Status waitForVelocity(grpc::ServerContext* context,
const ResolvedMotor& resolved,
std::uint64_t generation,
double target_velocity_rad_s,
const api::MotorWaitOptions& wait,
api::MotorCommandResponse* response,
std::chrono::steady_clock::time_point started);
grpc::Status setZeroImpl(grpc::ServerContext* context,
const api::SetMotorZeroRequest* request,
api::MotorCommandResponse* response);
grpc::Status moveToZeroImpl(grpc::ServerContext* context,
const api::MoveMotorToZeroRequest* request,
api::MotorCommandResponse* response);
grpc::Status profilePositionImpl(
grpc::ServerContext* context,
const api::ProfilePositionRequest* request,
api::MotorCommandResponse* response);
grpc::Status profileVelocityImpl(
grpc::ServerContext* context,
const api::ProfileVelocityRequest* request,
api::MotorCommandResponse* response);
grpc::Status emergencyStopImpl(
grpc::ServerContext* context,
const api::EmergencyStopRequest* request,
api::MotorCommandResponse* response);
grpc::Status getStatusImpl(grpc::ServerContext* context,
const api::GetMotorStatusRequest* request,
api::GetMotorStatusResponse* response);
grpc::Status setEnabledImpl(
grpc::ServerContext* context,
const api::SetMotorEnabledRequest* request,
api::MotorCommandResponse* response);
grpc::Status streamCyclicPositionImpl(
grpc::ServerContext* context,
grpc::ServerReaderWriter<api::CyclicControlResponse,
api::CyclicPositionRequest>* stream,
std::optional<api::MotorTarget>& cleanup_target);
grpc::Status streamCyclicVelocityImpl(
grpc::ServerContext* context,
grpc::ServerReaderWriter<api::CyclicControlResponse,
api::CyclicVelocityRequest>* stream,
std::optional<api::MotorTarget>& cleanup_target);
void bestEffortQuickStop(const api::MotorTarget& target,
const std::string& error) noexcept;
void latchUnsafeAfterFailedStop(
const std::shared_ptr<MotorControlState>& state,
const std::string& error) const;
void fillMotorStatus(const ResolvedMotor& resolved,
api::MotorStatus* status) const;
void setLastError(const std::shared_ptr<MotorControlState>& state,
const std::string& error) const;
device::DeviceManager& dmgr_;
mutable std::mutex states_mutex_;
mutable std::unordered_map<const device::AbstractMotor*,
MotorControlEntry> states_;
};
} // namespace cmvr::service

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,907 @@
#include "service/grpc/include/grpc_motor_service.h"
#include <arpa/inet.h>
#include <array>
#include <atomic>
#include <cerrno>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <memory>
#include <sstream>
#include <string>
#include <thread>
#include <grpcpp/grpcpp.h>
#include <gtest/gtest.h>
#include <modbus/modbus.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>
#include "cmvr/config/device_manager_config/device_manager_config.pb.h"
#include "cmvr/config/motor_config/motor_config.pb.h"
#include "common/config/config_files.h"
#include "devices/motor/bus_runtime/modbus_tcp/include/cmvr_plc_register_map.h"
#include "devices/motor/manager/include/motor_manager.h"
#include "manager/device_manager/include/device_manager.h"
namespace cmvr::service {
namespace {
using namespace std::chrono_literals;
namespace plc = device::cmvr_plc;
constexpr char kManagerId[] = "e2e_plc_motors";
constexpr char kMotorGroupId[] = "e2e_plc_axis_group";
constexpr char kJointName[] = "E2E_PLC_AXIS";
std::uint16_t reserveLoopbackPort()
{
const int socket_fd = ::socket(AF_INET, SOCK_STREAM, 0);
if (socket_fd < 0) {
return 0;
}
sockaddr_in address{};
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
address.sin_port = 0;
if (::bind(socket_fd, reinterpret_cast<sockaddr*>(&address),
sizeof(address)) != 0) {
::close(socket_fd);
return 0;
}
socklen_t length = sizeof(address);
if (::getsockname(socket_fd, reinterpret_cast<sockaddr*>(&address),
&length) != 0) {
::close(socket_fd);
return 0;
}
const auto port = ntohs(address.sin_port);
::close(socket_fd);
return port;
}
template <typename Predicate>
bool waitUntil(Predicate&& predicate, const std::chrono::milliseconds timeout)
{
const auto deadline = std::chrono::steady_clock::now() + timeout;
do {
if (predicate()) {
return true;
}
std::this_thread::sleep_for(5ms);
} while (std::chrono::steady_clock::now() < deadline);
return predicate();
}
class FakeCmvrPlc final {
public:
~FakeCmvrPlc() { stop(); }
bool start()
{
port_ = reserveLoopbackPort();
if (port_ == 0) {
std::fprintf(stderr, "reserveLoopbackPort failed: %s\n",
std::strerror(errno));
return false;
}
context_ = modbus_new_tcp("127.0.0.1", port_);
mapping_ = modbus_mapping_new(
1, 1, plc::kAxisFirstOffset + plc::kAxisRegisterStride, 1);
if (!context_ || !mapping_) {
std::fprintf(stderr, "fake PLC allocation failed: %s\n",
modbus_strerror(errno));
stop();
return false;
}
auto* registers = mapping_->tab_registers;
registers[plc::kMagicCmOffset] = plc::kMagicCm;
registers[plc::kMagicVrOffset] = plc::kMagicVr;
registers[plc::kProtocolMajorOffset] = plc::kProtocolMajor;
registers[plc::kProtocolMinorOffset] = plc::kProtocolMinor;
registers[plc::kAxisCountOffset] = 1;
registers[plc::kOwnerStateOffset] =
static_cast<std::uint16_t>(plc::OwnerState::None);
plc::encodeUint32(registers, plc::kPlcBootIdOffset, 0x45453245U);
plc::encodeUint32(registers, plc::kPlcHeartbeatOffset,
plc_heartbeat_);
const auto status_base =
plc::axisBase(0) + plc::kAxisStatusRelativeOffset;
registers[status_base + plc::kCommandState] =
static_cast<std::uint16_t>(plc::CommandState::Idle);
registers[status_base + plc::kResultCode] =
static_cast<std::uint16_t>(plc::ResultCode::Ok);
publishSnapshot_(registers + status_base);
listen_socket_.store(modbus_tcp_listen(context_, 2));
if (listen_socket_.load() < 0) {
std::fprintf(stderr, "modbus_tcp_listen failed on %u: %s\n",
port_, modbus_strerror(errno));
stop();
return false;
}
running_.store(true);
worker_ = std::thread(&FakeCmvrPlc::loop_, this);
return true;
}
void stop()
{
running_.store(false);
const auto client = client_socket_.exchange(-1);
if (client >= 0) {
::shutdown(client, SHUT_RDWR);
::close(client);
}
const auto listener = listen_socket_.exchange(-1);
if (listener >= 0) {
::shutdown(listener, SHUT_RDWR);
::close(listener);
}
if (worker_.joinable()) {
worker_.join();
}
if (mapping_) {
modbus_mapping_free(mapping_);
mapping_ = nullptr;
}
if (context_) {
modbus_free(context_);
context_ = nullptr;
}
}
void disconnectClient()
{
const auto client = client_socket_.load();
if (client >= 0) {
::shutdown(client, SHUT_RDWR);
}
}
std::uint16_t port() const { return port_; }
std::uint32_t commandCount() const { return command_count_.load(); }
std::uint32_t acceptedSessionCount() const
{
return accepted_session_count_.load();
}
std::uint32_t enableCount() const { return enable_count_.load(); }
std::uint32_t profilePositionCount() const
{
return profile_position_count_.load();
}
std::uint32_t profileVelocityCount() const
{
return profile_velocity_count_.load();
}
std::uint32_t cyclicOpenCount() const
{
return cyclic_open_count_.load();
}
std::uint32_t cyclicSampleCount() const
{
return cyclic_sample_count_.load();
}
std::uint32_t quickStopCount() const
{
return quick_stop_count_.load();
}
private:
void loop_()
{
std::array<std::uint8_t, MODBUS_TCP_MAX_ADU_LENGTH> request{};
while (running_.load()) {
int listener = listen_socket_.load();
const int accepted =
listener < 0 ? -1 : modbus_tcp_accept(context_, &listener);
if (accepted < 0) {
if (running_.load()) {
std::this_thread::sleep_for(5ms);
}
continue;
}
client_socket_.store(accepted);
while (running_.load()) {
const auto request_length =
modbus_receive(context_, request.data());
if (request_length <= 0) {
break;
}
if (modbus_reply(context_, request.data(), request_length,
mapping_) < 0) {
break;
}
// Treat the mapping as the PLC's published process image.
// Read-only FC3 requests must not themselves advance the
// seqlock; otherwise the runtime's guard/full/guard snapshot
// validation could never observe one stable scan.
if (request_length > 7 && request[7] == 0x10U) {
processMailbox_();
}
}
const auto client = client_socket_.exchange(-1);
if (client >= 0) {
::close(client);
}
}
}
void processMailbox_()
{
auto* registers = mapping_->tab_registers;
const auto base = plc::axisBase(0);
const auto status_base = base + plc::kAxisStatusRelativeOffset;
beginSnapshot_(registers + status_base);
plc::encodeUint32(registers, plc::kPlcHeartbeatOffset,
++plc_heartbeat_);
const auto session =
plc::decodeUint32(registers, plc::kCmvrSessionIdOffset);
if (session != observed_session_) {
// A new session is not observable as Accepted until the old
// stream/mailbox state has been made safe.
registers[plc::kOwnerStateOffset] =
static_cast<std::uint16_t>(plc::OwnerState::Accepting);
observed_session_ = session;
active_session_ = session;
last_sequence_ = 0;
registers[status_base + plc::kStatusFlags] &=
static_cast<std::uint16_t>(~plc::StatusFlag::StreamActive);
plc::encodeUint32(registers + base, plc::kPayloadSequence, 0);
plc::encodeUint32(registers + base,
plc::kPayloadSequenceMirror, 0);
plc::encodeUint32(registers + base,
plc::kCommitSequenceRelativeOffset, 0);
plc::encodeUint32(registers, plc::kOwnerSessionIdOffset,
active_session_);
registers[plc::kOwnerStateOffset] =
static_cast<std::uint16_t>(
active_session_ == 0 ? plc::OwnerState::None
: plc::OwnerState::Accepted);
if (active_session_ != 0) {
accepted_session_count_.fetch_add(1);
}
}
if (active_session_ == 0 || active_session_ != session) {
publishSnapshot_(registers + status_base);
return;
}
const auto sequence =
plc::decodeUint32(registers + base, plc::kPayloadSequence);
const auto sequence_mirror =
plc::decodeUint32(registers + base,
plc::kPayloadSequenceMirror);
const auto commit =
plc::decodeUint32(registers + base,
plc::kCommitSequenceRelativeOffset);
const auto command_session =
plc::decodeUint32(registers + base, plc::kCommandSessionId);
if (sequence == 0 || sequence == last_sequence_ ||
sequence != sequence_mirror || sequence != commit ||
command_session != active_session_) {
publishSnapshot_(registers + status_base);
return;
}
last_sequence_ = sequence;
command_count_.fetch_add(1);
plc::encodeUint32(registers + status_base, plc::kAckSequence,
sequence);
plc::encodeUint32(registers + status_base, plc::kActiveSequence,
sequence);
plc::encodeUint32(registers + status_base, plc::kAckSessionId,
active_session_);
registers[status_base + plc::kResultCode] =
static_cast<std::uint16_t>(plc::ResultCode::Ok);
const auto code = static_cast<plc::CommandCode>(
registers[base + plc::kCommandCode]);
const auto target_position =
plc::decodeInt32(registers + base, plc::kTargetPosition);
const auto target_velocity =
plc::decodeInt32(registers + base, plc::kTargetVelocity);
auto& flags = registers[status_base + plc::kStatusFlags];
auto state = plc::CommandState::Completed;
switch (code) {
case plc::CommandCode::ProfilePosition:
profile_position_count_.fetch_add(1);
registers[status_base + plc::kCurrentMode] =
msgs::RUN_MODE_PROFILE_POSITION;
plc::encodeInt32(registers + status_base,
plc::kActualPosition, target_position);
plc::encodeInt32(registers + status_base,
plc::kActualVelocity, 0);
plc::encodeInt32(registers + status_base,
plc::kTargetPositionStatus, target_position);
flags |= plc::StatusFlag::TargetReached;
flags &= static_cast<std::uint16_t>(
~plc::StatusFlag::QuickStopActive);
state = plc::CommandState::TargetReached;
break;
case plc::CommandCode::ProfileVelocity:
profile_velocity_count_.fetch_add(1);
registers[status_base + plc::kCurrentMode] =
msgs::RUN_MODE_PROFILE_VELOCITY;
plc::encodeInt32(registers + status_base,
plc::kActualVelocity, target_velocity);
plc::encodeInt32(registers + status_base,
plc::kTargetVelocityStatus, target_velocity);
flags &= static_cast<std::uint16_t>(
~plc::StatusFlag::QuickStopActive);
state = plc::CommandState::Accepted;
break;
case plc::CommandCode::OpenCyclicPosition:
cyclic_open_count_.fetch_add(1);
registers[status_base + plc::kCurrentMode] =
msgs::RUN_MODE_CYCLIC_SYNC_POSITION;
plc::encodeUint32(
registers + status_base,
plc::kLastAppliedCyclicSequence, 0);
flags |= plc::StatusFlag::StreamActive;
flags &= static_cast<std::uint16_t>(
~plc::StatusFlag::QuickStopActive);
state = plc::CommandState::Accepted;
break;
case plc::CommandCode::CyclicPositionSample:
cyclic_sample_count_.fetch_add(1);
plc::encodeInt32(registers + status_base,
plc::kActualPosition, target_position);
plc::encodeInt32(registers + status_base,
plc::kActualVelocity, target_velocity);
plc::encodeUint32(
registers + status_base,
plc::kLastAppliedCyclicSequence,
plc::decodeUint32(registers + base,
plc::kCyclicSampleSequence));
state = plc::CommandState::Accepted;
break;
case plc::CommandCode::QuickStop:
quick_stop_count_.fetch_add(1);
plc::encodeInt32(registers + status_base,
plc::kActualVelocity, 0);
flags |= plc::StatusFlag::QuickStopActive;
flags &= static_cast<std::uint16_t>(
~plc::StatusFlag::StreamActive);
state = plc::CommandState::QuickStopped;
break;
case plc::CommandCode::Enable:
enable_count_.fetch_add(1);
flags |= plc::StatusFlag::Enabled;
flags &= static_cast<std::uint16_t>(
~plc::StatusFlag::QuickStopActive);
break;
case plc::CommandCode::Disable:
flags &= static_cast<std::uint16_t>(
~plc::StatusFlag::Enabled);
flags &= static_cast<std::uint16_t>(
~plc::StatusFlag::StreamActive);
break;
default:
registers[status_base + plc::kResultCode] =
static_cast<std::uint16_t>(
plc::ResultCode::Unsupported);
state = plc::CommandState::Rejected;
break;
}
registers[status_base + plc::kCommandState] =
static_cast<std::uint16_t>(state);
publishSnapshot_(registers + status_base);
}
void publishSnapshot_(std::uint16_t* status)
{
if (pending_state_sequence_ == 0U) {
beginSnapshot_(status);
}
plc::encodeUint32(status, plc::kHeartbeatAge, 0);
plc::encodeUint32(status, plc::kStateSequenceMirror,
pending_state_sequence_);
plc::encodeUint32(status, plc::kStateSequence,
pending_state_sequence_);
state_sequence_ = pending_state_sequence_;
pending_state_sequence_ = 0U;
}
void beginSnapshot_(std::uint16_t* status)
{
auto next = state_sequence_ + 2U;
if (next == 0U) {
next = 2U;
}
pending_state_sequence_ = next;
plc::encodeUint32(status, plc::kStateSequence, next - 1U);
}
modbus_t* context_{nullptr};
modbus_mapping_t* mapping_{nullptr};
std::thread worker_;
std::atomic<bool> running_{false};
std::atomic<int> listen_socket_{-1};
std::atomic<int> client_socket_{-1};
std::atomic<std::uint32_t> command_count_{0};
std::atomic<std::uint32_t> accepted_session_count_{0};
std::atomic<std::uint32_t> enable_count_{0};
std::atomic<std::uint32_t> profile_position_count_{0};
std::atomic<std::uint32_t> profile_velocity_count_{0};
std::atomic<std::uint32_t> cyclic_open_count_{0};
std::atomic<std::uint32_t> cyclic_sample_count_{0};
std::atomic<std::uint32_t> quick_stop_count_{0};
std::uint32_t observed_session_{0};
std::uint32_t active_session_{0};
std::uint32_t plc_heartbeat_{1};
std::uint32_t state_sequence_{0};
std::uint32_t pending_state_sequence_{0};
std::uint32_t last_sequence_{0};
std::uint16_t port_{0};
};
std::string writeTemporaryMotorConfig(const std::uint16_t port)
{
char path[] = "/tmp/cmvr_motor_service_modbus_e2e_XXXXXX.pb.txt";
const int descriptor = ::mkstemps(path, 7);
if (descriptor < 0) {
return {};
}
::close(descriptor);
std::ofstream output(path, std::ios::out | std::ios::trunc);
if (!output.is_open()) {
std::remove(path);
return {};
}
output << "motor {\n"
<< " id: \"" << kManagerId << "\"\n"
<< " motor_groups {\n"
<< " id: \"" << kMotorGroupId << "\"\n"
<< " bus_type: MOTOR_BUS_MODBUS_TCP\n"
<< " vendor: MOTOR_VENDOR_PLC_GENERIC\n"
<< " protocol: MOTOR_PROTOCOL_CMVR_PLC_V1\n"
<< " modbus_tcp {\n"
<< " host: \"127.0.0.1\"\n"
<< " port: " << port << "\n"
<< " unit_id: 1\n"
<< " connect_timeout_ms: 200\n"
<< " io_timeout_ms: 50\n"
<< " heartbeat_period_ms: 20\n"
<< " communication_watchdog_ms: 300\n"
<< " status_poll_period_ms: 2\n"
<< " reconnect_min_ms: 10\n"
<< " reconnect_max_ms: 50\n"
<< " command_ack_timeout_ms: 300\n"
<< " cyclic_watchdog_ms: 300\n"
<< " protocol_major: 1\n"
<< " protocol_minor: 0\n"
<< " axes { motor_id: 1 axis_index: 0 }\n"
<< " }\n"
<< " joint_limits {\n"
<< " enable: true\n"
<< " source: JOINT_LIMIT_SOURCE_CUSTOM\n"
<< " joints {\n"
<< " joint_name: \"" << kJointName << "\"\n"
<< " q_lb: -2.0\n"
<< " q_ub: 2.0\n"
<< " qd: 2.0\n"
<< " qdd: 4.0\n"
<< " }\n"
<< " }\n"
<< " motors {\n"
<< " motors { id: 1 joint_name: \"" << kJointName << "\" }\n"
<< " }\n"
<< " }\n"
<< "}\n";
output.close();
if (!output) {
std::remove(path);
return {};
}
return path;
}
class MotorServiceModbusE2eTest : public ::testing::Test {
protected:
void SetUp() override
{
device::DeviceManager::destroyInstance();
ASSERT_TRUE(plc_.start());
config_path_ = writeTemporaryMotorConfig(plc_.port());
ASSERT_FALSE(config_path_.empty());
config::MotorRootConfig parsed;
ASSERT_TRUE(ConfigHelper::loadConfigFileSilent(config_path_, parsed));
ASSERT_EQ(parsed.motor().id(), kManagerId);
ASSERT_EQ(parsed.motor().motor_groups_size(), 1);
config::DeviceManagerConfig device_config;
device_config.set_init_all_motors_when_no_active_joints(true);
auto* entry = device_config.add_devices();
entry->set_id(kManagerId);
entry->set_type(
config::DeviceConfigEntry::DEVICE_TYPE_MOTOR_SYSTEM);
entry->set_config_file(config_path_);
entry->set_enable(true);
auto& device_manager =
device::DeviceManager::getInstance(device_config);
manager_ =
device_manager.getDevice<device::MotorManager>(kManagerId);
ASSERT_NE(manager_, nullptr);
ASSERT_NE(manager_->getMotor(1), nullptr);
ASSERT_EQ(manager_->getMotor(1)->jointName(), kJointName);
service_ = std::make_unique<gRPCMotorServiceImpl>();
ASSERT_TRUE(startGrpcServer_());
}
void TearDown() override
{
if (grpc_server_) {
grpc_server_->Shutdown();
grpc_server_->Wait();
grpc_server_.reset();
}
stub_.reset();
service_.reset();
if (manager_) {
manager_->stop();
manager_.reset();
}
device::DeviceManager::destroyInstance();
plc_.stop();
if (!grpc_socket_path_.empty()) {
std::remove(grpc_socket_path_.c_str());
grpc_socket_path_.clear();
}
if (!config_path_.empty()) {
std::remove(config_path_.c_str());
config_path_.clear();
}
}
static api::MotorTarget makeTarget_()
{
api::MotorTarget target;
target.mutable_header()->set_device_id(kManagerId);
target.set_motor_id(1);
return target;
}
bool startGrpcServer_()
{
grpc_socket_path_ =
"/tmp/cmvr_motor_service_modbus_e2e_" +
std::to_string(static_cast<long long>(::getpid())) + ".sock";
std::remove(grpc_socket_path_.c_str());
const std::string server_address = "unix:" + grpc_socket_path_;
grpc::ServerBuilder builder;
builder.AddListeningPort(
server_address, grpc::InsecureServerCredentials());
builder.RegisterService(service_.get());
grpc_server_ = builder.BuildAndStart();
if (!grpc_server_) {
return false;
}
stub_ = api::MotorService::NewStub(
grpc::CreateChannel(
server_address,
grpc::InsecureChannelCredentials()));
return stub_ != nullptr;
}
FakeCmvrPlc plc_;
std::shared_ptr<device::MotorManager> manager_;
std::unique_ptr<gRPCMotorServiceImpl> service_;
std::unique_ptr<grpc::Server> grpc_server_;
std::unique_ptr<api::MotorService::Stub> stub_;
std::string config_path_;
std::string grpc_socket_path_;
};
TEST_F(MotorServiceModbusE2eTest,
StubTraversesManagerAndModbusRuntimeWithSafetyAndReconnect)
{
api::SetMotorEnabledRequest enable_request;
*enable_request.mutable_target() = makeTarget_();
enable_request.set_enabled(true);
grpc::ClientContext enable_context;
enable_context.set_deadline(
std::chrono::system_clock::now() + 3s);
api::MotorCommandResponse enable_response;
const auto enable_status =
stub_->setEnabled(&enable_context, enable_request, &enable_response);
ASSERT_TRUE(enable_status.ok()) << enable_status.error_message();
ASSERT_TRUE(enable_response.header().success())
<< enable_response.header().error_message();
EXPECT_EQ(plc_.enableCount(), 1U);
api::ProfilePositionRequest position_request;
*position_request.mutable_target() = makeTarget_();
position_request.set_target_position_rad(0.75);
position_request.set_max_velocity_rad_s(0.5);
position_request.set_acceleration_rad_s2(1.0);
position_request.mutable_wait()->set_timeout_ms(1000);
position_request.mutable_wait()->set_poll_period_ms(2);
position_request.mutable_wait()->set_settle_sample_count(1);
grpc::ClientContext position_context;
position_context.set_deadline(
std::chrono::system_clock::now() + 3s);
api::MotorCommandResponse position_response;
const auto position_status = stub_->profilePosition(
&position_context, position_request, &position_response);
ASSERT_TRUE(position_status.ok()) << position_status.error_message();
ASSERT_TRUE(position_response.header().success())
<< position_response.header().error_message();
EXPECT_NEAR(position_response.status().position_rad(), 0.75, 1e-6);
EXPECT_TRUE(position_response.status().target_reached());
EXPECT_EQ(plc_.profilePositionCount(), 1U);
api::GetMotorStatusRequest get_status_request;
*get_status_request.mutable_target() = makeTarget_();
grpc::ClientContext get_status_context;
get_status_context.set_deadline(
std::chrono::system_clock::now() + 3s);
api::GetMotorStatusResponse get_status_response;
const auto get_status = stub_->getStatus(
&get_status_context, get_status_request, &get_status_response);
ASSERT_TRUE(get_status.ok()) << get_status.error_message();
ASSERT_TRUE(get_status_response.header().success())
<< get_status_response.header().error_message();
EXPECT_EQ(get_status_response.status().motor_id(), 1U);
EXPECT_EQ(get_status_response.status().joint_name(), kJointName);
EXPECT_EQ(get_status_response.status().run_mode(),
msgs::RUN_MODE_PROFILE_POSITION);
EXPECT_NEAR(get_status_response.status().position_rad(), 0.75, 1e-6);
api::ProfileVelocityRequest velocity_request;
*velocity_request.mutable_target() = makeTarget_();
velocity_request.set_target_velocity_rad_s(-0.2);
velocity_request.set_acceleration_rad_s2(0.5);
velocity_request.mutable_wait()->set_timeout_ms(1000);
velocity_request.mutable_wait()->set_poll_period_ms(2);
velocity_request.mutable_wait()->set_settle_sample_count(1);
grpc::ClientContext velocity_context;
velocity_context.set_deadline(
std::chrono::system_clock::now() + 3s);
api::MotorCommandResponse velocity_response;
const auto velocity_status = stub_->profileVelocity(
&velocity_context, velocity_request, &velocity_response);
ASSERT_TRUE(velocity_status.ok()) << velocity_status.error_message();
ASSERT_TRUE(velocity_response.header().success())
<< velocity_response.header().error_message();
EXPECT_NEAR(velocity_response.status().velocity_rad_s(), -0.2, 1e-6);
EXPECT_EQ(plc_.profileVelocityCount(), 1U);
const auto quick_stops_before_stream = plc_.quickStopCount();
grpc::ClientContext stream_context;
stream_context.set_deadline(
std::chrono::system_clock::now() + 3s);
auto stream = stub_->streamCyclicPosition(&stream_context);
ASSERT_NE(stream, nullptr);
api::CyclicPositionRequest open_request;
*open_request.mutable_open()->mutable_target() = makeTarget_();
open_request.mutable_open()->set_watchdog_timeout_ms(500);
ASSERT_TRUE(stream->Write(open_request));
api::CyclicControlResponse stream_response;
ASSERT_TRUE(stream->Read(&stream_response));
ASSERT_TRUE(stream_response.header().success())
<< stream_response.header().error_message();
EXPECT_EQ(stream_response.phase(), api::CYCLIC_STREAM_OPENED);
api::CyclicPositionRequest setpoint_request;
auto* setpoint = setpoint_request.mutable_setpoint();
setpoint->set_sequence(1);
setpoint->set_target_position_rad(0.25);
setpoint->set_target_velocity_rad_s(0.1);
ASSERT_TRUE(stream->Write(setpoint_request));
stream_response.Clear();
ASSERT_TRUE(stream->Read(&stream_response));
ASSERT_TRUE(stream_response.header().success())
<< stream_response.header().error_message();
EXPECT_EQ(stream_response.phase(), api::CYCLIC_STREAM_APPLIED);
EXPECT_EQ(stream_response.sequence(), 1U);
EXPECT_FALSE(stream_response.has_status());
EXPECT_EQ(plc_.cyclicOpenCount(), 1U);
EXPECT_EQ(plc_.cyclicSampleCount(), 1U);
ASSERT_TRUE(stream->WritesDone());
stream_response.Clear();
ASSERT_TRUE(stream->Read(&stream_response));
EXPECT_TRUE(stream_response.header().success())
<< stream_response.header().error_message();
EXPECT_EQ(stream_response.phase(), api::CYCLIC_STREAM_STOPPED);
EXPECT_FALSE(stream->Read(&stream_response));
const auto stream_finish = stream->Finish();
ASSERT_TRUE(stream_finish.ok()) << stream_finish.error_message();
EXPECT_GT(plc_.quickStopCount(), quick_stops_before_stream);
const auto quick_stops_before_emergency = plc_.quickStopCount();
api::EmergencyStopRequest emergency_request;
*emergency_request.mutable_target() = makeTarget_();
grpc::ClientContext emergency_context;
emergency_context.set_deadline(
std::chrono::system_clock::now() + 3s);
api::MotorCommandResponse emergency_response;
const auto emergency_status = stub_->emergencyStop(
&emergency_context, emergency_request, &emergency_response);
ASSERT_TRUE(emergency_status.ok()) << emergency_status.error_message();
ASSERT_TRUE(emergency_response.header().success())
<< emergency_response.header().error_message();
EXPECT_TRUE(emergency_response.status().emergency_stopped());
EXPECT_GT(plc_.quickStopCount(), quick_stops_before_emergency);
const auto commands_before_rejected_motion = plc_.commandCount();
api::ProfilePositionRequest rejected_request;
*rejected_request.mutable_target() = makeTarget_();
rejected_request.set_target_position_rad(0.5);
rejected_request.set_max_velocity_rad_s(0.5);
rejected_request.set_acceleration_rad_s2(1.0);
grpc::ClientContext rejected_context;
rejected_context.set_deadline(
std::chrono::system_clock::now() + 3s);
api::MotorCommandResponse rejected_response;
const auto rejected_status = stub_->profilePosition(
&rejected_context, rejected_request, &rejected_response);
EXPECT_EQ(rejected_status.error_code(),
grpc::StatusCode::FAILED_PRECONDITION);
EXPECT_FALSE(rejected_response.header().success());
EXPECT_EQ(plc_.commandCount(), commands_before_rejected_motion);
const auto sessions_before_disconnect =
plc_.acceptedSessionCount();
const auto commands_before_disconnect = plc_.commandCount();
plc_.disconnectClient();
ASSERT_TRUE(waitUntil(
[&] {
return plc_.acceptedSessionCount() >
sessions_before_disconnect;
},
3s));
std::this_thread::sleep_for(100ms);
EXPECT_EQ(plc_.commandCount(), commands_before_disconnect);
api::SetMotorEnabledRequest reenable_request;
*reenable_request.mutable_target() = makeTarget_();
reenable_request.set_enabled(true);
grpc::ClientContext reenable_context;
reenable_context.set_deadline(
std::chrono::system_clock::now() + 3s);
api::MotorCommandResponse reenable_response;
const auto reenable_status = stub_->setEnabled(
&reenable_context, reenable_request, &reenable_response);
ASSERT_TRUE(reenable_status.ok()) << reenable_status.error_message();
EXPECT_TRUE(reenable_response.header().success())
<< reenable_response.header().error_message();
EXPECT_EQ(plc_.commandCount(), commands_before_disconnect + 1U);
EXPECT_FALSE(reenable_response.status().emergency_stopped());
}
TEST_F(MotorServiceModbusE2eTest,
ActiveCyclicStreamFailsClosedAcrossReconnectAndNewStreamRecovers)
{
grpc::ClientContext old_context;
old_context.set_deadline(std::chrono::system_clock::now() + 8s);
auto old_stream = stub_->streamCyclicPosition(&old_context);
ASSERT_NE(old_stream, nullptr);
api::CyclicPositionRequest open_request;
*open_request.mutable_open()->mutable_target() = makeTarget_();
open_request.mutable_open()->set_watchdog_timeout_ms(1000);
ASSERT_TRUE(old_stream->Write(open_request));
api::CyclicControlResponse response;
ASSERT_TRUE(old_stream->Read(&response));
ASSERT_TRUE(response.header().success())
<< response.header().error_message();
ASSERT_EQ(response.phase(), api::CYCLIC_STREAM_OPENED);
api::CyclicPositionRequest first_sample;
first_sample.mutable_setpoint()->set_sequence(1);
first_sample.mutable_setpoint()->set_target_position_rad(0.1);
first_sample.mutable_setpoint()->set_target_velocity_rad_s(0.0);
ASSERT_TRUE(old_stream->Write(first_sample));
response.Clear();
ASSERT_TRUE(old_stream->Read(&response));
ASSERT_TRUE(response.header().success())
<< response.header().error_message();
ASSERT_EQ(response.phase(), api::CYCLIC_STREAM_APPLIED);
ASSERT_EQ(plc_.cyclicOpenCount(), 1U);
ASSERT_EQ(plc_.cyclicSampleCount(), 1U);
const auto accepted_sessions_before = plc_.acceptedSessionCount();
plc_.disconnectClient();
ASSERT_TRUE(waitUntil(
[&] {
return plc_.acceptedSessionCount() >
accepted_sessions_before;
},
3s));
const auto opens_after_reconnect = plc_.cyclicOpenCount();
const auto samples_after_reconnect = plc_.cyclicSampleCount();
const auto stops_after_reconnect = plc_.quickStopCount();
api::CyclicPositionRequest stale_sample;
stale_sample.mutable_setpoint()->set_sequence(2);
stale_sample.mutable_setpoint()->set_target_position_rad(0.2);
stale_sample.mutable_setpoint()->set_target_velocity_rad_s(0.0);
ASSERT_TRUE(old_stream->Write(stale_sample));
response.Clear();
ASSERT_TRUE(old_stream->Read(&response));
EXPECT_FALSE(response.header().success());
EXPECT_EQ(response.phase(), api::CYCLIC_STREAM_FAILED);
EXPECT_NE(response.header().error_message().find(
"rejected cyclic position setpoint"),
std::string::npos);
EXPECT_FALSE(old_stream->Read(&response));
const auto old_finish = old_stream->Finish();
EXPECT_TRUE(
old_finish.error_code() == grpc::StatusCode::FAILED_PRECONDITION ||
old_finish.error_code() == grpc::StatusCode::CANCELLED)
<< old_finish.error_message();
// Stream cleanup may commit a safety QuickStop, but the stale generation
// must never commit an Open or sample into the new PLC session.
EXPECT_EQ(plc_.cyclicOpenCount(), opens_after_reconnect);
EXPECT_EQ(plc_.cyclicSampleCount(), samples_after_reconnect);
EXPECT_GT(plc_.quickStopCount(), stops_after_reconnect);
grpc::ClientContext new_context;
new_context.set_deadline(std::chrono::system_clock::now() + 5s);
auto new_stream = stub_->streamCyclicPosition(&new_context);
ASSERT_NE(new_stream, nullptr);
ASSERT_TRUE(new_stream->Write(open_request));
response.Clear();
ASSERT_TRUE(new_stream->Read(&response));
ASSERT_TRUE(response.header().success())
<< response.header().error_message();
ASSERT_EQ(response.phase(), api::CYCLIC_STREAM_OPENED);
api::CyclicPositionRequest fresh_sample;
fresh_sample.mutable_setpoint()->set_sequence(1);
fresh_sample.mutable_setpoint()->set_target_position_rad(0.3);
fresh_sample.mutable_setpoint()->set_target_velocity_rad_s(0.0);
ASSERT_TRUE(new_stream->Write(fresh_sample));
response.Clear();
ASSERT_TRUE(new_stream->Read(&response));
ASSERT_TRUE(response.header().success())
<< response.header().error_message();
EXPECT_EQ(response.phase(), api::CYCLIC_STREAM_APPLIED);
EXPECT_EQ(plc_.cyclicOpenCount(), opens_after_reconnect + 1U);
EXPECT_EQ(plc_.cyclicSampleCount(), samples_after_reconnect + 1U);
ASSERT_TRUE(new_stream->WritesDone());
response.Clear();
ASSERT_TRUE(new_stream->Read(&response));
EXPECT_TRUE(response.header().success())
<< response.header().error_message();
EXPECT_EQ(response.phase(), api::CYCLIC_STREAM_STOPPED);
EXPECT_FALSE(new_stream->Read(&response));
const auto new_finish = new_stream->Finish();
EXPECT_TRUE(new_finish.ok()) << new_finish.error_message();
}
} // namespace
} // namespace cmvr::service

File diff suppressed because it is too large Load Diff

View File

@ -55,6 +55,7 @@ private:
std::unique_ptr<grpc::Service> dexhand_service_;
std::unique_ptr<grpc::Service> biohand_service_;
std::unique_ptr<grpc::Service> arm_service_;
std::unique_ptr<grpc::Service> motor_service_;
std::unique_ptr<grpc::Service> agv_service_;
std::unique_ptr<grpc::Service> hlc_service_;
};

View File

@ -15,6 +15,7 @@
#include "service/grpc/include/grpc_head_service.h"
#include "service/grpc/include/grpc_hlc_service.h"
#include "service/grpc/include/grpc_microphone_service.h"
#include "service/grpc/include/grpc_motor_service.h"
#include "service/grpc/include/grpc_speaker_service.h"
#include "service/grpc/include/grpc_system_service.h"
#include "task/task_factory.h"
@ -105,6 +106,7 @@ bool GrpcServerTask::start()
dexhand_service_ = std::make_unique<service::gRPCDexHandServiceImpl>();
biohand_service_ = std::make_unique<service::gRPCMBioHeadServiceImpl>();
arm_service_ = std::make_unique<service::gRPCArmServiceImpl>();
motor_service_ = std::make_unique<service::gRPCMotorServiceImpl>();
agv_service_ = std::make_unique<service::gRPCAgvServiceImpl>();
hlc_service_ = std::make_unique<service::gRPCHlcServiceImpl>();
@ -117,6 +119,7 @@ bool GrpcServerTask::start()
builder.RegisterService(dexhand_service_.get());
builder.RegisterService(biohand_service_.get());
builder.RegisterService(arm_service_.get());
builder.RegisterService(motor_service_.get());
builder.RegisterService(agv_service_.get());
builder.RegisterService(hlc_service_.get());
@ -254,6 +257,7 @@ void GrpcServerTask::clearServices()
{
hlc_service_.reset();
agv_service_.reset();
motor_service_.reset();
arm_service_.reset();
biohand_service_.reset();
dexhand_service_.reset();

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,151 @@
syntax = "proto3";
package cmvr.api;
import "cmvr/api/common.proto";
import "cmvr/msgs/motor.proto";
// Selects exactly one motor inside the MotorManager named by header.device_id.
message MotorTarget {
CommandHeader.Request header = 1;
oneof selector {
uint32 motor_id = 2;
string joint_name = 3;
}
}
message MotorWaitOptions {
// Zero selects the server default (30 seconds).
uint32 timeout_ms = 1;
// Zero selects the server default (10 milliseconds).
uint32 poll_period_ms = 2;
// Zero selects the server default.
double position_tolerance_rad = 3;
double velocity_tolerance_rad_s = 4;
// Number of consecutive in-tolerance samples. Zero selects the default (3).
uint32 settle_sample_count = 5;
}
enum MotorControlType {
MOTOR_CONTROL_NONE = 0;
MOTOR_CONTROL_SET_ZERO = 1;
MOTOR_CONTROL_PROFILE_POSITION = 2;
MOTOR_CONTROL_PROFILE_VELOCITY = 3;
MOTOR_CONTROL_CYCLIC_POSITION = 4;
MOTOR_CONTROL_CYCLIC_VELOCITY = 5;
MOTOR_CONTROL_SET_ENABLED = 6;
}
message MotorStatus {
uint32 motor_id = 1;
string joint_name = 2;
cmvr.msgs.RunMode run_mode = 3;
double position_rad = 4;
double velocity_rad_s = 5;
bool target_reached = 6;
bool service_busy = 7;
MotorControlType active_control = 8;
bool emergency_stopped = 9;
string last_error = 10;
}
message MotorCommandResponse {
CommandHeader.Feedback header = 1;
MotorStatus status = 2;
uint64 elapsed_ms = 3;
}
message SetMotorZeroRequest {
MotorTarget target = 1;
}
message MoveMotorToZeroRequest {
MotorTarget target = 1;
double max_velocity_rad_s = 2;
double acceleration_rad_s2 = 3;
MotorWaitOptions wait = 4;
}
message ProfilePositionRequest {
MotorTarget target = 1;
double target_position_rad = 2;
double max_velocity_rad_s = 3;
double acceleration_rad_s2 = 4;
MotorWaitOptions wait = 5;
}
message ProfileVelocityRequest {
MotorTarget target = 1;
double target_velocity_rad_s = 2;
double acceleration_rad_s2 = 3;
MotorWaitOptions wait = 4;
}
message EmergencyStopRequest {
MotorTarget target = 1;
}
message GetMotorStatusRequest {
MotorTarget target = 1;
}
message GetMotorStatusResponse {
CommandHeader.Feedback header = 1;
MotorStatus status = 2;
}
message SetMotorEnabledRequest {
MotorTarget target = 1;
bool enabled = 2;
}
message CyclicStreamOpen {
MotorTarget target = 1;
// The PLC/driver watchdog is authoritative. This service watchdog prevents a
// stalled gRPC client from retaining control indefinitely.
uint32 watchdog_timeout_ms = 2;
}
message CyclicPositionSetpoint {
uint64 sequence = 1;
double target_position_rad = 2;
optional double target_velocity_rad_s = 3;
}
message CyclicVelocitySetpoint {
uint64 sequence = 1;
double target_velocity_rad_s = 2;
}
message CyclicPositionRequest {
oneof payload {
CyclicStreamOpen open = 1;
CyclicPositionSetpoint setpoint = 2;
}
}
message CyclicVelocityRequest {
oneof payload {
CyclicStreamOpen open = 1;
CyclicVelocitySetpoint setpoint = 2;
}
}
enum CyclicStreamPhase {
CYCLIC_STREAM_PHASE_UNSPECIFIED = 0;
CYCLIC_STREAM_OPENED = 1;
CYCLIC_STREAM_APPLIED = 2;
CYCLIC_STREAM_STOPPED = 3;
CYCLIC_STREAM_WATCHDOG_EXPIRED = 4;
CYCLIC_STREAM_FAILED = 5;
}
message CyclicControlResponse {
CommandHeader.Feedback header = 1;
CyclicStreamPhase phase = 2;
uint64 sequence = 3;
uint64 dropped_setpoints = 4;
// Present for OPENED and terminal responses. APPLIED deliberately omits
// live status so one cyclic sample does not trigger extra fieldbus reads.
MotorStatus status = 5;
}

View File

@ -0,0 +1,21 @@
syntax = "proto3";
package cmvr.api;
import "cmvr/api/motor_command.proto";
service MotorService {
rpc setZero(SetMotorZeroRequest) returns (MotorCommandResponse);
rpc moveToZero(MoveMotorToZeroRequest) returns (MotorCommandResponse);
rpc profilePosition(ProfilePositionRequest) returns (MotorCommandResponse);
rpc profileVelocity(ProfileVelocityRequest) returns (MotorCommandResponse);
rpc streamCyclicPosition(stream CyclicPositionRequest)
returns (stream CyclicControlResponse);
rpc streamCyclicVelocity(stream CyclicVelocityRequest)
returns (stream CyclicControlResponse);
rpc emergencyStop(EmergencyStopRequest) returns (MotorCommandResponse);
rpc getStatus(GetMotorStatusRequest) returns (GetMotorStatusResponse);
rpc setEnabled(SetMotorEnabledRequest) returns (MotorCommandResponse);
}

View File

@ -61,11 +61,35 @@ message MujocoMotorGroupConfig {
string world_id = 1;
}
message ModbusTcpAxisConfig {
int32 motor_id = 1;
uint32 axis_index = 2;
}
message ModbusTcpConfig {
string host = 1;
uint32 port = 2;
uint32 unit_id = 3;
uint32 connect_timeout_ms = 4;
uint32 io_timeout_ms = 5;
uint32 heartbeat_period_ms = 6;
uint32 communication_watchdog_ms = 7;
uint32 status_poll_period_ms = 8;
uint32 reconnect_min_ms = 9;
uint32 reconnect_max_ms = 10;
uint32 command_ack_timeout_ms = 11;
uint32 protocol_major = 12;
uint32 protocol_minor = 13;
uint32 cyclic_watchdog_ms = 14;
repeated ModbusTcpAxisConfig axes = 20;
}
enum MotorBusType {
MOTOR_BUS_UNKNOWN = 0;
MOTOR_BUS_CAN = 1;
MOTOR_BUS_ETHERCAT = 2;
MOTOR_BUS_MUJOCO = 3;
MOTOR_BUS_MODBUS_TCP = 4;
}
enum MotorVendor {
@ -73,6 +97,7 @@ enum MotorVendor {
MOTOR_VENDOR_TI5 = 1;
MOTOR_VENDOR_MUJOCO = 2;
MOTOR_VENDOR_EYOU = 3;
MOTOR_VENDOR_PLC_GENERIC = 4;
}
enum MotorProtocol {
@ -80,6 +105,7 @@ enum MotorProtocol {
MOTOR_PROTOCOL_CANOPEN = 1;
MOTOR_PROTOCOL_ETHERCAT_CIA402 = 2;
MOTOR_PROTOCOL_MUJOCO = 3;
MOTOR_PROTOCOL_CMVR_PLC_V1 = 4;
}
message MotorGroupConfig {
@ -92,6 +118,7 @@ message MotorGroupConfig {
SocketCanConfig can = 10;
EtherCATConfig ethercat = 11;
MujocoMotorGroupConfig mujoco = 12;
ModbusTcpConfig modbus_tcp = 13;
}
JointLimitsConfig joint_limits = 30;