diff --git a/.gitignore b/.gitignore index 52a9aa9..3ebdbd1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,14 @@ # Build artifacts build/ +build-*/ generated/ +*.o +*.a +*.csv +*.bin +compile_commands.json +.cache/ +Testing/ # IDE .vscode/ diff --git a/CMakeLists.txt b/CMakeLists.txt index 78c3f46..209556a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,112 +1,181 @@ -cmake_minimum_required(VERSION 3.16) -project(litearm-cpp VERSION 0.1.0 LANGUAGES CXX) +cmake_minimum_required(VERSION 3.14) +project(litearm-cpp VERSION 2.1.0 LANGUAGES CXX) + +# --------------------------------------------------------------------------- +# LiteArm STM32 直连后端 —— 薄协议 SDK (C++17, 零外部依赖) +# +# 唯一依赖是 C++ 标准库与平台自带的串口 API (POSIX termios / Win32) —— 刻意不引 +# libserialport 之类: PC 侧只做帧编解码, 重型计算 (S 曲线/IK/动力学) 本来就在固件里。 +# --------------------------------------------------------------------------- set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_EXPORT_COMPILE_COMMANDS ON) - -# ── Protobuf ────────────────────────────────────────────────────────────────── -# Requires protobuf >= 3.19. A system install is found automatically. -# For a custom install prefix (e.g. a conda env), pass: -# cmake .. -DPROTOBUF_ROOT=$CONDA_PREFIX -set(PROTOBUF_ROOT "" CACHE PATH "Protobuf install prefix (optional)") -if(PROTOBUF_ROOT) - list(PREPEND CMAKE_PREFIX_PATH "${PROTOBUF_ROOT}") -endif() +set(CMAKE_CXX_EXTENSIONS OFF) -find_path(PROTOBUF_INCLUDE_DIR google/protobuf/message.h - HINTS "${PROTOBUF_ROOT}/include" - REQUIRED) - -find_library(PROTOBUF_LIBRARY protobuf - HINTS "${PROTOBUF_ROOT}/lib" - REQUIRED) - -find_program(PROTOC_EXECUTABLE protoc - HINTS "${PROTOBUF_ROOT}/bin" - REQUIRED) - -message(STATUS "Protobuf include: ${PROTOBUF_INCLUDE_DIR}") -message(STATUS "Protobuf library: ${PROTOBUF_LIBRARY}") -message(STATUS "Protoc: ${PROTOC_EXECUTABLE}") - -# ── Proto generation ────────────────────────────────────────────────────────── -set(PROTO_FILE "${CMAKE_SOURCE_DIR}/proto/litearm.proto") -set(PROTO_GEN_DIR "${CMAKE_SOURCE_DIR}/generated") - -add_custom_command( - OUTPUT "${PROTO_GEN_DIR}/litearm.pb.cc" "${PROTO_GEN_DIR}/litearm.pb.h" - COMMAND ${CMAKE_COMMAND} -E make_directory "${PROTO_GEN_DIR}" - COMMAND ${PROTOC_EXECUTABLE} - --cpp_out="${PROTO_GEN_DIR}" - --proto_path="${CMAKE_SOURCE_DIR}/proto" - "${PROTO_FILE}" - DEPENDS "${PROTO_FILE}" - COMMENT "Generating protobuf C++ code from litearm.proto" -) +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) +endif() -add_custom_target(proto_gen DEPENDS - "${PROTO_GEN_DIR}/litearm.pb.cc" - "${PROTO_GEN_DIR}/litearm.pb.h" -) +option(LITEARM_BUILD_EXAMPLES "构建 examples/" ON) +option(LITEARM_BUILD_TESTS "构建 tests/" ON) +option(LITEARM_WERROR "把编译警告当错误" OFF) -# ── Main library ────────────────────────────────────────────────────────────── add_library(litearm - src/exceptions.cpp - src/types.cpp - src/codec.cpp + src/protocol.cpp + src/clock.cpp + src/framing.cpp + src/errors.cpp + src/rot.cpp + src/state.cpp src/transport.cpp + src/ack.cpp + src/params.cpp + src/model.cpp + src/log.cpp + src/diagnostics.cpp + src/cart.cpp src/arm.cpp - src/device.cpp - generated/litearm.pb.cc + src/testing.cpp ) +add_library(litearm::litearm ALIAS litearm) target_include_directories(litearm PUBLIC - $ - $ - $ + $ $ ) -target_link_libraries(litearm PUBLIC ${PROTOBUF_LIBRARY}) -target_compile_options(litearm PRIVATE -Wall -Wextra -Wpedantic) - -add_dependencies(litearm proto_gen) +# POSIX 下用 termios/poll/flock; Windows 下用 Win32 串口 API。 +if(WIN32) + target_link_libraries(litearm PUBLIC setupapi) +else() + find_package(Threads REQUIRED) + target_link_libraries(litearm PUBLIC Threads::Threads) +endif() -# ── Tests ───────────────────────────────────────────────────────────────────── -# GoogleTest is downloaded automatically on first configure. -# For offline builds, point to a local source tree: -# cmake .. -DGOOGLETEST_ROOT=/usr/src/googletest -include(FetchContent) -set(GOOGLETEST_ROOT "" CACHE PATH "Local GoogleTest source dir (optional)") -if(GOOGLETEST_ROOT) - FetchContent_Declare(googletest SOURCE_DIR ${GOOGLETEST_ROOT}) +if(MSVC) + target_compile_options(litearm PRIVATE /W4) else() - FetchContent_Declare( - googletest - GIT_REPOSITORY https://github.com/google/googletest.git - GIT_TAG v1.14.0 - ) + target_compile_options(litearm PRIVATE -Wall -Wextra) + if(LITEARM_WERROR) + target_compile_options(litearm PRIVATE -Werror) + endif() endif() -set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) -FetchContent_MakeAvailable(googletest) - -enable_testing() - -function(add_litearm_test test_name test_source) - add_executable(${test_name} ${test_source}) - target_link_libraries(${test_name} PRIVATE litearm gtest gtest_main) - target_include_directories(${test_name} PRIVATE - ${CMAKE_SOURCE_DIR}/include - ${PROTOBUF_INCLUDE_DIR} - ${PROTO_GEN_DIR} - ) - add_test(NAME ${test_name} COMMAND ${test_name}) -endfunction() - -add_litearm_test(test_protocol tests/test_protocol.cpp) -add_litearm_test(test_codec tests/test_codec.cpp) -add_litearm_test(test_transport tests/test_transport.cpp) -add_litearm_test(test_arm tests/test_arm.cpp) -add_litearm_test(test_arm_direct tests/test_arm_direct.cpp) -add_litearm_test(test_device tests/test_device.cpp) + +# --------------------------------------------------------------------------- +# examples +# --------------------------------------------------------------------------- +if(LITEARM_BUILD_EXAMPLES) + file(GLOB LITEARM_EXAMPLE_SOURCES CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/examples/*.cpp") + list(REMOVE_ITEM LITEARM_EXAMPLE_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/examples/_common.cpp") + add_library(litearm_examples_common STATIC examples/_common.cpp) + target_link_libraries(litearm_examples_common PUBLIC litearm) + foreach(src ${LITEARM_EXAMPLE_SOURCES}) + get_filename_component(name ${src} NAME_WE) + add_executable(${name} ${src}) + target_link_libraries(${name} PRIVATE litearm_examples_common) + endforeach() +endif() + +# --------------------------------------------------------------------------- +# tests +# --------------------------------------------------------------------------- +# ⚠⚠ 交叉编译时**不许**开测试。 +# 注意本仓的测试**不依赖 gtest** ⇒ 它们**编得过** —— 问题在**跑**: ctest 会去执行 +# aarch64 二进制, 而本机跑不了 ⇒ 一整排 Failed, 报错是 "Exec format error" 这种 +# 离"你交叉编译了"很远的消息。明确拦下比留一堆跑不了的 target 好。 +# ⚠ toolchain 文件里已经 FORCE 成 OFF; 这里再守一道, 是因为那条约束只活在一个 +# 将来可能被改的文件里, 而失败现场在这里 (走本仓的 toolchain 时它其实求值不到 —— +# 它守的是别的交叉配置: 手动设 CMAKE_SYSTEM_NAME、或不含 FORCE 的 toolchain)。 +if(CMAKE_CROSSCOMPILING AND LITEARM_BUILD_TESTS) + message(FATAL_ERROR + "交叉编译时不允许 LITEARM_BUILD_TESTS=ON: 测试二进制在构建机上跑不了, " + "ctest 会整排失败。请加 -DLITEARM_BUILD_TESTS=OFF, 或配置 " + "CROSSCOMPILING_EMULATOR (如 qemu-aarch64) 后自行放开本守卫。") +endif() + +if(LITEARM_BUILD_TESTS) + enable_testing() + add_library(litearm_test_support STATIC tests/test_framework.cpp) + target_include_directories(litearm_test_support PUBLIC + ${CMAKE_CURRENT_SOURCE_DIR}/tests) + target_link_libraries(litearm_test_support PUBLIC litearm) + + file(GLOB LITEARM_TEST_SOURCES CONFIGURE_DEPENDS + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_*.cpp") + # test_framework.cpp 是框架自身 (它提供 main), 已经被编进 litearm_test_support —— + # 别再把它当成一个"用例文件"注册成一个 0 用例的 ctest 条目。 + list(REMOVE_ITEM LITEARM_TEST_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_framework.cpp") + foreach(src ${LITEARM_TEST_SOURCES}) + get_filename_component(name ${src} NAME_WE) + add_executable(${name} ${src}) + target_link_libraries(${name} PRIVATE litearm_test_support) + add_test(NAME ${name} COMMAND ${name}) + # 有用例 SKIP 时框架返回 77 —— 让 ctest 报 `***Skipped` 而不是 "Passed"。 + # ⚠ 这不是洁癖: ctest 默认**吞掉通过用例的全部输出**, 于是"11 个用例被跳过"在 + # 报告里长得和"全跑过了"一模一样 (实测)。假绿比没测更糟。 + set_tests_properties(${name} PROPERTIES SKIP_RETURN_CODE 77) + endforeach() +endif() + +# --------------------------------------------------------------------------- +# install / 打包 +# --------------------------------------------------------------------------- +# 客户两种用法都支持: +# find_package(litearm CONFIG REQUIRED) +# target_link_libraries(my_app PRIVATE litearm::litearm) +# —— 或 —— +# pkg-config --cflags --libs litearm +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +# ⚠ 只对**共享**产物有意义; 静态库上设它无害。 +# SOVERSION 取主版本号 ⇒ 同一主版本内承诺 ABI 兼容 (这是本仓的**承诺**, 不是默认值)。 +set_target_properties(litearm PROPERTIES + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR}) + +install(TARGETS litearm + EXPORT litearmTargets + ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + # ⚠ INCLUDES DESTINATION 让导出的 target 自带 include 路径 —— 少了它, 客户 + # `find_package` 成功、`target_link_libraries` 也成功, 但**编译时找不到头**, + # 报错指向 "litearm/litearm.hpp: No such file" 而完全看不出是打包漏了一行。 + INCLUDES DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +# ⚠ 装的是**整个目录** (`include/litearm`), 不是逐个头文件列的清单 —— 目录形式会自动 +# 跟上新增的头 (本仓头文件加得频繁: clock.hpp / framing.hpp 都是后加的)。 +install(DIRECTORY include/litearm DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) + +# --- CMake 包配置 --- +install(EXPORT litearmTargets + FILE litearmTargets.cmake + NAMESPACE litearm:: + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/litearm) + +configure_package_config_file( + cmake/litearmConfig.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/litearmConfig.cmake + INSTALL_DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/litearm) + +# ⚠ `SameMajorVersion`: 主版本相同的请求都算兼容 (与上面的 SOVERSION 承诺一致)。 +# 换成 `ExactVersion` 会让客户在 2.1.1 上必须写死 2.1.1 —— 对一个小版本修 bug 的 +# 库来说是自找麻烦。 +write_basic_package_version_file( + ${CMAKE_CURRENT_BINARY_DIR}/litearmConfigVersion.cmake + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion) + +install(FILES + ${CMAKE_CURRENT_BINARY_DIR}/litearmConfig.cmake + ${CMAKE_CURRENT_BINARY_DIR}/litearmConfigVersion.cmake + DESTINATION ${CMAKE_INSTALL_LIBDIR}/cmake/litearm) + +# --- pkg-config --- +configure_file(cmake/litearm.pc.in ${CMAKE_CURRENT_BINARY_DIR}/litearm.pc @ONLY) +install(FILES ${CMAKE_CURRENT_BINARY_DIR}/litearm.pc + DESTINATION ${CMAKE_INSTALL_LIBDIR}/pkgconfig) diff --git a/README.md b/README.md index 8a8ad4e..7d6f857 100644 --- a/README.md +++ b/README.md @@ -1,286 +1,816 @@ # litearm-cpp -C++17 client SDK for the LiteArm robotic arm. Connect to the arm control service -over the network and control the arm from any machine — ideal for low-latency, -embedded use. +A **C++ SDK** for the LiteArm robotic arm — it talks **directly to the firmware over a USB +serial link**. No server, no middleware. Trajectory planning, kinematics and dynamics all live +in the firmware; the host only sends waypoints and reads results back. + +This repository is the C++ port of +[litearm-python](https://github.com/nexform-tech/litearm-python) 2.1.0: the same wire +protocol, the same semantics, the same safety criteria, ported line by line. The test suite +is likewise fully offline. + +> Full API reference: [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md); +> field troubleshooting (symptom → cause → fix): [TROUBLESHOOTING.md](TROUBLESHOOTING.md). + +## Highlights + +- **Zero external dependencies**: only the C++17 standard library and the platform's own + serial API (POSIX termios / Win32). No libserialport, no numpy — the heavy computation + already lives in the firmware. +- **One location**: headers in `include/litearm/`, a single static library `liblitearm.a`. +- **Direct USB**: one cable to the firmware; VID:PID `1d50:606f` is discovered automatically. +- **The firmware does the heavy lifting**: planning, kinematics and dynamics all live there; + the host just sends waypoints and judges arrival. +- **Complete motion API**: joint motion, Cartesian lines/arcs/multi-waypoint paths, freedrive. +- **Safety built in**: forked children fail closed, an independent emergency-stop path, and + every irreversible command is called out. + +## Building + +| Item | Requirement | +| --- | --- | +| Compiler | C++17 (g++ 7+ / clang 6+ / MSVC 2019+) | +| Build | CMake ≥ 3.14 | +| Dependencies | none (termios/poll/flock on POSIX, the Win32 serial API on Windows) | +| Firmware | `Litearm1.5.0` or newer | +| Connection | USB CDC serial, VID:PID `1d50:606f` | -Mirrors the API of [litearm-python](../litearm-python) for seamless migration -between languages. - -> 📖 Full developer guide & API reference: [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md). +```bash +./build.sh # configure + build + run tests (fully offline) +source env.sh # put build/ on PATH (builds on first use) +./run_example.sh 01_hello +``` -## Requirements +Manual build: -- C++17 compiler (GCC 8+, Clang 7+) -- CMake 3.16+ -- Protobuf 3.19+ (headers, library, and the `protoc` compiler) -- Google Test (for tests; downloaded automatically on first configure) +```bash +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j +ctest --test-dir build --output-on-failure +``` -## Install protobuf +CMake options: `-DLITEARM_BUILD_EXAMPLES=OFF` / `-DLITEARM_BUILD_TESTS=OFF` / +`-DLITEARM_WERROR=ON`. -`litearm-cpp` needs protobuf ≥ 3.19. Check what you already have: +On Linux you need serial port permission: ```bash -protoc --version # should print "libprotoc 3.19" or newer +sudo usermod -aG dialout $USER # takes effect after you log in again ``` -If it is missing or too old, pick one of: +To consume it as a subdirectory: + +```cmake +add_subdirectory(third_party/litearm-cpp) +target_link_libraries(your_app PRIVATE litearm::litearm) +``` -**Debian / Ubuntu** (protobuf 3.21 on 24.04+): +### Installing and packaging ```bash -sudo apt update -sudo apt install -y protobuf-compiler libprotobuf-dev +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local +cmake --build build -j && cmake --install build ``` -**Conda** (any OS): +What gets installed (all three consumption styles have been verified — see "How this was +verified" below): -```bash -conda install -c conda-forge protobuf=3.19.6 +``` +include/litearm/*.hpp # every public header (including testing.hpp / framing.hpp) +lib/liblitearm.a # or .so (add -DBUILD_SHARED_LIBS=ON) +lib/cmake/litearm/litearmConfig.cmake +lib/cmake/litearm/litearmConfigVersion.cmake +lib/cmake/litearm/litearmTargets.cmake +lib/pkgconfig/litearm.pc ``` -**From source** (any OS, no package manager needed): +**Consumption style 1 — CMake**: -```bash -cd /tmp -curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v3.19.6/protobuf-cpp-3.19.6.tar.gz -tar xzf protobuf-cpp-3.19.6.tar.gz && cd protobuf-3.19.6 -./configure && make -j$(nproc) && sudo make install -sudo ldconfig +```cmake +find_package(litearm CONFIG REQUIRED) +target_link_libraries(my_app PRIVATE litearm::litearm) ``` -Verify afterwards: +**Consumption style 2 — pkg-config**: ```bash -protoc --version +g++ my_app.cpp $(pkg-config --cflags --libs litearm) +# static linking needs --static (it pulls in -lpthread from Libs.private) ``` -## Build +**Consumption style 3 — subdirectory** (`add_subdirectory`, see above). + +⚠ The umbrella header `litearm.hpp` **deliberately omits** `testing.hpp` and the transport +implementation details — to use the fakes, include `` yourself. + +#### How this was verified (don't stop at "it installed") + +Install it for real into a temporary prefix, then write a **real consumer** and build and run +it with `find_package` and with `pkg-config`: ```bash -mkdir build && cd build -cmake .. # a system-installed protobuf is found automatically -cmake --build . -j$(nproc) +cmake -S . -B build-inst -DCMAKE_INSTALL_PREFIX=/tmp/litearm-prefix \ + -DLITEARM_BUILD_TESTS=OFF -DLITEARM_BUILD_EXAMPLES=OFF +cmake --build build-inst -j && cmake --install build-inst +# then: cmake consumer with find_package(litearm CONFIG REQUIRED) -> builds -> runs +# pkg-config consumer with g++ main.cpp $(pkg-config --cflags --libs litearm) -> builds -> runs ``` -If protobuf lives in a custom prefix (e.g. an activated conda env), tell CMake -where it is: +`tests/test_packaging.cpp` additionally guards the classes of problem you can catch **before +shipping**: that the version has exactly one source of truth (CMakeLists ↔ the macros in the +header ↔ `version()`), that every sibling header referenced by a public header actually lives +in `include/litearm/`, that both packaging templates are present, and that the `.pc` version is +not hard-coded. + +#### Cross-compiling for aarch64 ```bash -cmake .. -DPROTOBUF_ROOT=$CONDA_PREFIX +cmake -S . -B build-arm64 -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-aarch64-linux-gnu.cmake +cmake --build build-arm64 -j ``` -## Test +⚠⚠ **This repository has never been run on aarch64** — only the "does it compile" layer has +been attempted, and **even that layer was not verified on this machine** (no +`aarch64-linux-gnu-g++` installed): the toolchain file was parsed correctly and failed cleanly +at "compiler not found". Alignment, endianness and serial behaviour on arm64 are **unguarded**. +Do not read this section as "arm64 is supported". -```bash -cd build -ctest --output-on-failure -``` - -## Project Structure - -```text -litearm-cpp/ -├── CMakeLists.txt -├── proto/ -│ └── litearm.proto # Interface definition -├── generated/ -│ ├── litearm.pb.h # Generated headers -│ └── litearm.pb.cc # Generated source -├── include/litearm/ -│ ├── arm.hpp # Arm class (main API) -│ ├── codec.hpp # Serialization -│ ├── device.hpp # RemoteDevice / DeviceManager (peripherals) -│ ├── exceptions.hpp # Exception hierarchy -│ ├── protocol.hpp # Naming conventions -│ ├── transport.hpp # Transport abstraction -│ └── types.hpp # Core types (Value, RobotState, etc.) -├── src/ -│ ├── arm.cpp -│ ├── codec.cpp -│ ├── device.cpp -│ ├── exceptions.cpp -│ ├── transport.cpp -│ └── types.cpp -└── tests/ - ├── test_arm.cpp - ├── test_codec.cpp - ├── test_device.cpp - ├── test_protocol.cpp - └── test_transport.cpp -``` - -## Usage +⚠ That toolchain file **forces** `LITEARM_BUILD_TESTS=OFF`: this repository's tests do not +depend on gtest so they *would* compile, but ctest would then execute aarch64 binaries on the +build machine ⇒ a wall of "Exec format error". To actually **run** tests under +cross-compilation, configure `CROSSCOMPILING_EMULATOR` (qemu-aarch64). + +## Quick start ```cpp -#include +#include +using namespace litearm; int main() { - // Connect to the arm control service - auto arm = litearm::Arm("tcp/192.168.1.100:7447", "armA"); + Arm arm; // no port given -> auto-discover (VID:PID 1d50:606f) + arm.connect(); // checks the firmware version convention - // Get current state - auto state = arm.get_state(); - if (state) { - // state->q contains joint positions - } + std::printf("firmware %s, %d joints\n", arm.firmware().c_str(), arm.n()); - // Move to joint target - arm.movej({0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, /*speed=*/0.5); + arm.enable(); // you must enable before any motion - // Home all joints to zero (bypasses joint-limit & self-collision checks) - arm.home(/*speed=*/0.3, /*settle_s=*/0.5); + // Joint motion: the firmware plans an S-curve and finishes on its own + arm.movej({0.1, 0, 0, 0, 0, 0, 0}, 0.3); - // Forward kinematics - auto [pos, rot] = arm.fk({0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}); + // Cartesian line: pose = 3 positions (m) + 3 orientations (rad) + arm.move_l({0.30, 0.0, 0.40, 3.1416, 0.0, 0.0}, 0.5); - // Emergency stop - arm.request_stop(); + // Read state -- note the .value: these return a Msg envelope, see below + auto st = arm.get_state().value; + if (st) std::printf("q = %s\n", fmt(st->q()).c_str()); - arm.close(); - return 0; + auto tcp = arm.get_tcp().value; + if (tcp) std::printf("TCP = %s\n", fmt(*tcp).c_str()); + + // Inverse kinematics: pose -> joint angles + auto q = arm.ik({0.30, 0.0, 0.35, 3.1416, 0.0, 0.0}); + + arm.home(); // return to the zero pose + arm.close(); // disconnect (the destructor also does this as a backstop) } ``` -### Peripheral device usage +`Arm::connect()` is the only entry point. Every session carries a **reader thread**, so you +**must `close()`** — the C++ destructor is a backstop, but closing explicitly is clearer (the +destructor only runs when the object is actually destroyed). With no port given it +auto-discovers; the SDK **reads no environment variables**. + +## API reference + +### Connecting ```cpp -#include +Arm arm; // auto-discover +Arm arm2("/dev/ttyACM0"); // explicit port +arm.connect(); // idempotent: a no-op if already connected to the same target +arm.reconnect(); // force a rebuild (needed after changing move_timeout) +arm.close(); // disconnect (idempotent) +printf("%s / %d\n", arm.firmware().c_str(), arm.n()); +``` -int main() { - auto arm = litearm::Arm("tcp/192.168.1.100:7447", "armA"); +### Joint motion + +All three require `enable()` first: `movej` is a single shot, `movej_sync` is synchronised +PTP, `home` returns to the zero pose. + +```cpp +arm.enable(); +arm.movej({0.1,0,0,0,0,0,0}, 0.3); // independent S-curve per axis (first to arrive stops first) +arm.movej_sync({0.1,0,0,0,0,0,0}); // one path scalar drives every axis; they arrive together (slower but predictable) +arm.home(); // firmware speed is hard-coded to 0.10 +``` + +### Cartesian motion + +```cpp +std::array P1{0.30, 0.0, 0.40, 3.1416, 0.0, 0.0}; +std::array P2{0.32, 0.0, 0.42, 3.1416, 0.0, 0.0}; +std::array P3{0.34, 0.0, 0.44, 3.1416, 0.0, 0.0}; + +CartPlan a = arm.move_l(P2, 0.5); // straight line +CartPlan b = arm.move_p(P2); // joint-space point to point -- NOT a straight line! +CartPlan c = arm.move_path({P1, P2, P3}, 0.5); // passes through in order, sharp corners +CartPlan d = arm.move_c(arm.get_tcp().value.value(), P2, P3); // arc (start must be the measured TCP) + +CartPlan e = arm.move_l(P2, 0.5, /*wait=*/false); // does not block; poll yourself afterwards +auto claimed = arm.poll_cart(); // claim a result nobody took +arm.set_speed(50); // global speed governor: integer percent 0..100 +``` - // Dexterous hand - auto hand = arm.device("hand_0"); // or arm.devices()["hand_0"] - hand.open(); - hand.set_gesture("pinch"); - hand.finger_move({0.1, 0.2, 0.3, 0.4, 0.5, 0.6}); +Poses also accept `(pos[3], R[3x3])` and a 4x4 homogeneous matrix: - // Gripper - auto gripper = arm.device("gripper_0"); - gripper.set_width(0.5); - double w = gripper.get_width(); +```cpp +rot::Vec3 pos{0.32, 0.0, 0.42}; +rot::Mat3 R = rot::rpy_to_mat({3.1416, 0.0, 0.0}); +arm.move_l(rot::PoseInput::from_pos_rot(pos, R), 0.5); +``` - // Teach pendant - auto teach = arm.device("teach_0"); - auto joints = teach.get_joints(); +### State / kinematics - arm.close(); - return 0; +```cpp +auto st = arm.get_state().value; // std::optional +if (st) { + fmt(st->q()); fmt(st->dq()); fmt(st->tau()); + bool en = st->enabled(), busy = st->cart_busy(), bad = st->faulted(); + std::string detail = st->fault_detail(); + std::vector axes = st->fault_axes(); // 0-based } +auto tcp = arm.get_tcp().value; // std::optional> +arm.get_status_now(); // ask for a frame on demand +arm.ik({0.30, 0.0, 0.35, 3.1416, 0.0, 0.0}); // pose -> q[7] +``` + +### Housekeeping / safety + +```cpp +arm.emergency_stop(); // emergency stop: no preconditions at all +arm.reset(); // clear faults + re-anchor (not an MCU reset) +arm.clear_faults(); // clears only the RAM fault bits +arm.disable(); // ⚠ once disabled, the position loop no longer holds the arm +``` + +### Feedforward / dynamics + +```cpp +arm.set_payload(1.0); // **always** call this after changing the payload +arm.set_gravity_scale({1,1,1,1,1,1,1}); +arm.ff_preset(1); // 0 all off / 1 factory / 2 all on +double m = arm.get_ff_scalar(4).value; // read back payload_mass +arm.get_ff_mask(); // ff_mask (0x2C item 9, read-only) +``` + +### Freedrive (zero gravity) + +```cpp +{ + auto zg = arm.zero_g(); // enter + start the keepalive + std::this_thread::sleep_for(std::chrono::seconds(10)); +} // leaving the scope finishes it (does not throw) +// or pair explicitly: arm.zero_g_start(); ...; arm.zero_g_stop(); +``` + +### Joint-level parameters (`arm.params()`) + +```cpp +JointParam p = arm.params().get_joint_param(0).value; // J1's kp/kd/tau_max/soft limits +arm.params().set_joint_param(0, 30.0, 1.0, 20.0); // idx, kp, kd, tau_max +arm.params().set_joint_limits(0, -1.0, 1.0); // can only narrow +for (const auto& jp : arm.params().all_joint_params()) { + printf("J%d kp=%.1f q=[%.3f, %.3f]\n", jp.idx + 1, jp.kp, jp.q_min, jp.q_max); +} +``` + +### Dynamics model (`arm.model()`) + +```cpp +if (arm.model().probe()) { // does the firmware support online import? + for (int i = 1; i <= 7; ++i) arm.model().set_body(i, body10); + arm.model().set_jm({...}); + arm.model().commit(MODEL_MASK_WRITTEN); // 0x2FE; requires the arm to be disabled +} +auto s = arm.model().status().value; // override / staged_mask / dirty +auto g = arm.model().get_gravity({0,0,0,0,0,0,0}).value; // G(q) +``` + +### Data capture (`arm.log()`) + +```cpp +arm.log().start(300); // record 300 ticks then stop by itself +LogReader r = arm.log().reader(); +uint32_t total = r.total(); // bytes recorded so far +auto samples = r.samples(); // parsed tick by tick +// convenience: capture + wait until full + read back +auto s2 = arm.log().capture(600); +arm.log().dump("trace.bin", /*wait=*/true); // raw bytes to disk +``` + +### Firmware self-test (`arm.diag()`) + +```cpp +KinBenchResult kb = arm.diag().kin_bench().value; +printf("crc bad frames %lld, replies dropped %lld, CAN TX failures %lld\n", + kb.crc_errors(), kb.reply_dropped(), kb.can_tx_fail()); ``` -Device methods route to `device.{device_id}.{method}` on the service, so any -end-effector or peripheral behind the arm control service is reachable with the -same `device(id)` handle. +### Licensing / activation -## API Reference +```cpp +LicenseInfo lic = arm.license(); // returns normally even when not activated; does not throw +printf("%s uid=%s\n", lic.state_name().c_str(), lic.uid_hex().c_str()); +arm.disable(); +arm.activate(cust_id, issued, flags, mac16, 16); // requires disabled; the MAC is issued by the vendor +``` + +### Persistence + +```cpp +arm.disable(); +arm.save_params(); // ⚠ writes flash, irreversible +``` + +### Read-only properties + +```cpp +arm.n(); // number of joints +arm.firmware(); // version string, e.g. "Litearm1.8.0-7J" (from the 0x41 handshake; authoritative) +arm.fw_version(); // std::optional +arm.cart_supported(); // whether the firmware was built with LITEARM_CART_PLAN +arm.last_reset_reason(); // "normal" / "iwdg-rst" / "" (no boot signature seen) +arm.zero_g_active(); // whether the keepalive is still running +arm.port_string(); // the port **actually** in use (falls back to the ctor argument when disconnected) +``` + +Diagnostic additions (**all of them diverge from the Python reference**, which has none of +them; all are pure local snapshots that **do not throw even in the terminal state** — the +moment you need them most is exactly when the link is already dead): + +```cpp +arm.is_connected(); // reader thread alive **and** the status frame is not stale (both conditions required) +arm.is_in_dfu(); // whether the DFU terminal state was entered +arm.status_seq(); // running count of successfully decoded status frames +arm.msg_hz(0x40); // mean arrival rate of that uplink frame type (Hz; the denominator is the actual arrival interval) +arm.banner_version(); // std::optional -- version string from the boot banner (the banner is sent once at boot) +arm.host_stats(); // host-side counter snapshot, see `struct HostStats` +arm.options(); // the options in effect (**current values**, not a construction-time snapshot) +``` + +### Tunable knobs + +```cpp +arm.q_tol = 0.03; // arrival criterion: joint angle tolerance (rad) +arm.dq_tol = 0.10; // arrival criterion: joint velocity tolerance (rad/s) +arm.arrive_frames = 3; // consecutive at-rest frames +arm.move_timeout = 15.0; // motion wait window (seconds) -- ⚠ changing it requires reconnect() +``` + +## The `Msg` return envelope + +Eleven "read one frame" getters return `Msg { value, hz, timestamp }`: + +| Field | Meaning | +| --- | --- | +| `value` | the underlying return value. Entries that can fail to get a frame (`get_state` / `get_tcp`) express that as `std::nullopt` | +| `hz` | **mean arrival rate** of that frame type in this session (measured from the first arrival onward; does not decay while the link is idle) | +| `timestamp` | local monotonic time of the **most recent** such frame; `0.0` if none has ever arrived | + +The eleven that are wrapped: `get_state` / `get_status_now` / `get_tcp` / `get_ff_vec` / +`get_ff_scalar` / `params().get_joint_param` / `model().get_body` / `model().get_jm` / +`model().status` / `model().get_gravity` / `diag().kin_bench`. + +**Deliberately not wrapped**: `move_*` / `home` (they are **action results**, not "read one +frame"); purely local quantities (`n()` / `firmware()` / `last_reset_reason()`); `license()` +(there is no firmware-initiated traffic — `hz` there would only ever be the caller's own +polling rate); and the two derived getters (`get_ff_mask` / `all_joint_params`, which are +aggregates or scalar projections). + +Two cases where `hz == 0.0`: (1) that frame type has **never arrived** (then `timestamp` is +also 0); (2) exactly one frame has arrived — a single sample cannot define a rate. For +single-request/response entries (`get_joint_param` / `get_body` / `get_jm` / `get_gravity`) +the **first call necessarily reports `hz == 0.0`**, and from the second call onward it equals +**the caller's own polling rate**, not any firmware period. + +## Command line + +```bash +./01_hello # equivalent to the CLI's status +./04_ik_tcp +./02_movej --go 0.1 0 -0.1 0 0 0 0 +``` + +The SDK also ships a small CLI (`litearm::main_impl`) for walk-up checks: + +```bash +litearm-cpp status # read-only +litearm-cpp fw # version string + joint count +litearm-cpp tcp # current pose + frame rate +litearm-cpp movej -0.1 0 0 0 0 0 0 --speed 0.3 +litearm-cpp home +``` -The `litearm::Arm` class provides these method groups: +## Caveats -### Computation (no hardware) +### Multi-process: a forked child must not reuse the parent's `Arm` -- `fk(q)` — Forward kinematics -- `ik(pos_d, R_d, q_seed)` — Inverse kinematics -- `plan_movel(q_start, pose_goal)` — Plan Cartesian line -- `plan_movec(q_start, pose_via, pose_goal)` — Plan circular arc -- `plan_movep(q_start, poses_goal)` — Plan multi-waypoint path +The commands **really do go out on the wire**, but the parent's reader thread will eat the +replies — all you see is a "no reply" timeout, and retrying means **sending the command a +second time**. Reading state is sneakier: it does not error, it just **forever returns the +stale value it inherited**. -### Motion +So this library is **fail-closed**: inside a child process, any command immediately raises +`ForkedSessionError` and not a single byte goes out. -- `movej(q_target, speed, settle_s, max_cycles, allow_start_collision_recovery)` — Joint-space move -- `home(speed, settle_s, max_cycles)` — Home all joints to zero, bypasses joint-limit & self-collision path checks -- `recover_joint_limits(speed, settle_s, max_cycles, inset_rad)` — Return out-of-limit joints to safe boundary -- `movel(pose_goal, speed, settle_s, max_cycles)` — Cartesian line -- `movec(pose_via, pose_goal, ...)` — Circular arc -- `movep(poses_goal, ...)` — Multi-waypoint with corner blending -- `replay_joint_path(q_path, ...)` — Replay joint path -- `replay_trajectory(traj, ..., check_singularity)` — Replay recorded trajectory -- `replay_timed_trajectory(traj_q, traj_t, ...)` — Replay on recorded time axis (safety-enforced) -- `play_trajectory(trajectory, ...)` — Load & replay a saved JointTrajectory -- `record_trajectory(...)` — Record by drag -- `hold(kp_scale, max_cycles)` — Hold position -- `zero_gravity(max_cycles, duration_s, measured_overspeed_factor, vel_max)` — Free-drag mode -- `joint_impedance(q_des, K, B, ...)` — Joint impedance control -- `cartesian_impedance(q_des, K_cart, B_cart, ...)` — Cartesian impedance -- `joint_follow(K, B, ...)` — Follow external target +For a child to use the arm, the **parent must `close()` first** to release the port, then +fork, then construct a new `Arm` in the child. -### State +⚠ `close()` is **still callable** in the child (it only clears session state and does not +touch the transport, so it will not hang), but do not expect it to release the serial port. -- `get_state(refresh)` — Read broadcast state cache -- `get_tcp_pose()` — Current TCP pose +The C++ version of this needs more explanation than the Python one — in a child process +`close()` **deliberately leaks two handles**: -### Control +- The `Ack` object must not be destroyed: its destructor would `join` a thread that **does not + exist in the child**, and `std::thread::join()` blocks **forever** on a nonexistent thread + while `~std::thread` calls `std::terminate` on a thread that was never joined. So it is + `release()`d and left to the kernel. +- The transport is the same story: the read lock that `SerialTransport::close()` takes is + **very likely held by the parent's reader thread** at the instant of `fork()`, and the + thread that could release it is not in the child — taking it is a deadlock. -- `request_stop()` — Emergency stop (independent channel) -- `clear_stop()` — Clear stop condition -- `enable()` — Enable all motors and hold current pose (re-enable after disable) -- `disable()` — Disable all motors (arm drops under gravity!); CAN stays connected +A child either exits quickly or constructs its own `Arm`, so the leak is bounded. -### Parameters +**⚠ The step that genuinely hangs is destroying a `std::condition_variable` that somebody is +parked on** (located empirically, glibc 2.35): the parent's reader thread is parked on `Ack`'s +`stop_cv_`, and destroying that condvar in the forked child makes glibc take the condvar's own +internal lock (a group switch) — a lock bound to a waiter that **no longer exists** ⇒ +permanent block. Minimal reproduction: one thread parked in `cv.wait_for(...)`, `fork()`, then +destroy that condvar. (In the same experiment: destroying a condvar/mutex **nobody is waiting +on** is fine; `detach()`ing an inherited thread handle does not throw; `malloc`/`free` are +safe — the problem is **only** a condvar someone is parked on.) -- `set_gains(kp, kd)` / `get_gains()` -- `clear_faults()` -- `set_payload(mass, com)` / `get_payload()` -- `set_installation(base_rpy, gravity)` / `get_installation()` +So the rule here is: **never destroy an inherited session object in a child process**. +Following the supported paths (`close()` / leaving the scope) satisfies this automatically; +if someone bypasses it and destroys the object directly, the library **warns loudly** rather +than hanging silently — the same "dropped frames are never silent" stance as the rest of the +repository. -### Peripheral devices +(Tests: `tests/test_fork_guard.cpp` really calls `fork()` and checks 7 cases — file-descriptor +inheritance, "threads are not copied", and "destroying the condvar hangs" are all operating +system facts that no fake can simulate.) + +### Safety rules + +1. **After `disable()` the position loop no longer holds the arm** — it will sag under load. +2. **`movej` does not check joint limits** — an out-of-range target is clamped by the firmware + and the arm **still travels the whole way**. +3. **`move_js` / `send_mit` need ≥10 Hz keepalive** — otherwise the 0.1 s watchdog drops + stiffness and the arm slowly goes soft. +4. **`enter_dfu()` is a terminal operation** — every entry point stops working afterwards, and + you must construct a new `Arm` after reflashing. + +### Irreversible commands: do not run these on a calibrated arm + +The following overwrite or erase the **per-arm calibrated** dynamics model, with **no undo**: + +| Entry | Effect | +| --- | --- | +| `save_params()` | writes current RAM into flash | +| `arm.model().commit()` | makes staged dynamics-model changes take effect | +| `arm.model().revert()` | rolls the dynamics model back (does not touch flash, so a power cycle brings it back) | +| `arm.params().reset_factory()` | restores factory settings | + +Only do these on a board whose calibration is worth nothing. +**`arm.model().set_jm()` should never be called** — a wrong joint mapping makes the arm flail, +and there is no reliable way back. + +When stress-testing the CAN link, run only `candump` (read-only); **never `cangen`**: `can0` +**is** the motor bus. + +## Examples + +See [examples/README.md](examples/README.md): + +- `01_hello` — handshake + firmware version + read state +- `02_movej` — joint motion +- `03_move_p` — Cartesian point to point +- `04_ik_tcp` — inverse kinematics and the current pose +- `05_ff_tune` — dynamics / control-law tuning +- `06_cartesian` — Cartesian line / arc / multi-waypoint +- `07_vel_jitter_trace` — tick-by-tick capture of the 100 Hz status stream and the 300 Hz firmware log together + +Examples are **read-only by default**; anything that moves requires `--go`: + +```bash +source env.sh +./run_example.sh 01_hello +./run_example.sh 02_movej --go +``` + +## Tests + +```bash +./build.sh # configure + build + ctest (fully offline, never touches hardware) +ctest --test-dir build -R test_cart -V +``` + +27 test binaries covering protocol/CRC/rotation/error codes/status frames/serial (a real +pty)/session assembly/the command surface/**frame ownership**/**command coverage**/**firmware +protocol sync**/feedforward/joint parameters/model/capture/self-test/Cartesian/zero-gravity/ +licensing/concurrency/**fork guard**/CLI. + +**No hardware is required** — the tests use a scripted fake transport +(`litearm::testing::FakeTransport`), and the serial layer itself is tested through a **real +pty** (not a fake: a fake would ignore `timeout` entirely, so defects like "`read_frame(0)` +actually touches zero bytes" go green on a fake). + +### The unverified register + +`UNVERIFIED_REGISTER.md` at the repository root lists, item by item, everything that is +**unverified / known-imperfect / deliberately diverging from the reference implementation** — +and **every entry must carry a source reference** (`file:line`), otherwise it does not count +as registered (nobody can re-check it in the field). Skim it before saying "this was tested". + +⚠ That discipline **has machine-enforced criteria** (`tests/test_register.cpp`): the column +count, the shape of the reference, contiguous ids, and "**an invisible row that lost its +leading `|`**" (such a row still reads like a registration while the machine cannot see it at +all). The criteria check **shape only** — they cannot tell whether a reference still points at +the sentence it meant to. That limitation is itself registered (entry 18). + +### Firmware protocol sync (`test_protocol_sync`) + +This one **needs the firmware repository** to run, and **SKIPs** by default (not "passes" — +a false green is worse than no test): + +```bash +LITEARM_FW_DIR=/path/to/litearm-stm32 ctest --test-dir build -R test_protocol_sync -V +``` + +It parses the firmware sources directly (**no firmware build required**) and enforces a +bidirectional comparison: + +- every firmware `CMD_*`/`RSP_*` has the **same name and value** in the SDK's `protocol.hpp` + (and the reverse — the SDK may not declare a command the firmware lacks); +- every implemented firmware command has an entry in `COMMAND_COVERAGE`, with **no stale + entries**; +- the three exemption tables (`FIRMWARE_ONLY_CMDS`/`FIRMWARE_ONLY_RSPS`/`PREEXISTING_GAPS`) + still hold; +- the status frame layout is still `6 + N*21` and the per-joint stride is still 21 B; +- `LITEARM_BENCH_MODEL_AXIS` in `joint_cfg.h` matches the SDK constant; +- `ERR{cmd,0x00}` is still produced **only** by the firmware's `default` branch (the SDK's + capability detection rests entirely on this); +- the firmware's self-reported version is not below `MIN_FW`. + +**Why it has to exist**: when firmware 1.5.x changed the status frame from `4+21N` to `6+21N`, +the SDK was not updated, while the fake-based tests replaced the whole transport and +manufactured the old layout — **fully green offline, guaranteed to fail on real hardware**. +Manual review does not stop this class of problem. + +⚠ **SKIP is listed separately in the report**: if you see ` skipped` after a run, the sync +half **was not tested**. Do not read it as tested. + +## Relationship to the original (litearm-python 2.1.0) + +**Ported item by item**, with an identical wire protocol and semantics; differences are purely +at the language level: + +| Topic | Python | C++ | +| --- | --- | --- | +| Pose argument | shape decided at runtime (`as_pose` accepts list/tuple) | `rot::PoseInput`, with a named factory for each of three spellings; a 6-vector converts implicitly | +| Joint count | `arm.n` attribute | `arm.n()` method | +| Tunable knobs | `arm.move_timeout = ...` | same-named public members; just assign | +| Unavailable values | `None` | `std::nullopt` | +| Zero-gravity context | `with arm.zero_g():` | `{ auto zg = arm.zero_g(); ... }` (RAII) | +| Teardown | `__del__` backstop | destructor backstop (likewise delegates to `close()` only) | +| Test framework | pytest | a minimal in-house framework (zero dependencies, one executable per `test_*.cpp`) | + +### Four deliberate divergences from the original (since 2026-09-28) + +> All four were aligned to a second, independent C++ implementation (the Gitee repository +> `yudao_hz_1/litearm-cpp`, branch `feat/p0-protocol-layer`). **Within the range both cover, it +> is byte-for-byte identical to the Python reference** (its golden samples drive this +> repository to 19/19); the only disagreements are the trade-offs below. + +#### 1. Same-frame throttling: **removed entirely** (the original has it; this repository does not) + +The original's `_tx_allowed` / `set_tx_repeat_min_interval` allows throttling **any** frame. +The consequence: once a command that **waits for an ACK** is dropped, the frame never reaches +the USB link ⇒ the firmware never replies ⇒ the caller can only **wait out the whole window** +and then report "no reply" — the attribution is **backwards**. + +This repository **previously** narrowed the scope to write paths declared "droppable" (only +the zero-gravity keepalive is written that way). **Now it removes the mechanism entirely**, +because in the reference implementation it is **private and off by default** (behaviourally +equivalent to absent) — not worth keeping a path that can be misused: + +- `Arm::set_tx_repeat_min_interval()` and `Arm::tx_throttled_frames()` are **gone**; +- the `droppable` parameter of `raw_write` is **gone**; +- ⇒ `raw_write`'s invariant is now stronger: **reaching this function means the frame will be + written**, so the half-state "queue cleared but nothing sent" cannot exist. The criterion is + `threading_every_raw_write_reaches_the_wire_there_is_no_drop_path` in + `tests/test_threading.cpp`. + +#### 2. Client-side prechecks: **four added** (the original has none; the firmware clamps) + +All of them refuse **locally, before a frame is sent**. The single shared reason: when a +parameter is wrong enough that "every comparison is false", silently letting it through means +**the arm really moves** and the caller has no way to notice. + +| Precheck | Criterion | Where | +|---|---|---| +| Non-finite targets | target contains NaN / ±inf ⇒ throw | `precheck_q_`, **ordered before "cache empty ⇒ allow"** | +| Soft-limit violation | outside `[q_min, q_max]` ⇒ throw | `precheck_q_`; the cache comes from the `0x24` reads at the end of `connect()` | +| Speed | `!(speed >= 0 && speed <= 1)` | `precheck_speed_`; **the `move_p` one is the only one of the six motion entries the original lacks** | +| Tolerances | `!(pos_tol > 0) \|\| !(rpy_tol > 0)` | inline in `move_p`; `dp >= NaN` being false would **silently skip the position half** of the check | + +⚠ Two **deliberate non-checks**: `home()` is **never** prechecked (once the arm has drifted +outside its soft limits, the recovery path must not be blocked by its own precheck); and +`move_js` / `send_mit` / `send_mit_all` are pass-through commands and are not prechecked. + +⚠ The cost must be stated: `connect()` therefore issues **n extra `0x24` frames** (the +reference's `connect()` does not read soft limits), and when they cannot be read it +**fails open** (no precheck, allow — never let the connection fail). Criteria in +`tests/test_precheck.cpp` (13 of them). + +#### 3. Diagnostic accessors: **seven added** (the original has none) + +`is_connected()` / `is_in_dfu()` / `status_seq()` / `msg_hz()` / `banner_version()` / +`host_stats()` / `options()`. All are **pure local snapshots that do not throw even in the +terminal state** — the moment you need them most is exactly when the link is dead. Criteria in +`tests/test_accessors.cpp` (11 of them). + +⚠ `options()` returns **current values** rather than a construction-time snapshot (consistent +with this repository's design, where the knobs are public members mutable at runtime). This +repository's `port_string()` was also fixed along the way — it used to return the **constructor +argument**, which is permanently empty under auto-discovery, **while the header comment +promised "or the auto-discovered one"** (comment and implementation had drifted apart; +reproduced on real hardware). + +#### 4. Time source: **injectable** (the original has none) ```cpp -auto hand = arm.device("hand_0"); // or arm.devices()["hand_0"] -hand.open(); -hand.set_gesture("pinch"); -hand.finger_move({0.1, 0.2, ...}); - -auto gripper = arm.device("gripper_0"); -gripper.set_width(0.5); -double w = gripper.get_width(); - -auto teach = arm.device("teach_0"); -teach.get_joints(); -``` - -`RemoteDevice` methods route to `device.{device_id}.{method}`. Common methods: -`get_status/get_info/connect/disconnect/clear_faults/open/close/set_force`. -Hand: `get_state/set_gesture/list_gestures/finger_move/set_speed/set_torque`. -Gripper: `set_width/get_width`. Teach: `get_joints/get_buttons`. - -### Extended interfaces - -- `get_system_stats()` / `get_logs(page, size, search)` / `restart_service()` -- Settings: `get_joint_limits` / `set_joint_limits(limits)`, - `get_zero_offsets` / `set_zero_offsets(offsets)`, - `get_end_effector` / `set_end_effector(config)`, - `get_cartesian_limits` / `set_cartesian_limits(limits)`, - `get_collision_config` / `set_collision_config(config)` -- Trajectory management: `start_recording` / `stop_recording` / `discard_recording`, - `get_recording_state` / `get_playback_state` / `list_trajectories`, - `save_trajectory(id, name, points, duration)` / `delete_trajectory(id)` -- Device management: `list_device_types()`, - `connect_device(category, subtype, device_id, can_iface, config)`, - `disconnect_device(device_id)` / `get_active_device(device_id)` -- Teleop: `enter_teleop(mode, params)` / `exit_teleop()` / `get_teleop_status()` - -## Comparison with litearm-python - -| Aspect | litearm-python | litearm-cpp | -| ------------- | ----------------------------- | ---------------------------------------- | -| Language | Python 3.10+ | C++17 | -| Value type | Native `dict`/`list` | `LiteArmValue` (tagged union) | -| Dependencies | Auto-installed | protobuf, gtest (test only) | -| Use case | Rapid prototyping, scripting | Low-latency embedded, real-time systems | - -Both SDKs expose the same interface and connect to the same arm control service. -The C++ client's `Arm` class mirrors the Python `Arm` class method-for-method, so -code can be migrated between languages with minimal changes. - -Key differences: - -- **No kinematics/dynamics on the client** — all computation runs server-side. -- **Transport is injectable**, which enables zero-dependency unit testing. -- **Value type**: Python uses native `dict`/`list`; C++ uses `LiteArmValue` (a tagged union). -- **Exception hierarchy** is identical — same type names, same inheritance. +auto clk = litearm::testing::FakeClock::make(); +litearm::ArmOptions opts; +opts.port = "fake"; +opts.clock = clk; // empty = SteadyClock (the production default) +``` + +⚠⚠ **This repository's clock only answers "what time is it", not "how do I wait"** — a +**deliberate difference** from the other implementation (the Gitee one): its `Clock` also has +`sleep_until(t)` (a fake clock jumps virtual time to `t` and returns immediately), which this +repository **does not provide**. The reason: every wait here is a +`condition_variable::wait_for`, relying on **being notified the instant a frame arrives**; +moving sleeping into the clock would throw that away, forcing every wait to run out a full +slice. + +⇒ The semantics here are: **deadlines / expiry comparisons / timestamps** use the injected +clock; **waiting** uses the condition variable's **real** time. + +⚠⚠ **One consequence you must know**: injecting a fake clock that **does not advance on its +own** means a `now + timeout` deadline **never arrives** ⇒ any criterion of the form "the +timeout really happens" will **hang** (not fail — hang). So a fake clock is only for +**pure-comparison** criteria (expiry comparisons, timestamps, TTL levels — the ones that do +not wait). The other implementation reached the same conclusion (its comments likewise say +"criteria where the timeout really happens must use SteadyClock"); the difference is that +there it degrades to a skip, while here it hangs. + +**Its payoff is one concrete test**: "how long without a frame counts as disconnected" +(`STATUS_STALE_MAX_S` = 2.0 s) used to require **really sleeping a full 2 seconds**; with a +fake clock, `advance(3.0)` is **instantaneous**, and the assertion no longer mixes in "will the +scheduler wake up on time". Criteria in `tests/test_clock.cpp`. + +⚠ **What does not follow the injection**: the transport layer (`transport.cpp`) **keeps the +real clock** — its `poll` / `tcdrain` timeouts are **hardware** timeouts, where injecting a +fake clock would be **wrong**, not merely useless. + +### Known inherited gaps (all of them exist in the original; none were introduced by the port) + +- `license()` on firmware **below 1.8.0** reports a "**no reply timeout**" rather than "the + firmware lacks this command": it calls `expect` without an `echo_cmd`, so the + `ERR{0x2F,0x00}` lands in a different queue and this entry cannot see it. The evidence and + the fix are in a test in `tests/test_license.cpp`. The port **deliberately preserves the + original semantics**. +- `get_status_now()` can **report success** when the firmware **rejects** `0x40` (returning a + status frame instead of raising `UnsupportedByFirmwareError`). The reason is that the second + half of its conclusion predicate is "**any** new status frame arrived" — and the firmware's + 100 Hz passive stream is an independent line from our `0x40`: if a status frame lands + between the `seq0` sample and the first predicate evaluation, the predicate is satisfied on + the spot while the `ERR{0x40,code}` may not have been delivered yet, so the function glances + at the ERR queue (empty) and falls through to "return success". + **Quantified** (2026-09-28, experiment below): with **zero status frames delivered** during + the call ⇒ 150/150 raise correctly; with exactly one delivered ⇒ 26–35 of 150 report + success. Under TSan, `cmd_unsupported_during_status_read_...` in `tests/test_commands.cpp` + therefore goes red about 1 time in 15. + + ⚠⚠ **The same root cause has a second manifestation with far more serious consequences — it + fails open on a safety guard**: `Arm::reject_if_cart_in_flight()` (the reverse guard on the + zero-gravity entry) reads one fresh frame via `get_status_now()` to judge `cart_busy`, and + **conservatively refuses** when it cannot (`CART_IN_FLIGHT_UNCONFIRMED_MESSAGE`). The window + above makes it "think it succeeded" — what it gets is a **stale status frame**, `cart_busy()` + is 0 ⇒ **the guard lets it through**. That is precisely the side the guard exists to prevent: + "entering mid-trajectory ⇒ the arm coasts to a stop on friction". + Measured under TSan: `zero_g_is_refused_conservatively_when_the_status_is_unavailable` goes + red 3 times in 30. + + Both tests now use `lt::quiesce()` (turn off the passive stream + wait for in-flight frames + to settle) to remove the variable, so each pins only the thing it means to pin (the + ERR→exception mapping / the conservative refusal). **Do not change them back to "assert + directly on a live link"** — that turns them back into timing tests. + + **Why it is not fixed**: status frames carry **no command echo** (only ACK/ERR do), so "is + this frame the reply to our command" is **undecidable at the protocol level**; adding a grace + window to the failure branch only shrinks the window, it cannot close it — and a half-fix + that *looks* fixed is more dangerous than a known limitation written down. On top of that, + this path is **unreachable** on supported firmware: `connect()`'s version gate already + rejects old firmware that lacks `0x40`. + ⇒ Semantics preserved, recorded here and next to the `done` predicate in `src/arm.cpp`. +- **No CDC auto-discovery outside `linux`**: macOS has no sysfs, and Windows needs SetupAPI to + read VID:PID reliably; guessing would produce false positives. Pass the port explicitly. + This is a deliberate trade-off, not an omission. +- **The first `connect()` after USB re-enumeration times out on the handshake** + (`get_firmware` no reply); waiting a few seconds and retrying works. Measured on real + hardware (2026-09-28): **4 times** that day, **every first connection** after replugging or + re-attaching the USB passthrough failed, and every retry succeeded; never happened on a warm + link. + ⚠ The reference implementation **does not retry either** (its `connect()` likewise sends + `0x41` once, waits 1.5 s, and closes the link and throws on failure) ⇒ this is **inherited + behaviour**, not introduced by the port, and the original semantics are deliberately kept. + In the field: **retry once after a second or two**; do not immediately suspect the firmware + or the cable. (Register entry 21; whether to add a bounded retry is an **open trade-off** — + see there.) + +### The batch of TSan warnings: **settled — false positives, with runtime evidence** + +`./build.sh --tsan` reports **large numbers** of data races on `Ack::queues` (dozens from a +single test case). **The conclusion is false positive**, and the basis is not "the code looks +fine to me" but **runtime evidence**: + +`Ack::MuLock` (`include/litearm/ack.hpp`) registers the owner of `mu` in **debug builds**, so +every access to `queues` / `state` / `recv_` can assert "I really am inside the lock". With +that assertion active, the **entire suite of 379 cases** ran: the assertion **never fired +once**, while TSan still reported those dozens ⇒ those accesses were **proven at runtime** to +be inside the lock. + +Conversely, that assertion is now **permanent**: the invariant "`Ack::queues` must be under +`mu`" used to rest on **eyeballing** the six access sites, and breaking it would be a **silent +data race** (the hardest kind to find). Now breaking it **aborts on the spot in the next debug +build and names the site**. In release builds it degrades to `std::unique_lock`, at zero cost. + +**How to use TSan**: `./build.sh --tsan` (on this machine it needs `setarch -R` to disable +ASLR; the script adds it automatically). The script sets +`TSAN_OPTIONS=halt_on_error=0:exitcode=0` — without those two switches, **a test that prints +"all passed" gets judged Failed by ctest**: the first is "exit(66) on the spot when something +is detected", which stops the assertions from ever running; the second is "set the exit code to +66 even after finishing". Been there; do not delete them. + +⚠⚠ **But `exitcode=0` has a backfire (measured 2026-09-28, sneakier than the above)**: TSan's +**deadlock detector** has only a **fixed 64-slot array** +(`sanitizer_deadlock_detector.h:67`), and once more than 64 mutexes are tracked at once it +does `FATAL: ThreadSanitizer CHECK failed (0x40, 0x40)` and **kills itself on the spot** — and +that death likewise goes through `internal__exit(exitcode)` ⇒ **exit code 0** ⇒ **ctest +reports Passed**. +⇒ Any test that "creates dozens of `Arm` objects in one run" hits it, so it **dies halfway +through while the report is green**. +⇒ **The criterion is not the ctest line, it is whether the framework's own summary line** +(`all passed: N passed / M failed`) **appeared**, plus whether stderr contains +`FATAL: ThreadSanitizer CHECK failed`. +None of the current 27 suites hits it (checked by running each binary directly); to bypass it +temporarily, use `TSAN_OPTIONS=...:detect_deadlocks=0`. +⚠ The same triage also found that `data race ... tests/test_zero_g.cpp:18 in count_zero_g_on` +is **not** a false positive — it is a test helper iterating `tx_log` **without holding +`tx_lock`** while the keepalive thread is concurrently `emplace_back`ing (reallocation frees +the buffer being iterated). 36 sites across 10 test files were written that way; they now all +go through the locked `tx_snapshot()` / `tx_count()`. + +## Platforms + +| Platform | Serial backend | Status | +| --- | --- | --- | +| Linux | POSIX termios + `flock` + `poll` | Tested on hardware (including real pty tests) | +| macOS | POSIX termios + `flock` + `poll` | Same code path; but **port auto-discovery is unavailable** (no sysfs) — pass the port explicitly | +| Windows | Win32 `CreateFile`/`SetCommState`/`ReadFile`/`WriteFile` | Implemented but **never verified on Windows**; port auto-discovery is likewise unavailable | +| Linux / aarch64 | same as Linux (cross-compiled) | **Toolchain file only, never compiled once** — no aarch64 toolchain on this machine. See "Installing and packaging / Cross-compiling for aarch64" | + +⚠ **Scope of hardware acceptance** (2026-09-28): **every reversible feature has been exercised** +— 14 groups / 68 assertions / 218 frames, **homing before and after every group**, covering the +full set of read-only accessors, joint and Cartesian motion, continuous servo, zero-gravity, +feedforward and joint parameters (read the original value, write it back), capture, self-test, +enable/disable round trips, and emergency stop with recovery; the frame-by-frame audit confirms +**no irreversible command was ever sent**. + +**Still never run on hardware**: the four irreversible ones (`save_params` / `model().commit()` +/ `reset_factory` / `enter_dfu` — excluded by user decision) plus `activate()` (this board is +already activated). See `UNVERIFIED_REGISTER.md` entry 14. Do not read "most things verified" +as "everything verified". + +On Windows, build with MSVC + CMake (`cmake -S . -B build -G "Visual Studio 17 2022"`). +`env.ps1` / `run_example.ps1` are in the repository root. ## License -Same as litearm-python. +This port reuses the upstream `LICENSE` file verbatim (**Apache License 2.0**). +⚠ The upstream `README.md` says "MIT" while its own `LICENSE` file is Apache-2.0 — that +inconsistency exists upstream; this repository treats the LICENSE file as authoritative. diff --git a/README.zh-CN.md b/README.zh-CN.md index aa4081c..4d85822 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,278 +1,719 @@ # litearm-cpp -LiteArm 机械臂的 C++17 客户端 SDK。通过网络连接机械臂控制服务,即可从任意机器控制机械臂——适合低延迟、嵌入式场景。 +LiteArm 机械臂的 **C++ SDK** —— 经由 USB 串口**直连固件**, 中间没有服务器、没有中间件。 +轨迹规划、运动学与动力学全部由固件承担, PC 侧只发点、收结果。 -API 与 [litearm-python](../litearm-python) 对齐,便于跨语言迁移。 +本仓是 [litearm-python](https://github.com/nexform-tech/litearm-python) 2.1.0 的 C++ 移植: +同一套线协议、同一套语义、同一套安全判据, 逐条对照移植; 测试同样全离线可跑。 -> 📖 完整开发指南与接口说明见 [docs/DEVELOPER_GUIDE.zh-CN.md](docs/DEVELOPER_GUIDE.zh-CN.md)。 +> 接口全景见 [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md); +> 现场排查 (现象 → 原因 → 怎么办) 见 [TROUBLESHOOTING.md](TROUBLESHOOTING.md)。 -## 环境要求 +## 特点 -- C++17 编译器(GCC 8+,Clang 7+) -- CMake 3.16+ -- Protobuf 3.19+(headers、库、`protoc` 编译器) -- Google Test(仅测试需要;首次配置时自动下载) +- **零外部依赖**: 只用 C++17 标准库 + 平台自带串口 API (POSIX termios / Win32)。 + 不引 libserialport, 不引 numpy —— 重型计算本来就在固件里。 +- **单一定位**: 头文件 `include/litearm/`, 一个静态库 `liblitearm.a`。 +- **直连 USB**: 一根线到固件; VID:PID `1d50:606f` 自动发现。 +- **固件干重活**: 规划/运动学/动力学都在固件; PC 侧只发点、判到位。 +- **完整运动 API**: 关节运动、笛卡尔直线/圆弧/多路点、拖动示教。 +- **安全内建**: 子进程 fail-closed、独立急停通道、每条不可逆命令都标出来。 -## 安装 protobuf +## 构建 -`litearm-cpp` 需要 protobuf ≥ 3.19。先检查本机是否已装: +| 项目 | 要求 | +| --- | --- | +| 编译器 | 支持 C++17 (g++ 7+ / clang 6+ / MSVC 2019+) | +| 构建 | CMake ≥ 3.14 | +| 依赖 | 无 (POSIX 用 termios/poll/flock, Windows 用 Win32 串口 API) | +| 固件 | `Litearm1.5.0` 或更高 | +| 连接 | USB CDC 串口, VID:PID `1d50:606f` | ```bash -protoc --version # 应显示 "libprotoc 3.19" 或更新版本 +./build.sh # 配置 + 构建 + 跑测试 (全离线) +source env.sh # 把 build/ 放进 PATH (首次会自动构建) +./run_example.sh 01_hello ``` -若未安装或版本过低,任选一种方式安装: - -**Debian / Ubuntu**(24.04+ 自带 protobuf 3.21): +手工构建: ```bash -sudo apt update -sudo apt install -y protobuf-compiler libprotobuf-dev +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build -j +ctest --test-dir build --output-on-failure ``` -**Conda**(任意系统): +CMake 开关: `-DLITEARM_BUILD_EXAMPLES=OFF` / `-DLITEARM_BUILD_TESTS=OFF` / +`-DLITEARM_WERROR=ON`。 + +Linux 需要串口权限: ```bash -conda install -c conda-forge protobuf=3.19.6 +sudo usermod -aG dialout $USER # 重新登录后生效 ``` -**源码编译**(任意系统,无需包管理器): +作为子目录接入: -```bash -cd /tmp -curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v3.19.6/protobuf-cpp-3.19.6.tar.gz -tar xzf protobuf-cpp-3.19.6.tar.gz && cd protobuf-3.19.6 -./configure && make -j$(nproc) && sudo make install -sudo ldconfig +```cmake +add_subdirectory(third_party/litearm-cpp) +target_link_libraries(your_app PRIVATE litearm::litearm) ``` -安装后验证: +### 安装与打包 ```bash -protoc --version +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr/local +cmake --build build -j && cmake --install build ``` -## 构建 +装出来的东西 (三种消费方式都验过 —— 见下面「怎么验的」): + +``` +include/litearm/*.hpp # 全部公开头 (含 testing.hpp / framing.hpp) +lib/liblitearm.a # 或 .so (加 -DBUILD_SHARED_LIBS=ON) +lib/cmake/litearm/litearmConfig.cmake +lib/cmake/litearm/litearmConfigVersion.cmake +lib/cmake/litearm/litearmTargets.cmake +lib/pkgconfig/litearm.pc +``` + +**消费方式一 —— CMake**: + +```cmake +find_package(litearm CONFIG REQUIRED) +target_link_libraries(my_app PRIVATE litearm::litearm) +``` + +**消费方式二 —— pkg-config**: ```bash -mkdir build && cd build -cmake .. # 系统安装的 protobuf 会被自动找到 -cmake --build . -j$(nproc) +g++ my_app.cpp $(pkg-config --cflags --libs litearm) +# 静态链接要带 --static (它会带出 Libs.private 里的 -lpthread) ``` -若 protobuf 安装在自定义前缀(例如已激活的 conda 环境),告诉 CMake 位置: +**消费方式三 —— 子目录** (`add_subdirectory`, 见上)。 + +⚠ 伞头 `litearm.hpp` **刻意不含** `testing.hpp` 与传输实现细节 —— 用假件要自己 +`#include `。 + +#### 怎么验的 (别只看"装上了") + +在临时 prefix 上真装一遍, 再写**真消费者**分别用 `find_package` 与 `pkg-config` 去编、去跑: ```bash -cmake .. -DPROTOBUF_ROOT=$CONDA_PREFIX +cmake -S . -B build-inst -DCMAKE_INSTALL_PREFIX=/tmp/litearm-prefix \ + -DLITEARM_BUILD_TESTS=OFF -DLITEARM_BUILD_EXAMPLES=OFF +cmake --build build-inst -j && cmake --install build-inst +# 然后: cmake 消费者 find_package(litearm CONFIG REQUIRED) → 编过 → 跑通 +# pkg-config 消费者 g++ main.cpp $(pkg-config --cflags --libs litearm) → 编过 → 跑通 ``` -## 测试 +`tests/test_packaging.cpp` 另外守着**卖出去之前**就能发现的那几类: +版本只有一个说法 (CMakeLists ↔ 头里的宏 ↔ `version()`)、公开头引用的兄弟头都在 +`include/litearm/` 里、两个打包模板文件在、`.pc` 的版本不是写死的。 + +#### aarch64 交叉编译 ```bash -cd build -ctest --output-on-failure -``` - -## 项目结构 - -```text -litearm-cpp/ -├── CMakeLists.txt -├── proto/ -│ └── litearm.proto # 接口定义 -├── generated/ -│ ├── litearm.pb.h # 生成的头文件 -│ └── litearm.pb.cc # 生成的源文件 -├── include/litearm/ -│ ├── arm.hpp # Arm 类(主 API) -│ ├── codec.hpp # 序列化 -│ ├── device.hpp # RemoteDevice / DeviceManager(外设) -│ ├── exceptions.hpp # 异常体系 -│ ├── protocol.hpp # 命名约定 -│ ├── transport.hpp # 传输抽象 -│ └── types.hpp # 核心类型(Value、RobotState 等) -├── src/ -│ ├── arm.cpp -│ ├── codec.cpp -│ ├── device.cpp -│ ├── exceptions.cpp -│ ├── transport.cpp -│ └── types.cpp -└── tests/ - ├── test_arm.cpp - ├── test_codec.cpp - ├── test_device.cpp - ├── test_protocol.cpp - └── test_transport.cpp -``` - -## 使用 +cmake -S . -B build-arm64 -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-aarch64-linux-gnu.cmake +cmake --build build-arm64 -j +``` + +⚠⚠ **本仓从未在 aarch64 上运行过** —— 只做过"能否编译"这一层, 而**编译这一层在本机也没验成** +(没装 `aarch64-linux-gnu-g++`): 工具链文件被正确解析、干净地失败在"找不到编译器"。 +对齐、端序、串口行为在 arm64 上**无人守**。别把本节读成"已支持 arm64"。 + +⚠ 那个工具链文件**强制** `LITEARM_BUILD_TESTS=OFF`: 本仓的测试不依赖 gtest 所以**编得过**, +但 ctest 会在构建机上执行 aarch64 二进制 ⇒ 一整排 "Exec format error"。 +要交叉**跑**测试请配置 `CROSSCOMPILING_EMULATOR` (qemu-aarch64)。 + +## 快速上手 ```cpp -#include +#include +using namespace litearm; int main() { - // 连接机械臂控制服务 - auto arm = litearm::Arm("tcp/192.168.1.100:7447", "armA"); + Arm arm; // 不指定端口则自动发现 (VID:PID 1d50:606f) + arm.connect(); // 校验固件版本约定 + + std::printf("固件 %s, %d 关节\n", arm.firmware().c_str(), arm.n()); - // 读取当前状态 - auto state = arm.get_state(); - if (state) { - // state->q 为关节位置 - } + arm.enable(); // 任何运动之前必须先使能 - // 关节空间运动 - arm.movej({0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, /*speed=*/0.5); + // 关节运动: 固件规划 S 曲线并自完成 + arm.movej({0.1, 0, 0, 0, 0, 0, 0}, 0.3); - // 回零:所有关节归零,绕开限位和自碰检查 - arm.home(/*speed=*/0.3, /*settle_s=*/0.5); + // 笛卡尔直线: 位姿 = 3 个位置 (m) + 3 个姿态 (rad) + arm.move_l({0.30, 0.0, 0.40, 3.1416, 0.0, 0.0}, 0.5); - // 正运动学 - auto [pos, rot] = arm.fk({0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}); + // 读状态 —— 注意读值要 .value (返回的是 Msg 信封, 见下) + auto st = arm.get_state().value; + if (st) std::printf("q = %s\n", fmt(st->q()).c_str()); - // 急停 - arm.request_stop(); + auto tcp = arm.get_tcp().value; + if (tcp) std::printf("TCP = %s\n", fmt(*tcp).c_str()); - arm.close(); - return 0; + // 逆运动学: 位姿 -> 关节角 + auto q = arm.ik({0.30, 0.0, 0.35, 3.1416, 0.0, 0.0}); + + arm.home(); // 回零位舒展姿 + arm.close(); // 断开 (析构也会兜底调它) } ``` -### 外设设备使用 +`Arm::connect()` 是唯一入口。每条会话都带一条**读线程**, 所以**必须 `close()`** —— +C++ 的析构会兜底, 但显式关掉更清楚 (析构只在对象真被析构时才跑)。 +不传端口则自动发现; SDK **不读任何环境变量**。 + +## 接口参考 + +### 连接 ```cpp -#include +Arm arm; // 自动发现 +Arm arm2("/dev/ttyACM0"); // 指定端口 +arm.connect(); // 幂等: 已连着同一目标则 no-op +arm.reconnect(); // 强制重建 (改过 move_timeout 之后要用它) +arm.close(); // 断开 (幂等) +printf("%s / %d\n", arm.firmware().c_str(), arm.n()); +``` -int main() { - auto arm = litearm::Arm("tcp/192.168.1.100:7447", "armA"); +### 关节运动 + +三条都需要先 `enable()`: `movej` 是单发, `movej_sync` 是同步 PTP, `home` 回零位。 + +```cpp +arm.enable(); +arm.movej({0.1,0,0,0,0,0,0}, 0.3); // 每轴独立 S 曲线 (先到先停) +arm.movej_sync({0.1,0,0,0,0,0,0}); // 一条路径标量驱动全轴, 同时到达 (更慢但可预测) +arm.home(); // 固件速度写死 0.10 +``` + +### 笛卡尔运动 + +```cpp +std::array P1{0.30, 0.0, 0.40, 3.1416, 0.0, 0.0}; +std::array P2{0.32, 0.0, 0.42, 3.1416, 0.0, 0.0}; +std::array P3{0.34, 0.0, 0.44, 3.1416, 0.0, 0.0}; + +CartPlan a = arm.move_l(P2, 0.5); // 直线 +CartPlan b = arm.move_p(P2); // 关节空间点到点 —— 不是直线! +CartPlan c = arm.move_path({P1, P2, P3}, 0.5); // 依次经过, 尖角 +CartPlan d = arm.move_c(arm.get_tcp().value.value(), P2, P3); // 圆弧 (起点须为实测 TCP) + +CartPlan e = arm.move_l(P2, 0.5, /*wait=*/false); // 不阻塞; 之后自己轮询 +auto claimed = arm.poll_cart(); // 认领一条没被取走的结果 +arm.set_speed(50); // 全局调速器: 整数百分比 0..100 +``` + +位姿也收 `(pos[3], R[3x3])` 与 4x4 齐次矩阵: + +```cpp +rot::Vec3 pos{0.32, 0.0, 0.42}; +rot::Mat3 R = rot::rpy_to_mat({3.1416, 0.0, 0.0}); +arm.move_l(rot::PoseInput::from_pos_rot(pos, R), 0.5); +``` + +### 状态 / 运动学 + +```cpp +auto st = arm.get_state().value; // std::optional +if (st) { + fmt(st->q()); fmt(st->dq()); fmt(st->tau()); + bool en = st->enabled(), busy = st->cart_busy(), bad = st->faulted(); + std::string detail = st->fault_detail(); + std::vector axes = st->fault_axes(); // 0 基 +} +auto tcp = arm.get_tcp().value; // std::optional> +arm.get_status_now(); // 主动要一帧 +arm.ik({0.30, 0.0, 0.35, 3.1416, 0.0, 0.0}); // 位姿 -> q[7] +``` - // 灵巧手 - auto hand = arm.device("hand_0"); // 或 arm.devices()["hand_0"] - hand.open(); - hand.set_gesture("pinch"); - hand.finger_move({0.1, 0.2, 0.3, 0.4, 0.5, 0.6}); +### 生活 / 安全 - // 夹爪 - auto gripper = arm.device("gripper_0"); - gripper.set_width(0.5); - double w = gripper.get_width(); +```cpp +arm.emergency_stop(); // 急停: 没有任何前置条件 +arm.reset(); // 清故障 + 重新锚定 (不是 MCU 重启) +arm.clear_faults(); // 只清 RAM 故障位 +arm.disable(); // ⚠ 失能后位置环不再抱住臂 +``` - // 示教板 - auto teach = arm.device("teach_0"); - auto joints = teach.get_joints(); +### 前馈 / 动力学 - arm.close(); - return 0; +```cpp +arm.set_payload(1.0); // 换负载后**总是**要调 +arm.set_gravity_scale({1,1,1,1,1,1,1}); +arm.ff_preset(1); // 0 全关 / 1 出厂 / 2 全开 +double m = arm.get_ff_scalar(4).value; // 读回 payload_mass +arm.get_ff_mask(); // ff_mask (0x2C item 9, 只读) +``` + +### 拖动示教 + +```cpp +{ + auto zg = arm.zero_g(); // 进入 + 启动保活 + std::this_thread::sleep_for(std::chrono::seconds(10)); +} // 退出作用域自动收尾 (不抛) +// 或显式配对: arm.zero_g_start(); ...; arm.zero_g_stop(); +``` + +### 关节级参数 (`arm.params()`) + +```cpp +JointParam p = arm.params().get_joint_param(0).value; // J1 的 kp/kd/tau_max/软限位 +arm.params().set_joint_param(0, 30.0, 1.0, 20.0); // idx, kp, kd, tau_max +arm.params().set_joint_limits(0, -1.0, 1.0); // 只许收窄 +for (const auto& jp : arm.params().all_joint_params()) { + printf("J%d kp=%.1f q=[%.3f, %.3f]\n", jp.idx + 1, jp.kp, jp.q_min, jp.q_max); } ``` -设备方法路由到 `device.{device_id}.{method}` 接口,因此机械臂控制服务背后的任意末端外设都可以用同一个 `device(id)` 句柄访问。 +### 动力学模型 (`arm.model()`) -## API 参考 +```cpp +if (arm.model().probe()) { // 固件支持在线导入吗 + for (int i = 1; i <= 7; ++i) arm.model().set_body(i, body10); + arm.model().set_jm({...}); + arm.model().commit(MODEL_MASK_WRITTEN); // 0x2FE; 须失能 +} +auto s = arm.model().status().value; // override / staged_mask / dirty +auto g = arm.model().get_gravity({0,0,0,0,0,0,0}).value; // G(q) +``` -`litearm::Arm` 类提供以下方法分组: +### 数据采集 (`arm.log()`) -### 计算(无硬件) +```cpp +arm.log().start(300); // 录 300 拍后自停 +LogReader r = arm.log().reader(); +uint32_t total = r.total(); // 已录字节数 +auto samples = r.samples(); // 逐拍解析 +// 便捷: 采 + 等录满 + 读回 +auto s2 = arm.log().capture(600); +arm.log().dump("trace.bin", /*wait=*/true); // 原始字节落盘 +``` -- `fk(q)` — 正运动学 -- `ik(pos_d, R_d, q_seed)` — 逆运动学 -- `plan_movel(q_start, pose_goal)` — 笛卡尔直线规划 -- `plan_movec(q_start, pose_via, pose_goal)` — 圆弧规划 -- `plan_movep(q_start, poses_goal)` — 多航点规划 +### 固件自检 (`arm.diag()`) -### 运动 +```cpp +KinBenchResult kb = arm.diag().kin_bench().value; +printf("crc 坏帧 %lld, 应答丢弃 %lld, CAN TX 失败 %lld\n", + kb.crc_errors(), kb.reply_dropped(), kb.can_tx_fail()); +``` + +### 授权 / 激活 -- `movej(q_target, speed, settle_s, max_cycles, allow_start_collision_recovery)` — 关节空间运动 -- `home(speed, settle_s, max_cycles)` — 回零:所有关节归零,绕开限位和自碰路径检查 -- `recover_joint_limits(speed, settle_s, max_cycles, inset_rad)` — 越限关节回安全边界 -- `movel(pose_goal, speed, settle_s, max_cycles)` — 笛卡尔直线 -- `movec(pose_via, pose_goal, ...)` — 圆弧 -- `movep(poses_goal, ...)` — 多航点带拐角平滑 -- `replay_joint_path(q_path, ...)` — 回放关节路径 -- `replay_trajectory(traj, ..., check_singularity)` — 回放已录轨迹 -- `replay_timed_trajectory(traj_q, traj_t, ...)` — 按原始时间轴回放(安全拉伸) -- `play_trajectory(trajectory, ...)` — 回放已保存轨迹 -- `record_trajectory(...)` — 拖动录轨迹 -- `hold(kp_scale, max_cycles)` — 持位 -- `zero_gravity(max_cycles, duration_s, measured_overspeed_factor, vel_max)` — 零重力(自由拖动) -- `joint_impedance(q_des, K, B, ...)` — 关节空间阻抗控制 -- `cartesian_impedance(q_des, K_cart, B_cart, ...)` — 笛卡尔阻抗控制 -- `joint_follow(K, B, ...)` — 跟随外部目标 +```cpp +LicenseInfo lic = arm.license(); // 未激活也正常返回, 不抛 +printf("%s uid=%s\n", lic.state_name().c_str(), lic.uid_hex().c_str()); +arm.disable(); +arm.activate(cust_id, issued, flags, mac16, 16); // 须失能; mac 由厂商签发 +``` -### 状态 +### 持久化 -- `get_state(refresh)` — 读取状态缓存 -- `get_tcp_pose()` — 当前 TCP 位姿 +```cpp +arm.disable(); +arm.save_params(); // ⚠ 写 flash, 不可逆 +``` -### 控制 +### 只读属性 -- `request_stop()` — 急停(独立通道) -- `clear_stop()` — 清除停止状态 -- `enable()` — 使能全部电机并保持当前姿态 -- `disable()` — ⚠️ 失能全部电机(机械臂会掉臂!),CAN 保持连接 +```cpp +arm.n(); // 关节数 +arm.firmware(); // 版本串, 如 "Litearm1.8.0-7J" (来自握手 0x41, 权威) +arm.fw_version(); // std::optional +arm.cart_supported(); // 固件是否编进了 LITEARM_CART_PLAN +arm.last_reset_reason(); // "normal" / "iwdg-rst" / "" (未收到开机签名) +arm.zero_g_active(); // 保活是否仍在维持 +arm.port_string(); // **实际**连着的端口 (未连接时回退到构造入参) +``` -### 参数 +诊断用的加法 (**全部与 Python 参照分叉**, 参照侧一个都没有; 都是纯本地快照, +**终态下也不抛** —— 现场最需要它们的时候正是链路已经废掉的时候): -- `set_gains(kp, kd)` / `get_gains()` -- `clear_faults()` -- `set_payload(mass, com)` / `get_payload()` -- `set_installation(base_rpy, gravity)` / `get_installation()` +```cpp +arm.is_connected(); // 读线程活着 **且** 状态帧没过期 (两个条件缺一不可) +arm.is_in_dfu(); // 是否已进 DFU 终态 +arm.status_seq(); // 成功解码的状态帧累计计数 +arm.msg_hz(0x40); // 该类上行帧的平均到达频率 (Hz; 分母是实际到达间隔) +arm.banner_version(); // std::optional —— 开机横幅里的版本串 (横幅只在开机发一次) +arm.host_stats(); // 宿主机侧计数快照, 见 `struct HostStats` +arm.options(); // 当前生效的选项 (**现值**, 不是构造期快照) +``` -### 外设设备 +### 可调旋钮 ```cpp -auto hand = arm.device("hand_0"); // 或 arm.devices()["hand_0"] -hand.open(); -hand.set_gesture("pinch"); -hand.finger_move({0.1, 0.2, ...}); - -auto gripper = arm.device("gripper_0"); -gripper.set_width(0.5); -double w = gripper.get_width(); - -auto teach = arm.device("teach_0"); -teach.get_joints(); -``` - -`RemoteDevice` 方法路由到 `device.{device_id}.{method}`。通用方法: -`get_status/get_info/connect/disconnect/clear_faults/open/close/set_force`。 -灵巧手:`get_state/set_gesture/list_gestures/finger_move/set_speed/set_torque`。 -夹爪:`set_width/get_width`。示教板:`get_joints/get_buttons`。 - -### 扩展接口 - -- `get_system_stats()` / `get_logs(page, size, search)` / `restart_service()` -- 设置:`get_joint_limits` / `set_joint_limits(limits)`、 - `get_zero_offsets` / `set_zero_offsets(offsets)`、 - `get_end_effector` / `set_end_effector(config)`、 - `get_cartesian_limits` / `set_cartesian_limits(limits)`、 - `get_collision_config` / `set_collision_config(config)` -- 轨迹管理:`start_recording` / `stop_recording` / `discard_recording`、 - `get_recording_state` / `get_playback_state` / `list_trajectories`、 - `save_trajectory(id, name, points, duration)` / `delete_trajectory(id)` -- 设备管理:`list_device_types()`、 - `connect_device(category, subtype, device_id, can_iface, config)`、 - `disconnect_device(device_id)` / `get_active_device(device_id)` -- 遥操:`enter_teleop(mode, params)` / `exit_teleop()` / `get_teleop_status()` - -## 与 litearm-python 对比 - -| 方面 | litearm-python | litearm-cpp | +arm.q_tol = 0.03; // 到位判据: 关节角容差 (rad) +arm.dq_tol = 0.10; // 到位判据: 关节速度容差 (rad/s) +arm.arrive_frames = 3; // 连续静止拍数 +arm.move_timeout = 15.0; // 动作等待窗口 (秒) —— ⚠ 改它要配 reconnect() +``` + +## 返回值信封 `Msg` + +11 个"读一帧"型 getter 返回 `Msg { value, hz, timestamp }`: + +| 字段 | 含义 | +| --- | --- | +| `value` | 原返回值。取不到帧的入口 (`get_state` / `get_tcp`) 用 `std::nullopt` 表达 | +| `hz` | 该类帧在本会话里的**平均到达频率** (自首次收到起算, 不随链路空闲衰减) | +| `timestamp` | 该类帧**最近一帧**的本地单调时刻; 从没收到过则是 `0.0` | + +被包的 11 个: `get_state` / `get_status_now` / `get_tcp` / `get_ff_vec` / +`get_ff_scalar` / `params().get_joint_param` / `model().get_body` / `model().get_jm` / +`model().status` / `model().get_gravity` / `diag().kin_bench`。 + +**刻意不包的**: `move_*` / `home` (它们是**动作结果**, 不是"读一帧"); +纯本地量 (`n()` / `firmware()` / `last_reset_reason()`); `license()` (没有固件发起的 +流量 —— 那样 `hz` 只会是"调用方自己的轮询频率"); 以及两个派生 getter +(`get_ff_mask` / `all_joint_params`, 它们是聚合或标量投影)。 + +`hz == 0.0` 的两种情况: ① 该类帧**从没到过** (此时 `timestamp` 也是 0); +② 只到过一帧 —— 一个样本定不出频率。单发请求/应答式的入口 (`get_joint_param` / +`get_body` / `get_jm` / `get_gravity`) **第一次调用必然 `hz == 0.0`**, 第二次起等于 +**调用方自己的轮询频率**, 不是固件的什么周期。 + +## 命令行 + +```bash +./01_hello # 等价于 CLI 的 status +./04_ik_tcp +./02_movej --go 0.1 0 -0.1 0 0 0 0 +``` + +SDK 里也带一个简易 CLI (`litearm::main_impl`), 便于巡检: + +```bash +litearm-cpp status # 只读 +litearm-cpp fw # 版本串 + 轴数 +litearm-cpp tcp # 当前位姿 + 帧率 +litearm-cpp movej -0.1 0 0 0 0 0 0 --speed 0.3 +litearm-cpp home +``` + +## 注意事项 + +### 多进程: fork 出来的子进程不能沿用父进程的 `Arm` + +命令**真的会发到线上**, 但父进程的读线程会把应答吃掉 —— 你只会看到"无应答"超时, +而重试就意味着**把命令发第二遍**。读状态更隐蔽: 它不报错, 只是**永远返回继承来的陈旧值**。 + +所以本库是 **fail-closed** 的: 子进程里任何命令立刻抛 `ForkedSessionError`, +一个字节都不会出去。 + +子进程要用臂, **父进程必须先 `close()` 释放端口**, 再 fork, 然后在子进程里新建 `Arm`。 + +⚠ 子进程里 `close()` **仍可调** (它只清会话状态、不碰传输层, 所以不会挂死), 但别指望它 +释放串口。 + +C++ 里这一条的落法比 Python 更需要说清楚 —— 子进程里 `close()` **故意泄漏两个句柄**: + +- 那层 `Ack` 不能被销毁: 它的析构会 `join` 一条**在子进程里不存在**的线程, 而 + `std::thread::join()` 对不存在的线程会**永久阻塞**、`~std::thread` 对没 join 的线程会 + `std::terminate`。故把它 `release()` 掉, 交给内核回收。 +- 传输层同理: `SerialTransport::close()` 要取的那把读锁, 在 fork 那一刻**很可能正被父进程的 + 读线程持着**, 而能解锁的那个线程不在子进程里 —— 取它就是死锁。 + +子进程要么很快退出, 要么会新建自己的 `Arm`, 所以这份泄漏是有界的。 + +**⚠ 真正会挂死的那一步, 是析构一个"有人停在上面的" `std::condition_variable`** +(实测定位, glibc 2.35): 父进程的读线程正停在 `Ack` 的 `stop_cv_` 上, `fork()` 之后在子进程里 +析构那个 condvar —— glibc 会去取 condvar 自己的内部锁 (组切换), 而那把锁与已经**不存在**的 +等待者绑在一起 ⇒ 永久阻塞。最小复现: 一个线程 `cv.wait_for(...)` 停在 condvar 上, `fork()` +之后删掉那个 condvar。 (同一个实验里: 析构**没人在等**的 condvar / mutex 都没事; `detach()` +继承来的线程句柄也不抛; `malloc`/`free` 也安全 —— 问题**只**出在"有人停在上面的 condvar"。) + +所以本库的规则是: **子进程里绝不析构继承来的会话对象**。走支持的路径 +(`close()` / 离开作用域) 时这条自动成立; 万一有人绕过去直接析构, 库会**出声告警** +(而不是静默挂死) —— 与全仓"丢帧不静默"同一口径。 + +(测试: `tests/test_fork_guard.cpp` 真的 `fork()` 出来验, 7 条用例 —— 文件描述符继承、 +"线程不被复制"、以及"condvar 析构会挂"都是操作系统的事实, 任何桩都模拟不出来。) + +### 安全规则 + +1. **`disable()` 之后位置环不再抱住臂** —— 有负载会下坠。 +2. **`movej` 不检查关节限位** —— 越界目标被固件钳到限位, 臂**仍会走完全程**。 +3. **`move_js` / `send_mit` 需要 ≥10Hz 保活** —— 否则 0.1s 看门狗丢掉刚度, 臂慢慢软下来。 +4. **`enter_dfu()` 是终态操作** —— 之后每个入口都失效, 烧完固件要新建 `Arm`。 + +### 不可逆命令: 别在已标定的臂上跑 + +下面这些会覆盖或抹掉**这台臂逐台标定**的动力学模型, **没有撤销**: + +| 入口 | 效果 | +| --- | --- | +| `save_params()` | 把当前 RAM 写进 flash | +| `arm.model().commit()` | 让 staged 的动力学模型改动生效 | +| `arm.model().revert()` | 回退动力学模型 (不动 flash, 所以重新上电会复活) | +| `arm.params().reset_factory()` | 恢复出厂设置 | + +只在一台标定没有任何价值的板子上做这些事。 +**`arm.model().set_jm()` 永远不该被调用** —— 错的关节映射会让臂乱摆, 且没有可靠的回退路径。 + +压测 CAN 链路时只跑 `candump` (只读), 绝不 `cangen`: `can0` **就是**电机总线。 + +## 例子 + +见 [examples/README.md](examples/README.md): + +- `01_hello` —— 握手 + 固件版本 + 读状态 +- `02_movej` —— 关节运动 +- `03_move_p` —— 笛卡尔点到点 +- `04_ik_tcp` —— 逆运动学与当前位姿 +- `05_ff_tune` —— 动力学 / 控制律调参 +- `06_cartesian` —— 笛卡尔直线 / 圆弧 / 多路点 +- `07_vel_jitter_trace` —— 100Hz 状态流 + 300Hz 固件日志的双路逐拍采集 + +样例**默认只读**; 任何会动的都要 `--go`: + +```bash +source env.sh +./run_example.sh 01_hello +./run_example.sh 02_movej --go +``` + +## 测试 + +```bash +./build.sh # 配置 + 构建 + ctest (全离线, 从不碰硬件) +ctest --test-dir build -R test_cart -V +``` + +21 个测试二进制, 覆盖协议/CRC/旋转/错误码/状态帧/串口(真 pty)/会话装配/命令面/ +**帧归属**/**命令覆盖**/**固件协议同步**/ +前馈/关节参数/模型/采集/自检/笛卡尔/零重力/授权/并发/**fork 守卫**/CLI。 +**不需要任何硬件** —— 测试用一份脚本化的假传输 (`litearm::testing::FakeTransport`), +而串口本身用**真 pty** 测 (不是桩: 桩会把 `timeout` 整个忽略掉, 于是"`read_frame(0)` +其实一个字节都不碰"这类缺陷在桩上全绿)。 + +### 未验证登记册 + +仓库根目录的 `UNVERIFIED_REGISTER.md` 逐条登记「**没验证的 / 已知不完美的 / 有意与参照实现 +分叉的**」—— 每条**必须带出处**(`文件:行号`),否则等于没登记(现场复核不了)。 +报告里说"测过了"之前先扫一眼它。 + +⚠ 这条纪律**本身有机器判据**(`tests/test_register.cpp`):列数、出处形状、id 连号、 +以及"**丢了行首 `|` 的隐形行**"(那种行读起来还像一条登记,而机器完全看不见它)。 +判据只查**形状**,查不了出处是否**还指得到那句话** —— 这条限制也登记在册(第 18 条)。 + +### 与固件的协议同步 (`test_protocol_sync`) + +这一套**需要固件仓库**才能跑, 默认 **SKIP**(不是通过 —— 假绿比没测更糟): + +```bash +LITEARM_FW_DIR=/path/to/litearm-stm32 ctest --test-dir build -R test_protocol_sync -V +``` + +它直接解析固件源码 (**不需要编译固件**), 双向强制比对: + +- 固件每条 `CMD_*`/`RSP_*` 在 SDK `protocol.hpp` 里**同名同值** (反向亦然 —— SDK 不许声明 + 固件没有的命令); +- 固件每条已实现命令在 `COMMAND_COVERAGE` 里都有登记, 且**没有过期登记**; +- 三张豁免表 (`FIRMWARE_ONLY_CMDS`/`FIRMWARE_ONLY_RSPS`/`PREEXISTING_GAPS`) 仍然成立; +- 状态帧布局仍是 `6 + N*21`、每关节步长仍是 21B; +- `joint_cfg.h` 的 `LITEARM_BENCH_MODEL_AXIS` 与 SDK 常量一致; +- `ERR{cmd,0x00}` 仍**只**由固件的 `default` 分支产生 (SDK 的能力判定全押在这条上); +- 固件自报版本不低于 `MIN_FW`。 + +**为什么必须有它**: 固件 1.5.x 把状态帧从 `4+21N` 改成 `6+21N` 时漏同步了 SDK, 而桩测试 +整替换 transport、自造旧布局 —— **离线全绿、真机必挂**。人工核对挡不住这类事。 + +⚠ **SKIP 会在报告里单列**: 跑完若看到 ` 跳过`, 说明同步那一半**没测**, 别读成测过了。 + +## 与原版 (litearm-python 2.1.0) 的关系 + +**逐条对照移植**, 线协议与语义完全一致; 差异只在语言层面: + +| 主题 | Python | C++ | +| --- | --- | --- | +| 位姿入参 | 运行期判形态 (`as_pose` 收 list/tuple) | `rot::PoseInput`, 三种写法各有具名工厂; 6 向量可隐式转换 | +| 关节数 | `arm.n` 属性 | `arm.n()` 方法 | +| 可调旋钮 | `arm.move_timeout = ...` | 同名公开成员, 赋值即可 | +| 取不到的值 | `None` | `std::nullopt` | +| 零重力上下文 | `with arm.zero_g():` | `{ auto zg = arm.zero_g(); ... }` (RAII) | +| 收尾 | `__del__` 兜底 | 析构兜底 (同样只委托给 `close()`) | +| 测试框架 | pytest | 自研极简框架 (零依赖, 每个 `test_*.cpp` 一个可执行文件) | + +### 本移植**有意**与原版分叉的四组 (2026-09-28 起) + +> 这三组都是照着另一套独立 C++ 实现 (Gitee `yudao_hz_1/litearm-cpp` 的 `feat/p0-protocol-layer`) +> 对齐的结果。**它与本仓在共同覆盖的范围内与 Python 参照逐字节一致** (用它的金样本驱动本仓 +> 跑过 19/19), 分歧只在下面这三处取舍上。 + +#### ① 同帧节流: **整套删除** (原版有, 本仓不做) + +原版的 `_tx_allowed` / `set_tx_repeat_min_interval` 允许节流**任何**帧, 后果是: 一条 +**等 ACK** 的命令被丢掉之后帧压根没上 USB ⇒ 固件永远不会应答 ⇒ 调用方只能**等满窗口**再报 +"无应答", 归因**指向反了**。 + +本仓**早先**的做法是把射程收窄到"声明为可丢弃"的写口 (只有零重力保活那样写)。**现在改成 +整套删除**, 理由是那套机制在参照实现里**私有且默认全关**(行为上等价于无), 不值得为它留一条 +可能被误用的路径: + +- `Arm::set_tx_repeat_min_interval()` 与 `Arm::tx_throttled_frames()` **已移除**; +- `raw_write` 的 `droppable` 形参**已移除**; +- ⇒ `raw_write` 的新不变式更强: **进了这个函数就一定会写出去**, 没有"清队了但帧没发" + 这种半状态。判据在 `tests/test_threading.cpp` 的 + `threading_every_raw_write_reaches_the_wire_there_is_no_drop_path`。 + +#### ② 客户端预检: **新增四条** (原版没有, 由固件钳制) + +全部在**发帧之前**本地拒发。共同理由只有一条: 参数错到"比较全为假"时, 静默放行的后果是 +**臂真的动了**而调用方无从察觉。 + +| 预检 | 判据 | 落地 | |---|---|---| -| 语言 | Python 3.10+ | C++17 | -| 值类型 | 原生 `dict`/`list` | `LiteArmValue`(tagged union) | -| 依赖 | 自动安装 | protobuf(gtest 仅测试) | -| 适用 | 快速原型 / 脚本 | 低延迟嵌入式 / 实时系统 | +| 非有限目标 | 目标含 NaN / ±inf ⇒ 抛 | `precheck_q_`, **排在"缓存空 ⇒ 放行"之前** | +| 软限越限 | 超出 `[q_min, q_max]` ⇒ 抛 | `precheck_q_`; 缓存来自 `connect()` 末尾读的 `0x24` | +| 速度 | `!(speed >= 0 && speed <= 1)` | `precheck_speed_`; **`move_p` 那只是六个运动入口里唯一原版没有的** | +| 容差 | `!(pos_tol > 0) \|\| !(rpy_tol > 0)` | `move_p` 内联; `dp >= NaN` 为假会**让位置判定被静默跳过** | + +⚠ 两条**刻意的不拦**: `home()` **绝不**预检 (臂漂出软限位后, 恢复路径不该被自己的预检堵死); +`move_js` / `send_mit` / `send_mit_all` 是透传命令, 不做预检。 -三个 SDK 接口方法一一对应,代码可跨语言迁移。 +⚠ 代价要说清: `connect()` 因此**多出 n 条 `0x24`** (参照实现的 connect 不读软限), 读不到时 +**fail-open**(不预检、放行, 绝不让连接失败)。判据在 `tests/test_precheck.cpp` (13 条)。 -主要区别: +#### ③ 诊断访问器: **新增七个** (原版没有) -- **客户端无运动学/动力学** — 所有计算都在服务端进行。 -- **传输层可注入**,便于无外部依赖的单元测试。 -- **值类型**:Python 用原生 `dict`/`list`;C++ 用 `LiteArmValue`(tagged union)。 -- **异常体系一致** — 同名同继承关系。 +`is_connected()` / `is_in_dfu()` / `status_seq()` / `msg_hz()` / `banner_version()` / +`host_stats()` / `options()`。全是**纯本地快照、终态下也不抛** —— 现场最需要它们的时候正是 +链路已经废掉的时候。判据在 `tests/test_accessors.cpp` (11 条)。 -## License +⚠ `options()` 返回**现值**而不是构造期快照 (与本仓"旋钮是公开成员、运行期可改"的设计一致); +本仓的 `port_string()` 也顺带修好了 —— 它原先返回**构造入参**, 走自动发现时恒为空串, +**而头文件注释承诺的是"或自动发现到的"**(注释与实现脱节; 真机复现过)。 + +#### ④ 时间源: **可注入** (原版没有) + +```cpp +auto clk = litearm::testing::FakeClock::make(); +litearm::ArmOptions opts; +opts.port = "fake"; +opts.clock = clk; // 空 = SteadyClock (生产默认) +``` -与 litearm-python 相同。 +⚠⚠ **本仓的时钟只管"现在几点", 不管"怎么等"** —— 这是与 A 侧(Gitee 那份)的一处 +**刻意不同**: A 的 `Clock` 还有 `sleep_until(t)`(假钟 = 把虚拟时间跳到 t 并立刻返回), +本仓**不提供**它。理由: 本仓的等待**清一色**是 `condition_variable::wait_for`, 靠的是 +**有帧到达时被 notify 立刻唤醒**; 把睡眠也搬进时钟就会丢掉这条, 每次等待都要等满一整片。 + +⇒ 本仓的语义是: **deadline / 到期比较 / 时间戳**走注入的钟; **等待**走条件变量的**真实**时间。 + +⚠⚠ **由此有一条必须知道的后果**: 注入一个**不会自己前进**的假钟时, `now + timeout` +这类 deadline **永远到不了** ⇒ 凡"等超时真的发生"的判据会**挂死**(不是失败,是挂死)。 +所以假钟只给**纯比较**类判据用(到期比较、时间戳、TTL 水位,那些不等待)。 +A 侧的结论一致(它的注释也写"凡'超时真的发生'的判据要用 SteadyClock"), +只是那边退化成 skip,这边是挂死。 + +**它的价值在一条具体的用例上**:「多久没收到帧算失联」(`STATUS_STALE_MAX_S` = 2.0s) +本来要**真睡满 2 秒**才验得到;用假钟 `advance(3.0)` 是**瞬间**的,而且断言里不再混进 +"调度器会不会按时醒来"。判据在 `tests/test_clock.cpp`。 + +⚠ **不跟着注入的**: 传输层(`transport.cpp`)的时间源**保持真钟** —— 那里的 +`poll` / `tcdrain` 超时是**硬件**超时,注入假钟是**错的**,不只是没用。 + +### 已知继承的差异/缺口 (都**不是**移植引入的, 而是在原版里就存在): + +- `license()` 在**低于 1.8.0** 的固件上报的是"**无应答超时**"而不是"固件没有这条命令": + 它调 `expect` 时没传 `echo_cmd`, 于是那条 `ERR{0x2F,0x00}` 落到别的队列里, 本入口看不到。 + 逐条证据与修法见 `tests/test_license.cpp` 里那条用例。移植时刻意**保留原语义**。 +- `get_status_now()` 在固件**拒绝** `0x40` 时可能**报成功** (返回一帧状态, 而非 + `UnsupportedByFirmwareError`)。原因是它的结论判据后半条是"**有任何**新状态帧到" —— + 而固件的 100Hz 被动流与我们的 `0x40` 是两条独立的线: 若在 `seq0` 采样与首次判据之间 + 落进一帧状态帧, 判据当场成立, 而那条 `ERR{0x40,code}` 可能还没被投递, 于是函数查一眼 + ERR 队列 (空) 便落到"返回成功"。 + **量化** (2026-09-28, 实验见下): 调用期间**一帧状态帧都没交付** ⇒ 150/150 全部正确抛出; + 交付了恰好 1 帧 ⇒ 150 次里 26~35 次报成功。TSan 下 `tests/test_commands.cpp` 的 + `cmd_unsupported_during_status_read_...` 因此约 1/15 概率翻红。 + + ⚠⚠ **同一根因还有第二处表现, 后果比上面重得多 —— 它在安全守卫上 fail-open**: + `Arm::reject_if_cart_in_flight()` (零重力入口的反向守卫) 靠 `get_status_now()` 现取一帧 + 判 `cart_busy`; 取不到时**保守拒绝** (`CART_IN_FLIGHT_UNCONFIRMED_MESSAGE`)。而上面这个 + 窗口让它"误以为取到了" —— 拿到的是**一帧陈旧的状态帧**, `cart_busy()` 为 0 ⇒ **守卫放行**。 + 正是该守卫存在的理由那一侧: "轨迹中途进场 ⇒ 臂靠摩擦滑停 (coast)"。 + 实测 TSan 下 `zero_g_is_refused_conservatively_when_the_status_is_unavailable` + 30 次里翻红 3 次。 + + 两条用例现已用 `lt::quiesce()` (关被动流 + 等在途帧落定) 把变量摘掉, 只钉各自要钉的 + 那件事 (ERR→异常映射 / 保守拒绝)。**别把它们改回"直接在活跃链路上断言"** —— + 那会重新变成两条测时序的用例。 + + **为什么不修**: 状态帧里**没有命令回显** (只有 ACK/ERR 有), 所以"这一帧是不是我们那条 + 命令的应答"在协议层面**不可判定**; 给失败分支加宽限窗口只能把窗口压小、关不严 —— + 而一个"看起来修好了"的半修, 比一个明写着的已知限制更危险。加上这条路径在受支持的 + 固件上**不可达**: `connect()` 的版本门已经挡掉了没有 `0x40` 的旧固件。 + ⇒ 保留原语义, 记在这里 + `src/arm.cpp` 的 `done` 谓词旁。 +- `linux` 之外**不做 CDC 自动发现**: macOS 没有 sysfs、Windows 需要 SetupAPI 才能可靠读到 + VID:PID, 猜一个会给出假阳性。请显式传端口。这是刻意的取舍, 不是遗漏。 +- **USB 重新枚举之后的第一次 `connect()` 会握手超时**(`get_firmware 无应答`),等几秒重试即好。 + 真机实测 (2026-09-28): 当天 **4 次**重新插上/重新直通之后**每一次首次连接都失败**, + 重试全部成功;热链路下从未出现。 + ⚠ 参照实现**同样不重试**(它的 `connect()` 也是发一次 `0x41`、等 1.5s、失败就关链路抛) + ⇒ 这是**继承行为**,不是移植引入的。移植时刻意保留原语义。 + 现场用法: 连不上就**隔一两秒重试一次**,别急着怀疑固件或线。 + (登记册第 21 条;要不要给它加一次有界重试是个**未决的取舍** —— 见那里。) + +### TSan 那一批告警: **已定论 —— 误报, 且有运行时证据** + +`./build.sh --tsan` 会在 `Ack::queues` 上报**大量** data race (单用例就有几十条)。 +**结论是误报**, 依据不是"看代码觉得像", 而是**运行时证据**: + +`Ack::MuLock` (`include/litearm/ack.hpp`) 在 **Debug 构建**下会登记 `mu` 的持有者, +于是 `queues` / `state` / `recv_` 的每一处访问都能断言"我确实在锁内"。开着这套断言跑 +**全部 308 条用例**: 断言**一次都没触发**, 而 TSan 照样报那几十条 ⇒ 那些访问在运行时 +**被证实在锁内**。 + +反过来说, 这套断言现在是**常设的**: `Ack::queues` 的"必须在 `mu` 之下"这条不变量以前 +只靠**人眼审**那 6 处访问点, 一旦被破坏就是**静默的数据竞争** (最难查的一类)。 +现在它被破坏会在**下一次调试构建里当场 abort 并指名站点**。Release 下退化成 +`std::unique_lock`, 零额外开销。 + +**怎么用 TSan**: `./build.sh --tsan` (本机需要 `setarch -R` 关 ASLR, 脚本已自动加)。 +脚本会设 `TSAN_OPTIONS=halt_on_error=0:exitcode=0` —— 少了这两个开关, +**打印着"全部通过"的用例会被 ctest 判成 Failed**: 前者是"检测到就当场 exit(66)"让断言 +跑不到, 后者是"跑完了也把退出码设成 66"。这条踩过, 别删。 + +⚠⚠ **但 `exitcode=0` 有一个反噬 (2026-09-28 实测, 比上面那条更阴)**: TSan 的**死锁检测器** +只有一个 **64 格的固定数组** (`sanitizer_deadlock_detector.h:67`), 被它同时追踪的 mutex +超过 64 个就 `FATAL: ThreadSanitizer CHECK failed (0x40, 0x40)` **当场自杀** —— 而自杀走的 +同样是 `internal__exit(exitcode)` ⇒ **退出码 0** ⇒ **ctest 报 Passed**。 +⇒ 任何"在一次运行里创建几十个 `Arm`"的用例都会撞上它, 于是**跑到一半就没了, 报告却是绿的**。 +⇒ **判据不是 ctest 那一行, 而是框架自己那行汇总** (`全部通过: N 通过 / M 失败`) 有没有出现, +以及 stderr 里有没有 `FATAL: ThreadSanitizer CHECK failed`。 +本仓现有 21 个套件都**没有**撞上 (逐个直跑核对过); 临时绕过用 +`TSAN_OPTIONS=...:detect_deadlocks=0`。 +⚠ 同一次分诊里还发现: 那条 `data race ... tests/test_zero_g.cpp:18 in count_zero_g_on` +**不是误报** —— 它是测试辅助函数在**不持 `tx_lock`** 的情况下遍历 `tx_log`, 而保活线程正在 +并发 `emplace_back` (扩容会把正在遍历的缓冲区释放掉)。10 个测试文件里 36 处都是这么写的, +现已统一改走持锁的 `tx_snapshot()` / `tx_count()`。 + +## 平台 + +| 平台 | 串口后端 | 状态 | +| --- | --- | --- | +| Linux | POSIX termios + `flock` + `poll` | 已实测 (含真 pty 测试) | +| macOS | POSIX termios + `flock` + `poll` | 同一份代码路径; 但**端口自动发现不可用** (无 sysfs), 请显式传端口 | +| Windows | Win32 `CreateFile`/`SetCommState`/`ReadFile`/`WriteFile` | 已实现但**未在 Windows 上实机验证**; 端口自动发现同样不可用 | +| Linux / aarch64 | 同 Linux (交叉编译) | **只写了工具链文件, 一次都没编过** —— 本机没装 aarch64 工具链。见「安装与打包 / aarch64 交叉编译」 | + +⚠ **真机验收的范围**(2026-09-28):**全部可逆功能已跑通** —— 14 组 / 68 项断言 / 218 帧, +**每组前后都回零**,含只读访问器全家、关节与笛卡尔运动、连续伺服、零重力、前馈与关节参数 +(读原值写回)、采集、自检、使能失能往返、急停与恢复;逐帧审计确认**没有发出任何不可逆命令**。 + +**仍未在真机上跑**:四项不可逆(`save_params` / `model().commit()` / `reset_factory` / +`enter_dfu` —— 用户裁决排除)+ `activate()`(本板已激活)。详见 `UNVERIFIED_REGISTER.md` +第 14 条。别把"大部分验过"读成"全验过"。 + +Windows 上用 MSVC + CMake 构建即可 (`cmake -S . -B build -G "Visual Studio 17 2022"`)。 +`env.ps1` / `run_example.ps1` 见仓库根目录。 + +## 许可 + +本移植沿用上游随仓的 `LICENSE` 文件原文 (**Apache License 2.0**)。 +⚠ 上游 `README.md` 写的是 "MIT", 而它自己的 `LICENSE` 文件是 Apache-2.0 —— 那份不一致是 +上游就有的; 本仓以 LICENSE 文件为准。 diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md new file mode 100644 index 0000000..12827c0 --- /dev/null +++ b/TROUBLESHOOTING.md @@ -0,0 +1,424 @@ +This is the troubleshooting guide for the `litearm` C++ SDK, which drives the +7-axis arm over the serial line protocol — read it when `connect()`, +`enable()`, or a motion command fails, misbehaves, or returns something you +cannot explain, and follow it from symptom to cause to remedy. + +# litearm-cpp field troubleshooting manual + +Every entry follows the same three beats: **symptom → cause → what to do**. +Start with the quick-reference table below. + +Recommended order of investigation: look at the **error code** first (§4 warns +about two error-code spaces that are easy to confuse), then the **link +diagnostic counters** (§11), and only then suspect the cable. + +> This file is the C++ version of the upstream +> `litearm-python/TROUBLESHOOTING.md`. The symptoms and causes are **firmware +> behavior** and are language-independent; the entry-point names here have +> been rewritten for the C++ API (things like `arm.model().commit()`). +> **Warning:** the "not yet verified" items in the upstream document's last +> section are **equally unverified in this port** — do not assume they have +> been verified just because the language changed. See §16. + +## Quick-reference table + +| What you see | Which section | +| --- | --- | +| `connect()` cannot open the port / cannot find the device | §1 | +| `connect()` reports a firmware version mismatch | §1 | +| `enable()` is rejected | §2 | +| Commands in a forked child time out; a retry "sometimes works" | §3 | +| Readings in a forked child **never change**, yet no error is raised | §3 | +| You get `ERR{0x02,0x03}` and cannot tell which meaning applies | §4 | +| The number "3" means two different things | §4 | +| Back-to-back Cartesian commands lose replies | §5 | +| `move_c()` arc fails | §6 | +| Cartesian accuracy is far from what you expect | §7 | +| An unexplained constant Cartesian offset | §8 | +| The state still trails a little when `movej` returns | §9 | +| `last_reset_reason()` returns an empty string | §10 | +| `kin_bench` counters are all 0 | §11 | +| A motion command sent right after `zero_g` is rejected | §12 | +| The arm slowly goes soft after `move_js` / `send_mit` | §13 | +| Flashing fails after `enter_dfu()` | §14 | +| `set_speed` / `set_joint_limits` behave counter-intuitively | §15 | +| You want to know what else is unverified | §16 | +| **After a power cycle, `enable()` returns `ERR{0x10,0x06}`** | §17 | + +--- + +## 1. `connect()` will not connect + +**Symptom**: `TransportError` (cannot open the port / cannot find the device) +or `FirmwareMismatchError` (version mismatch). + +| Cause | How to confirm | +| --- | --- | +| Device not found — not plugged in, no driver, not `1d50:606f` | `lsusb`; call `litearm::find_cdc_port()` on its own and see what it returns | +| Port already in use — another process or session still holds it open | On Linux, `fuser /dev/ttyACM0`; on Windows exclusivity is enforced by the OS, so if you cannot get it, you cannot open it | +| Firmware too old (< 1.5.0), or the version string does not follow the convention | Read the `FirmwareMismatchError` message — it quotes the version string it **actually saw** | + +**What to do**: for `FirmwareMismatchError`, flash `Litearm1.5.0` or later; if +the port is in use, close whatever is holding it. + +**Warning:** a device **re-enumeration** (unplug/replug, after `enter_dfu()`, +a real power cycle) changes the `/dev/ttyACM*` **number**. A script with +`LITEARM_PORT` hard-coded now points at a port that does not exist — this is +the most common cause of "it worked a moment ago". + +**Warning:** this library **reads no environment variables**: `LITEARM_PORT` +is only something the `env.sh` / sample layer uses. + +--- + +## 2. `enable()` is rejected + +**Symptom**: `enable(attempts)` throws `CommandRejectedError`. + +**Cause and remedy**: `attempts` retries only the codes on a **whitelist** — +only the one marked "retryable" below is resent. For every other code, +resending **does nothing at all** and only wastes time. + +| Code | Meaning | What to do | +| --- | --- | --- | +| `ERR{0x10,0x03}` | Transient failure (the only retryable one on the firmware's whitelist) | Leave it to `attempts`, or resend later | +| `ERR{0x10,0x06}` | **Latched** fault — resending is useless | Call `reset()` first, then find out which axis it is (`st->joint_fault` / `st->fault_axes()`) | +| `ERR{0x10,0x07}` | Resending is useless | Consult the firmware code table | +| `ERR{0x10,0x00}` | **The firmware does not have this command** | Firmware too old, upgrade it | + +**Warning:** a close relative that often gets mixed in here: **when the arm is +not enabled, `movej` is rejected with `ERR[01,3]`**, and its message points at +**two** possibilities at once — "not enabled **or** EMERGENCY latched". Do not +read only the first half of it. + +--- + +## 3. A forked child process behaves strangely + +**Cause**: `fork` **does not copy threads**, but it **does copy file +descriptors**. So in the child, commands **really do go out on the wire**, and +the parent's read thread eats the replies. See the multi-process section of +the [README](README.md). + +**How to recognize it**: + +| Symptom | Explanation | +| --- | --- | +| A command "times out", but a retry "sometimes works" | The command **did go out**; the parent read the reply ⇒ that retry was a **duplicate send** | +| `get_state()` raises no error, but the values **never change** | It silently returns the inherited **stale** values — the most insidious case | +| `connect()` throws in the child | The parent still holds the port ⇒ the parent must `close()` first | +| Any command immediately throws `ForkedSessionError` | The guard **is working correctly**; this is not a fault | + +**What to do**: have the parent call `close()` to release the port, then +`fork`, and then **construct a new** `Arm` in the child. + +**Warning:** in C++ there is one more layer: in the child, `close()` +**deliberately leaks** two handles (`Ack` and the transport layer) — +destroying them would hang forever (see that README section). The leak is +bounded and intentional. + +--- + +## 4. Two error codes that are easy to confuse + +### `ERR{0x02,0x03}` is ambiguous + +**The same `(command, code)` pair has two completely different origins in the +firmware**: it means both "not enabled / EMERGENCY latched" and "IK +unreachable / invalid solution". + +**What to do**: receiving it **does not mean** the arm has been disabled. Look +at the **current state** (`st->enabled()` / `st->mode`) instead of at the code +— if the arm is enabled, you are on the IK branch. + +### Two code spaces that both run 1–6 + +| Origin | Meaning | +| --- | --- | +| The `err` field in a `0x4E` reply (`CartPlan::err`) | **The planning result itself**: unreachable / collinear / over capacity / out of limits | +| The second byte of `RSP_ERR` | **Gating reason code**: not enabled `0x03` / drag teaching in progress `0x04` / `drop_hold` `0x06` | + +**Both take values from 1–6, and their meanings have nothing to do with each +other.** When you get a "3", first ask which path it came from. + +--- + +## 5. Back-to-back Cartesian commands lose replies + +**Symptom**: after sending several `move_l` / `move_path` commands in a row, +one of them throws `CartReplyLostError` ("outcome unknown"), or later replies +get **misaligned** (one command's answer is collected by the next one). +Reproduced 3 out of 3 times on real hardware. + +**Cause**: the firmware holds only **one** pending-planning slot at a time — +it is not a queue. The cancellation replies of the superseded command +overwrite each other, so a single missing reply shifts the whole pairing +sequence. **The root cause is in the firmware, not in this library.** + +**What to do**: send Cartesian commands **serially** — wait for one to finish +before sending the next. The library already serializes calls **within a +single process**, so normal use never hits this; only cross-process or +multi-client concurrency does. + +--- + +## 6. `move_c()` arc fails + +**Symptom**: `CartesianPlanError` is thrown, and the arm **does not move at +all**. + +| `err` | Cause | +| --- | --- | +| `2` | **The three points are collinear** (or nearly collinear) — the circle center runs off to infinity | +| `1` | No IK solution. Starting from the **fully extended `home` pose** (a singular configuration) **always** produces this; the firmware is doing the right thing, this is not a defect | +| `3` | Over capacity / unreachable | + +**What to do**: pick three non-collinear points; when starting from a singular +configuration, move out of the singular configuration first. + +**Warning:** the **`start` in `move_c(start, via, goal)` must match the +measured TCP at the time of the call** (tolerance 6 mm / 0.03 rad). It is not +a free "start from here" parameter — it **is reconciled against reality**, so +using a TCP from mid-motion as the start point is rejected. **Warning:** +**`via`'s orientation is ignored**; only its position takes part in defining +the circle. + +--- + +## 7. Cartesian accuracy is not a single number + +**The end-effector residual depends on [distance x speed x payload pose]**. It +is not some fixed device specification, and it varies noticeably with the +payload. + +**What to do**: any accuracy comparison must use **the same pose, the same +conventions, and the same payload** — otherwise you are measuring a pose +difference or a payload difference, not an accuracy difference. + +--- + +## 8. A constant Cartesian offset — check `payload_mass` first + +**Symptom**: the Cartesian pose carries a constant offset of about 8 mm, +unrelated to the protocol and the planner. + +**Cause**: the device still holds a stale `payload_mass = 1.0` (the factory +default should be `0.0`). The firmware's dynamics compensation uses that mass, +so the tool frame is systematically pushed off. + +**What to do**: after changing the payload, **always** call `set_payload()`. +When you see an unexplained constant offset, read `payload_mass` back with +`get_ff_scalar(4)` first, and suspect other things after that. + +--- + +## 9. `movej` returns before the arm has fully settled + +**Symptom**: read the state the moment `movej` returns and every axis still +has a small residual. + +**Cause**: `movej` returns when the **arrival criterion** is met — every axis +is within `q_tol`, and the velocity has been quiet for `arrive_frames` +consecutive frames. At that moment the arm has not come to a complete stop +yet. + +Measured (driving J6 to 0.7): at the moment of return it reads **0.6884**; +within 5 seconds it converges on its own to **0.6991** and holds there for 20 +seconds. That is about 0.012 rad (0.7 degrees), inside `q_tol = 0.03` — **this +is by design, not drift**. + +**What to do**: if you need an exact value, wait a few seconds before reading, +or tighten `q_tol` yourself. + +--- + +## 10. `last_reset_reason()` returns an empty string + +**This is correct behavior, not a parse failure.** The boot signature is sent +**once**, only **after a real MCU reset**, and `reset()` does not make it +repeat. Getting an empty string in everyday use is expected. + +It only has a value when you **connect soon after a real reset** (`"normal"` / +`"iwdg-rst"`). + +**Warning**, a related clarification: **`reset()` is a software state reset, +not an MCU reboot.** It does **not** trigger USB re-enumeration, **the same +`Arm` object stays usable afterwards**, and the signature is not resent. + +--- + +## 11. `kin_bench` counters are all 0 + +**Symptom**: the link diagnostic counters from `arm.diag().kin_bench()` +(`crc_errors()` / `reply_dropped()` / `can_tx_fail()` …) all read 0. + +**Cause**: this **does not necessarily** mean the link is clean. All zeros can +also mean "nothing was ever read" — and that kind of failure is **silent**: no +error, just a 0 that looks perfectly healthy. + +**What to do**: before treating this as a link health check, confirm that +frames are actually arriving — look at `Msg.hz` / `Msg.timestamp`. If both are +`0.0`, that class of frame **has never arrived**. + +**Warning:** among these counters, `crc` is live and accurate; `can_tx_fail` +can be surprisingly large (a cumulative value reported by the firmware, whose +exact definition has not been verified). + +--- + +## 12. `zero_g` keep-alive period and asynchronous exit + +| Symptom | Cause | What to do | +| --- | --- | --- | +| Motion commands are rejected during drag teaching | Firmware watchdog semantics: only queries get through | Queries are unaffected; **emergency stop / disable are the exceptions** and are always let through | +| `period=0.5` is rejected locally | The keep-alive must be **under 0.10 s**; if the firmware does not receive a resend within 0.10 s it falls into fail-soft | Use a `period` in `[0.005, 0.10)`; the default `0.04` is fine | +| A motion command sent right after `zero_g_stop()` is rejected | **The exit is asynchronous** — the firmware needs a little time to wind down after the call returns | Wait a moment before sending motion commands | + +A background thread resends the keep-alive automatically. If it stops because +of a **write failure**, exiting **throws**; it does not fail silently. You can +read the state from the read-only `zero_g_active()` / `zero_g_error()`. + +--- + +## 13. The arm slowly goes soft after `move_js` / `send_mit` + +**Cause**: these are **continuous-servo / passthrough** entry points that +**bypass motion planning**, and **the keep-alive is the caller's job**: +**resend at >=10 Hz**. Once the 0.1 s watchdog expires the firmware enters +fail-soft (reduced stiffness + τ=0) and the arm slowly sags under gravity — +the measured sag matches the estimate for "0.6x stiffness + τ=0". + +**What to do**: as long as the motion is still needed, keep resending at +>=10 Hz. + +**Warning:** the array length must be **`n`** (checked locally), and the +values must be **finite** — `NaN` / `Inf` make the firmware reject the whole +frame (`ERR{cmd,0x02}`). `send_mit` / `send_mit_all` / `move_js` all have this +check. **Warning:** `dq` in `move_js` is a **velocity reference**, not a +clamp. + +--- + +## 14. After `enter_dfu()` + +- **You cannot flash immediately**: `ACK{0x15}` only means "registered"; the device must first **re-enumerate** as `0483:DF11`. Running the flash right away fails; wait ten-odd seconds and it works. +- To tell whether the device is really gone, look for a **read or write that throws** — **not** `is_open()`, and **not** "read 0 bytes". +- Once it returns successfully, **this `Arm` is dead**: every entry point throws `ArmIsInDfuError` (except `close()`). After flashing the firmware, **construct a new `Arm`**. +- Calling it while enabled is **rejected locally** (the jump stops TIM3 ⇒ the motors release within 100 ms, and a load will sag). + +--- + +## 15. Two counter-intuitive parameter behaviors + +**`set_speed(percent)` takes an integer percentage, not a multiplier.** +`set_speed(1)` means **1% speed** — think in 0..1 terms and you get a crawling +arm. It is also **global and persistent** (it stays in effect until some call +with reset semantics), which is not the same thing as the **per-trajectory +multiplier** of `movej(speed=0..1)`. The argument must be an **`int` in +0..100**; out-of-range values are rejected locally (the C++ parameter is an +`int`, so problems like "passing a bool" cannot arise here). + +**`set_joint_limits()` is not idempotent.** The firmware **only allows +narrowing**, so writing the **current** values back unchanged is judged a +"widening request" and rejected (`ERR[23,2]`). ⇒ Do not use it for a +read-modify-write round-trip check. + +--- + +## 16. Not yet verified + +None of the following counts as verified: + +- `enter_dfu()` — the only terminal operation; recovering from it requires reflashing the firmware; +- The persistence of `save_params()` (it writes flash); +- `reset_factory()` (it wipes the tuned parameters); +- The **success** path of `move_c()` — no reliably succeeding arc was ever constructed; +- `move_js` / `send_mit` / `send_mit_all` — never run on real hardware; +- The **actual narrowing effect** of `set_joint_limits()` (only "writing the old values back is rejected" was verified); +- **Whether `capture()` always records 0 frames while disabled** — this appears only in this repository's field notes; there is no corresponding check or test in the code; not re-checked; +- **Windows** — unverified (the serial backend is implemented, but it has never been run on a real Windows machine); +- **The C++ port itself**: all tests are **offline** (fake transport + real pty), and **none has been run on a real arm** — this is a different matter from the items above, but do not assume it is verified either. + +### Irreversible commands: do not run them on a calibrated arm + +All four of the following **overwrite or erase this arm's individually +identified dynamics model**, and there is **no undo**: + +| Command | Entry point | +| --- | --- | +| `0x25` | `arm.save_params()` | +| `0x32` | `arm.model().commit()` | +| `0x36` | `arm.params().reset_factory()` | +| `0x37` | `arm.model().revert()` | + +**The only safe approach: do it on a board whose calibration has no value.** + +**Warning**, also: `0x33` `arm.model().set_jm()` **should never be called** — +it rewrites the joint mapping (including signs), a single mistake makes the +arm **flail**, and the only two local recovery paths (`revert` / +`save_params`) are both in the table above ⇒ **there is no reliable way +back**. + +**Warning:** when stress-testing the CAN link, run only **`candump` +(read-only) — never `cangen`**: `can0` **is** the motor bus. + +--- + +## 17. `enable()` returns `ERR{0x10,0x06}` after a power cycle (latched) + +**Symptom** (measured on real hardware on 2026-09-28, twice): the board loses +power ⇒ the arm loses force and collapses under gravity ⇒ after power is +restored, `enable()` returns `ERR{0x10,0x06}` (latched; you must call +`reset()` first, **resending is useless**). + +**Warning:** **work out which kind it is first** — the two measurements have +**different shapes, and different remedies**: + +### Case A: all-axis latch (most common) + +```text +joint_fault=0x007F enabled=0 err=0 (7/7 axes) max torque amplitude ≈ 0.2 N·m (that is just noise) FB_STALE +``` + +**This is the normal state of "the motors are not enabled yet"**, not broken +hardware. With this sequence it recovered in the measurements: + +```text +clear_faults() -> joint_fault cleared (0x007F -> 0x0000) + Warning: faulted() is still 1 here — because mode=EMERGENCY also counts as faulted +reset() -> everything else cleared (flags zeroed, mode=INIT) +enable() -> succeeds; and **the motors start reporting immediately** (axes with err=0: 7/7 -> 0/7) +``` + +⇒ The criterion is **whether `err` becomes 1 after `enable()`**. If it +changed, the arm is genuinely good. + +### Case B: single-axis latch + +```text +joint_fault=0x0008 (axis 4) FAULT + WD_TRIPPED + FB_STALE +enable() returns ERR{0x10,0x06}; movej() returns ERR{0x01,0x06} "rigid hold on lost link (drop_hold)" +``` + +Measured: `clear_faults()` **changes nothing, bit by bit**; `reset()` clears +it **but it latches again within one check period**. + +**Warning:** the **difference between cases A and B has not been explained** +(the suspicion is that `drop_hold` and an ordinary `joint_fault` are not the +same bit, or that it is related to "that `enable()` never succeeded and the +motor never came online"). **This manual does not invent a unified theory for +them** — both cases are measurements, and they are written as measured. + +### General handles + +| Handle | How to use it | +| --- | --- | +| The `joint_fault` bit map | `0x0008` = bit3 = **axis 4** (1-based). You do not need to tear down the whole set — go and check that one motor | +| `fault_axes()` | Same, returns the list of axis numbers | +| The **`err` byte** | Looks like "whether that axis's motor **is reporting**": healthy = 1, bus down / not enabled = 0. Warning: the SDK only **passes it through verbatim** and does not interpret it, so use it as a **clue**, not as a conclusion | +| Whether `FB_STALE` is "current" or "historical" | Call `reset()` once and read again immediately: **it comes back** ⇒ the hardware still has a problem; **it does not come back** ⇒ it was only a historical latch | + +### Two things that are easy to misjudge + +- **`enabled=1` does not equal "the enable succeeded".** When the firmware loses contact with one of the motors it **powers the axis itself to hold it** ("rigid hold on lost link"). The upside is that the arm does not fall; the downside is that it **masks the fact that "the enable was actually rejected"**. +- **`reset()` drops the enable** (measured `enabled: 1→0`). It is a **software state reset, not an MCU reboot** (see §10): it does not trigger USB re-enumeration, and the same `Arm` object stays usable afterwards. diff --git a/UNVERIFIED_REGISTER.md b/UNVERIFIED_REGISTER.md new file mode 100644 index 0000000..3a4bcd4 --- /dev/null +++ b/UNVERIFIED_REGISTER.md @@ -0,0 +1,74 @@ +# The unverified register + +> **Delivery discipline**: "**not measured does not equal verified**". Anything that is +> "unverified / known to be imperfect / deliberately diverging from the reference +> implementation" is **registered here, truthfully** — a report has to let the reader tell +> "measured / compiled but never run / not done" apart. +> +> ⚠ This register has **one core rule, and that rule has a check of its own**: **every entry +> must carry a reference**. An entry like "there is a difference" with no reference **counts as +> not registered at all** — nobody can re-check it in the field, so the whole investigation has +> to be repeated from scratch. The check lives in `tests/test_register.cpp` (five columns, +> reference shape, ids exactly 1..N — all three are pinned). + +## Format conventions + +The header is a fixed five columns: `| # | Item | Nature | Status | Reference |` + +- A **reference** must match `file.extension:number` or a 7-40 digit hexadecimal commit hash. + ⚠ This repository **is now under Git**, so both forms are available; `file:line` remains the + primary one and the hash branch is the fallback for things that no single line anchors. +- ⚠ **Line numbers rot.** Where a **symbol name** can be written instead, write it (for example + `Arm::refresh_limits`); the line number is only the anchor "as of then". When a line number + does not match, **search by symbol name first**, then come back and correct the line number. +- ⚠ **Ids are exactly 1..N, with no gaps** (the check counts them one by one). Deleting an entry + means renumbering — that is a **loud** change, unlike a dangling reference, which is easy to + overlook. + +## Legend (nature) + +| Nature | Meaning | +|---|---| +| Diverges from Python | This repository **deliberately** behaves differently from the reference implementation `litearm-python`, so the affected scenarios **do not enter** the cross-language comparison | +| Inherited defect | The reference implementation **has it too**; this repository **preserves the original semantics** (porting discipline: never change semantics silently) | +| Unverified item | No offline **criterion** can tell the difference, or it was never run on hardware, so it is **forbidden** to invent a vacuous, always-true criterion just to fill the slot | +| Structural fact | Neither a defect nor a trade-off but a mechanistic statement, such as "this shape is unobservable at runtime" | +| Deliberate trade-off | One side was chosen knowingly; the cost is written down | + +## The register + +| # | Item | Nature | Status | Reference | +|---|---|---|---|---| +| 1 | **Same-frame throttling removed entirely** (the original has `_tx_allowed` / `set_tx_repeat_min_interval`). The original permits throttling of **any** frame, so a command awaiting an ACK can be dropped, its frame never reaches USB, the firmware never answers, and the caller can only wait out the full window and then report "no reply" — a misattributed cause. This repository **does not implement that mechanism** | Diverges from Python | Deliberate | `src/arm.cpp:480` (the note on `Arm::raw_write`) + README "Four deliberate divergences" (1) | +| 2 | **Four client-side prechecks**: non-finite target / soft-limit violation / speed / the `move_p` tolerance. All four refuse **locally, before a frame is sent**; the original sends the arguments straight through and lets the firmware `clampf` clamp them silently | Diverges from Python | Deliberate | `include/litearm/arm.hpp:311` (`precheck_q_`) + `tests/test_precheck.cpp` | +| 3 | **`connect()` reads n `0x24` frames to build the soft-limit cache** — the original's `connect()` **does not read soft limits**. So the downstream traffic during connection is not identically named on the two sides; cross-language comparison always **starts recording after `connect`** | Diverges from Python | Deliberate | `src/arm.cpp:1012` (`Arm::refresh_limits`) | +| 4 | **The `move_p` speed precheck** — the **only** one of the six motion entry points that the original lacks | Diverges from Python | Deliberate | `src/arm.cpp:1121` (the `precheck_speed_` call site in `Arm::move_p`) | +| 5 | **Seven read-only diagnostic accessors** (`is_connected` / `is_in_dfu` / `status_seq` / `msg_hz` / `banner_version` / `host_stats` / `options`) — the reference side has **none of them** | Diverges from Python | Deliberate | `include/litearm/arm.hpp:537` + `tests/test_accessors.cpp` | +| 6 | **Injectable time source** (`ArmOptions::clock`) — the reference side has no time abstraction. ⚠ This repository's `Clock` **deliberately provides no `sleep_until`** (another implementation has it): every wait here is a `cv.wait_for` woken by a notification, and moving sleep into the clock would lose that property. Cost: the fake clock is usable only for **pure comparison** criteria; using it on a wait path **hangs** | Diverges from Python | Deliberate | `include/litearm/clock.hpp:21` + README "Four deliberate divergences" (4) | +| 7 | **`port_string()` reports the port of the live link** (the original has no such public entry point; its private `_port` is only the constructor argument). This repository previously returned the constructor argument too, so it was always an empty string under auto-discovery, while **the header comment promised "or the one found by auto-discovery"** (comment and implementation were out of step; reproduced on hardware) | Diverges from Python | Fixed 2026-09-28 | `include/litearm/arm.hpp:584` | +| 8 | **`get_status_now()` can report success when the firmware refuses `0x40`.** The second half of the completion criterion is "any new status frame arrived", and the passive stream and our `0x40` are two independent lines, so when a frame lands in the window first, that `ERR` has not been delivered yet; the function glances at the queue (empty) and falls through to "returned success". **Quantified**: with zero frames delivered during the call, 150/150 threw correctly; with exactly 1 frame delivered, 26-35 of 150 reported success | Inherited defect | Semantics preserved | `src/arm.cpp:586` (the `done` predicate of `Arm::get_status_now`) + README "Known inherited gaps" | +| 9 | **The same root cause makes a safety guard fail open**: `Arm::reject_if_cart_in_flight()` calls `get_status_now()` to take a fresh frame and read `cart_busy`, and when it cannot get one it ought to **refuse conservatively**; the window above instead lets it "believe it got one", so it reads stale state, sees `cart_busy=0`, and **lets the command through**. That is exactly the "entering mid-trajectory and coasting to a stop on friction" side | Inherited defect | Semantics preserved | `src/arm.cpp:404` (`Arm::reject_if_cart_in_flight`) + `tests/test_zero_g.cpp` case `zero_g_is_refused_conservatively_when_the_status_is_unavailable` | +| 10 | **On firmware below 1.8.0, `license()` reports "no reply, timed out"** rather than "the firmware has no such command": it calls `expect` without passing `echo_cmd`, so `ERR{0x2F,0x00}` lands in a different queue and this entry point never sees it | Inherited defect | Semantics preserved | `src/arm.cpp:1376` (`Arm::license`) + README "Known inherited gaps" | +| 11 | **The Windows backend has never run on Windows** (it has never been compiled there either). Do **not** claim it is verified when changing it | Unverified item | Not done | `src/transport.cpp:395` (the whole block from `#else // _WIN32`) + `README.md:789` (the "Platforms" table) | +| 12 | **macOS shares the code path only and was never measured**; **port auto-discovery is unavailable there** (no sysfs) | Unverified item | Not done | `README.md:789` (the "Platforms" table) | +| 13 | **aarch64 has only a toolchain file written; it has never been compiled once** — no `aarch64-linux-gnu-g++` on this machine. All that was measured is that "the toolchain file parses correctly and fails cleanly at not finding a compiler" | Unverified item | Not done | `cmake/toolchain-aarch64-linux-gnu.cmake:16` (from `CMAKE_SYSTEM_NAME`) | +| 14 | **Full-feature hardware verification has passed** (2026-09-28, 14 groups / 68 assertions / 218 frames, **homing before and after every test**). Coverage: the full set of read-only accessors (including `get_tcp` / `ik` / `host_stats` / `options` / `msg_hz`), `params` read-back, `model` reads (probe / get_body / get_jm / status / get_gravity / **revert**), `set_speed` / `set_motion_mode` / `park` / `clear_faults` / `reset`, `movej` / `movej_sync`, `move_p`, `move_js` / `send_mit` / `send_mit_all`, `move_l` / `move_c` / `move_path`, `zero_g` start and stop, the full FF set (read the original value, write it back), `set_joint_param` / `set_joint_limits` (read the original value, write it back), `log().start/capture/stop`, `kin_bench`, a `disable` / `enable` round trip, and `emergency_stop` with recovery. A **frame-by-frame audit found no irreversible command**. ⚠ **Still not run on hardware**: the four irreversible ones (excluded by user decision), `activate()` (this board is already activated, so calling it only returns the aggregate tier `0x02`), and the **negative paths** on hardware (unreachable target / refusal / timeout were hit only once, on `move_p`, see below) | Unverified item, **mostly verified** | Measured | `README.md:798` (scope of hardware acceptance) + `/tmp/live_full.cpp`, `/tmp/live_recover.cpp` (**not brought into the repository**) | +| 15 | **`test_protocol_sync` has never been checked against the real firmware sources** — the firmware repository sits behind Gitee enterprise permissions and cannot be fetched anonymously. Its regexes were aligned with the upstream, but the drift detection was **only validated against a constructed fake firmware tree** | Unverified item | Not done | `tests/test_protocol_sync.cpp:39` (`fw_dir()` reads `LITEARM_FW_DIR`) | +| 16 | **Scope of the read-only hardware re-verification** (2026-09-28): `/tmp/live_queries.cpp` ran **38 frames** on `Litearm1.8.0-7J`, all of them queries (a hard-coded whitelist criterion); it exercised `0x43` / `0x42` / `0x34` / `0x35` / `0x38` / `0x39` / `0x2B` / `0x2C` / `0x2F` / `0x24` / `0x40` plus the four prechecks. ⚠ With the arm **disconnected**, `joint_fault=0x007F` (all seven axes latched) and `mode=EMERGENCY`; connected, they go to `0x0000` / `MOVE_J` — that is, those fault bits mean "no motor feedback on the CAN bus" | Structural fact | Recorded | `README.md:798` + `/tmp/live_queries.cpp:26` (the whitelist) | +| 17 | **The hardware probes are not in the repository** (`/tmp/live_queries.cpp` and the rest), so the "downstream frame whitelist audit" safeguard **is not reproducible inside the repository**. The things it verified have separate offline criteria here, but the claim "it really only sent queries" currently rests on that one output | Structural fact | Not brought in | `/tmp/live_queries.cpp:26` (the `kQueryOnly` whitelist; not under version control) | +| 18 | **Line numbers rot, and the criterion only checks the shape.** This is the weakest link in the current arrangement: the check verifies that a reference is *present and well-formed*, not that the line number still points at the sentence it claims to | Structural fact | Known limitation | `tests/test_register.cpp:85` (`has_reference` checks shape only) + the corrected references in entries 11, 12, 14 and 16 of this register | +| 19 | **`framing.hpp` was extracted to eliminate duplication** (POSIX and Win32 each used to carry their own framing state machine). **The new code is compiled and unit-tested**, but the two Win32 **call sites** are still inside `#ifdef _WIN32` and cannot be compiled on Linux | Deliberate trade-off | Deliberate | `include/litearm/framing.hpp:23` + `tests/test_framing.cpp` | +| 20 | **Packaging has been measured on hardware for the static library only.** `BUILD_SHARED_LIBS=ON` goes through the same install and export rules, but the **shared artifact has never been measured**, and there is no symbol visibility control (`-fvisibility=hidden` plus an export macro), so a shared library exports every symbol | Unverified item | Not done | `CMakeLists.txt:124` (the install section) | +| 21 | **The first `connect()` after USB re-enumeration times out on the handshake** (`get_firmware` gets no reply); waiting a few seconds and retrying works. Measured on hardware (2026-09-28): that day, after **4** re-plugs / re-passthroughs, **the first connection failed every time** and every retry succeeded; it never happened on a warm link. **The reference implementation does not retry either** (its `connect()` also sends one `0x41`, waits 1.5 s, then `close()`s and throws), so this is **inherited behaviour**, not something the port introduced | Inherited defect | Semantics preserved (not fixed) | `src/arm.cpp:235` (`raw_write(proto::CMD_GET_FIRMWARE)`) + `README.md:690` ("Known inherited gaps") | +| 22 | **How to clear the latch after a power cycle — measured (2026-09-28, twice on hardware)**. ⚠⚠ **An early version of this entry drew the conclusion too broadly** (from the single "only J4 latched" case it concluded "the root cause is electrical and software cannot clear it"); a later measurement of an all-axis latch **overturned that generalisation**. **Two measurements**:
**(a) all seven axes latched**, `joint_fault=0x007F` + `enabled=0` + `err=0` (7/7) + peak torque amplitude about 0.2: `clear_faults()` clears `joint_fault` (`0x007F` goes to `0x0000`, but `faulted` is still 1, because `mode=EMERGENCY` also counts as faulted) then `reset()` clears the rest (`flags` go to zero, `mode=INIT`) then **`enable()` succeeds and the motors immediately start reporting** (axes with `err=0` go from 7/7 to 0/7). So **this sequence is a usable recovery path**.
**(b) single-axis latch**, `joint_fault=0x0008` (J4, which the firmware calls `drop_hold`): `clear_faults()` **changes nothing, bit by bit**; `reset()` clears it **but it re-latches within one check period**. ⚠ The difference between the two cases **has not been established** (it may be that `drop_hold` and an ordinary `joint_fault` are not the same bit, or it may relate to "that `enable()` never succeeded and the motor never came online") — so **no unified theory is invented**; both cases are recorded as they happened.
**Also measured along the way**: `reset()` **drops enable** (`enabled: 1` goes to `0`); the `err` byte looks like "is this axis's motor reporting" (healthy = 1; bus down / not enabled = 0), but the SDK only passes it through and **does not interpret it** | Unverified item, **partly characterised** | Measured | `TROUBLESHOOTING.md:367` (section 17) + the measured output of `/tmp/live_recover.cpp` and `/tmp/live_reset.cpp` (**not brought into the repository**) | + +## What is not in this register + +- **Things that are fixed and guarded by a criterion** — that is commit history, not "unverified". +- **The details of the wire protocol itself** (frame format, CRC, status-frame layout, dual ID) — those are pinned entry by entry in `test_protocol.cpp` and were checked by the cross-implementation comparison at 19/19, so they count as verified. + +## How to use this register + +1. Before saying "it has been tested" in a report, scan here for a related entry. +2. When you change any entry above, change its **Status** column and its reference **at the same time** — the reference is load-bearing, not decoration. +3. Adding an "unverified" entry is easy. **Deleting** one requires being able to say why it now counts as verified. If you cannot say it, do not delete it. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..7a9a272 --- /dev/null +++ b/build.sh @@ -0,0 +1,92 @@ +#! /usr/bin/env bash +# ============================================================================ +# 构建 + 跑测试 (离线, 不碰硬件)。 +# +# ./build.sh # Release 构建 + ctest +# ./build.sh --werror # 把编译警告当错误 +# ./build.sh --asan # AddressSanitizer + UBSan (查越界/泄漏) +# ./build.sh --tsan # ThreadSanitizer (查数据竞争) —— ⚠ 见文件末尾的"TSan 现状" +# +# ⚠ **TSan 现状 (2026-09-24, 未定论)**: 它是**分诊用**的可选工具, **不是**准入门禁。 +# 本机需要 `setarch -R` (关 ASLR) 才能跑 —— 内核 vm.mmap_rnd_bits 偏大时 TSan 会直接 +# `FATAL: unexpected memory mapping`, 还没走到被测代码。脚本已自动加上。 +# 当前它会报**大量** `Ack::queues` 上的 data race (11/19 个套件)。逐条人工核对过: +# `queues` 的**全部 6 处**访问点都在 `mu` 之下 (`deliver` / `drain_for` / `wait` / +# `get_status_now` 的 done 回调与显式 lock_guard), 代码层面**找不到**未加锁的访问, +# 且 TSan 打印的 `mutexes:` 是**报告生成那一刻**的持锁集合、不是访问发生时的 ⇒ +# "两侧都显示 M28" 并不能证明两侧当时都在锁内。 +# ⇒ **未定论**: 既没有证据说它是真竞争, 也没有证据说它是 TSan 误报。**先记账, 别当绿。** +# 下一个要查的人从这里开始: 单用例 `--filter cmd_enable_and_disable_round_trip` 就能 +# 复现 55 条告警, 最小化路径很短。 +# +# ⚠⚠ **`exitcode=0` 有一个反噬, 别踩 (2026-09-28 实测)**: TSan 的**死锁检测器**只有一个 +# 64 格的固定数组 (`sanitizer_deadlock_detector.h:67`), 程序里被它同时追踪的 mutex +# 超过 64 个就 `FATAL: CHECK failed (0x40, 0x40)` **当场自杀**。而自杀走的也是 +# `internal__exit(exitcode)` ⇒ **退出码 0** ⇒ **ctest 报 Passed**。 +# ⇒ 用例若在**同一次运行里创建几十个 `Arm`** (每个 `Ack` 带一套 mutex), 就会撞上它: +# 你会看到"全绿", 而实际上用例跑到一半就没了。 +# ⇒ 判据不是看 ctest, 是看**框架自己那行汇总** (`全部通过: N 通过 / M 失败`) 有没有出现, +# 以及 stderr 里有没有 `FATAL: ThreadSanitizer CHECK failed`。脚本已把两者都留在输出里。 +# 绕过办法 (临时): `TSAN_OPTIONS=...:detect_deadlocks=0`。 +# ./build.sh --clean # 先删掉 build/ +# ============================================================================ +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BUILD="$DIR/build" +EXTRA=() +RUNNER=() +TSAN=0 +CLEAN=0 + +for a in "$@"; do + case "$a" in + --werror) EXTRA+=("-DLITEARM_WERROR=ON") ;; + --asan) EXTRA+=("-DCMAKE_BUILD_TYPE=Debug" + "-DCMAKE_CXX_FLAGS=-fsanitize=address,undefined -fno-omit-frame-pointer") ;; + --tsan) TSAN=1 + BUILD="$DIR/build-tsan" + EXTRA+=("-DCMAKE_BUILD_TYPE=Debug" "-DCMAKE_CXX_FLAGS=-fsanitize=thread -g") ;; + # ⚠⚠ `--clean` 只**记意愿**, 删除留到循环之后 —— 参数按顺序处理, 而 `--tsan` 是在 + # 循环的**后面几个迭代**里才把 `BUILD` 改成 `build-tsan` 的。原先这里直接 + # `rm -rf "$BUILD"` 会让 `./build.sh --clean --tsan` 删掉的是 **`build/`** + # (非 tsan 的构建目录)、而 `build-tsan/` 原封不动 —— 一个**删错目录**的哑错误: + # 不报错、不提示, 只是另一个目录没了 (实测 2026-09-28: 这个坑让 `build/` + # 凭空消失过两次, 每次都先怀疑是自己手滑)。 + --clean) CLEAN=1 ;; + -h|--help) sed -n '2,13p' "$0"; exit 0 ;; + *) echo "未知开关: $a"; exit 2 ;; + esac +done + +# ⚠ 删除排在这里 (循环之后) —— 此时 `BUILD` 才是**最终**那个目录。理由见 `--clean` 那条。 +if [ "$CLEAN" = 1 ]; then + rm -rf "$BUILD" +fi + +# ⚠ TSan 在本机需要**关掉 ASLR** 才能跑: 内核的 vm.mmap_rnd_bits 偏大, +# 否则每个用例都直接 `FATAL: ThreadSanitizer: unexpected memory mapping` (还没到被测代码)。 +# `setarch -R` 就是"这一条命令关 ASLR"。 +if [ "$TSAN" = 1 ]; then + if setarch -R true >/dev/null 2>&1; then + RUNNER=(setarch -R) + else + echo "⚠ setarch -R 不可用; TSan 可能直接 FATAL (见 build.sh 里的说明)" + fi + # 两个 TSan 开关, 缺一个都会让"用例通过"被误报成"用例失败": + # * `halt_on_error=0` —— 否则检测到一条报告就**当场 exit(66)**, 被测断言根本跑不到 + # (fork 用例里的子进程就是这样整批挂掉的); + # * `exitcode=0` —— 否则即使跑完了, TSan 也会在进程退出时把退出码设成 66, + # 而 ctest 只看退出码 ⇒ 打印着"**全部通过: 12 通过 / 0 失败**"的用例被判 Failed。 + # (实测: 单跑 test_params 输出"全部通过"而 ctest 报 Failed, 就是这一条。) + # 本仓的口径: TSan 是**分诊工具**, 不是门禁 —— 报告要看得见, 但不该改变被测行为。 + export TSAN_OPTIONS="halt_on_error=0:exitcode=0${TSAN_OPTIONS:+:$TSAN_OPTIONS}" +fi + +cmake -S "$DIR" -B "$BUILD" -DCMAKE_BUILD_TYPE=Release "${EXTRA[@]}" +cmake --build "$BUILD" -j"$(nproc 2>/dev/null || echo 4)" +cd "$BUILD" +if [ "$TSAN" = 1 ] && [ ${#RUNNER[@]} -gt 0 ]; then + "${RUNNER[@]}" ctest --output-on-failure +else + ctest --output-on-failure +fi diff --git a/cmake/litearm.pc.in b/cmake/litearm.pc.in new file mode 100644 index 0000000..b6d29d7 --- /dev/null +++ b/cmake/litearm.pc.in @@ -0,0 +1,16 @@ +prefix=@CMAKE_INSTALL_PREFIX@ +exec_prefix=${prefix} +libdir=${exec_prefix}/@CMAKE_INSTALL_LIBDIR@ +includedir=${prefix}/@CMAKE_INSTALL_INCLUDEDIR@ + +Name: litearm +Description: LiteArm STM32 直连后端 (薄协议 SDK, C++17, 零外部依赖) +URL: https://github.com/nexform-tech/litearm-python +Version: @PROJECT_VERSION@ +Libs: -L${libdir} -llitearm +# ⚠ `Libs.private` 只在**静态链接**时被 `pkg-config --static` 带出来。 +# 本库的公开头里有 std::thread / std::mutex ⇒ 静态链接的客户需要 pthread。 +# (glibc 2.34 起 pthread 已并入 libc, 故在很新的系统上它是冗余的 —— 但带上无害, +# 而在老系统上缺了它客户会看到一堆 undefined reference to `pthread_*`。) +Libs.private: -lpthread +Cflags: -I${includedir} diff --git a/cmake/litearmConfig.cmake.in b/cmake/litearmConfig.cmake.in new file mode 100644 index 0000000..e14aa31 --- /dev/null +++ b/cmake/litearmConfig.cmake.in @@ -0,0 +1,25 @@ +@PACKAGE_INIT@ + +# litearm 的 CMake 包配置 —— 让客户能写: +# +# find_package(litearm CONFIG REQUIRED) +# target_link_libraries(my_app PRIVATE litearm::litearm) +# +# ⚠ 导入的 target 由 `litearmTargets.cmake` 提供 (它由顶层 CMakeLists 的 +# `install(EXPORT ...)` 生成)。这里只负责把它拉进来 + 做常规检查。 +# +# ⚠⚠ **不要在这里手写 `add_library(litearm::litearm ALIAS ...)`** —— 那会和导出文件里 +# 的同名 target 撞车。客户拿到的 `litearm::litearm` 来自导出文件, 不是这里的别名。 +include("${CMAKE_CURRENT_LIST_DIR}/litearmTargets.cmake") + +# 传递依赖 (Threads) 由导出文件里的 INTERFACE_LINK_LIBRARIES 带上, 但 CMake 需要 +# 那个 find_package 在本配置文件的作用域里**被发现过**才能解析它。 +# ⚠ 少了这一句, 客户在 Linux 上 `find_package(litearm)` 会报 +# "The link interface of target litearm::litearm contains: Threads::Threads but the +# target was not found" —— 而库本身编得好好的, 报错指向的地方极易误导。 +include(CMakeFindDependencyMacro) +if(NOT WIN32) + find_dependency(Threads) +endif() + +check_required_components(litearm) diff --git a/cmake/toolchain-aarch64-linux-gnu.cmake b/cmake/toolchain-aarch64-linux-gnu.cmake new file mode 100644 index 0000000..289b2dc --- /dev/null +++ b/cmake/toolchain-aarch64-linux-gnu.cmake @@ -0,0 +1,36 @@ +# aarch64 (Linux) 交叉编译工具链文件。 +# +# 用法: +# cmake -S . -B build-arm64 -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-aarch64-linux-gnu.cmake +# cmake --build build-arm64 -j +# +# ⚠⚠ **本仓从未在 aarch64 上运行过** —— 只做过"能否编译"这一层。对齐、端序、串口行为 +# 在 arm64 上**无人守** (端序这一条风险其实很低: 线协议是逐字节组装的, 见 protocol.cpp; +# 但"很低"不是"验过")。这条限制写在 README 的"平台"一节里, 别读成"已支持 arm64"。 +# +# ⚠ 依赖: `aarch64-linux-gnu-g++` (Debian/Ubuntu 上是 `g++-aarch64-linux-gnu`)。 +# 本机实测 (2026-09-28): **未安装** ⇒ 本文件在本机跑不通, 属于"写好了但没验过"。 +# ⚠ 这句话留着是**刻意的**: 它是登记册里那种"未验证"的诚实记录, 不要因为某天装上 +# 工具链跑通了就把它删掉 —— 要删就把它改成"实测通过 + 日期 + 机器"。 + +set(CMAKE_SYSTEM_NAME Linux) +set(CMAKE_SYSTEM_PROCESSOR aarch64) + +set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc) +set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++) + +# 只让 find_* 在**目标**根下找库/头, 但程序 (如工具) 仍在本机找。 +set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) +set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) +set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) + +# ⚠ 交叉时必须关测试。本仓的测试**不依赖 gtest**, 所以它们**编得过** —— 问题在**跑**: +# ctest 会去执行 aarch64 二进制, 而本机跑不了 ⇒ 一堆 Failed, 且报错形状是 +# "Exec format error" 这种离"你交叉了"很远的消息。 +# 明确拦下比留一堆跑不了的 target 好 (顶层 CMakeLists 里还有第二道, 见那里)。 +set(LITEARM_BUILD_TESTS OFF CACHE BOOL "" FORCE) + +# ⚠ FORCE 的副作用: toolchain 文件在命令行 `-D` **之后**处理 ⇒ 显式 +# `-DLITEARM_BUILD_TESTS=ON` 会被静默覆盖成 OFF。方向是安全的 (宁可少编也不产出 +# 跑不了的 target), 故保留 FORCE —— 但要接交叉跑测试, 请用 CROSSCOMPILING_EMULATOR +# (qemu-aarch64) 而不是把这个 FORCE 去掉。 diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index aaba954..a0efab9 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -1,477 +1,396 @@ -# litearm-cpp Developer Guide & API Reference +This document describes how the litearm C++ SDK is built — the wire protocol, the read and write +paths, and the safety invariants that hold them together — and it is written for anyone who is +going to modify `src/`, `include/litearm/`, or the tests, or who needs to know why part of the API +behaves the way it does. -`litearm-cpp` is the C++ client SDK for the LiteArm robotic arm (C++17). -Connect to the arm control service over the network — ideal for low-latency, -embedded scenarios. **A pure client with no kinematics/dynamics dependencies.** +# litearm-cpp Developer Guide -```text -Your program ──→ Arm control service ──→ Arm / CAN -``` +The C++ SDK for the LiteArm robotic arm — it talks straight to the firmware over a USB serial +link. ---- +Planning, kinematics and dynamics all live in the firmware; the host side does exactly three +things: **encode and decode frames**, **send commands**, and **judge arrival**. -## Table of Contents - -- [1. Requirements & Build](#1-requirements--build) - - [1.1 Install protobuf](#11-install-protobuf) - - [1.2 Build](#12-build) -- [2. Quick Start](#2-quick-start) -- [3. Connection Management](#3-connection-management) -- [4. Value Type: LiteArmValue](#4-value-type-litearmvalue) -- [5. API Reference](#5-api-reference) - - [5.1 Computation (no motors driven)](#51-computation-no-motors-driven) - - [5.2 Motion Control](#52-motion-control) - - [5.3 State Reading](#53-state-reading) - - [5.4 Emergency Stop / Enable](#54-emergency-stop--enable) - - [5.5 DIRECT Mode — Per-frame MIT Direct Control](#55-direct-mode--per-frame-mit-direct-control) - - [5.6 Parameters](#56-parameters) - - [5.7 Peripheral Devices](#57-peripheral-devices) - - [5.8 System / Settings / Trajectories / Devices / Teleop](#58-system--settings--trajectories--devices--teleop-extended-interfaces) -- [6. Exceptions](#6-exceptions) -- [7. Safety Notes](#7-safety-notes) -- [8. Comparison with litearm-python](#8-comparison-with-litearm-python) - -## 1. Requirements & Build - -| Item | Requirement | -|---|---| -| Compiler | C++17 (GCC 8+ / Clang 7+) | -| CMake | 3.16+ | -| Protobuf | 3.19+ (headers, library, `protoc`) | -| Google Test | Tests only (downloaded automatically) | - -### 1.1 Install protobuf - -Check your version first: +## Contents -```bash -protoc --version # needs "libprotoc 3.19" or newer -``` +1. [Module map](#1-module-map) +2. [Wire protocol](#2-wire-protocol) +3. [Read path: one reader + split queues](#3-read-path-one-reader--split-queues) +4. [Write path: one write point + two hooks](#4-write-path-one-write-point--two-hooks) +5. [Cartesian: FIFO pairing and the absorb budget](#5-cartesian-fifo-pairing-and-the-absorb-budget) +6. [Lock order](#6-lock-order) +7. [Safety invariants](#7-safety-invariants) +8. [Error surface](#8-error-surface) +9. [Tests](#9-tests) +10. [Comparison with the Python version](#10-comparison-with-the-python-version) -If it is missing or too old, install it: +--- -```bash -# Debian / Ubuntu (24.04+ ships protobuf 3.21) -sudo apt update && sudo apt install -y protobuf-compiler libprotobuf-dev - -# or conda (any OS) -conda install -c conda-forge protobuf=3.19.6 - -# or build from source (any OS) -cd /tmp -curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v3.19.6/protobuf-cpp-3.19.6.tar.gz -tar xzf protobuf-cpp-3.19.6.tar.gz && cd protobuf-3.19.6 -./configure && make -j$(nproc) && sudo make install -sudo ldconfig -``` +## 1. Module map -### 1.2 Build +| Header | Python counterpart | Responsibility | +| --- | --- | --- | +| `protocol.hpp` | `_protocol.py` | frame encode/decode / CRC16 / constants / status-frame layout / version parsing / the coverage contract | +| `errors.hpp` | `errors.py` | exception hierarchy + the `(cmd, code)` semantics table + the `ERR` -> exception mapping | +| `rot.hpp` | `_rot.py` | pure rotation/pose math (ZYX intrinsic, the same convention as the firmware's `kin.c`) + pose-shape normalization | +| `state.hpp` | `state.py` | `RobotState` / `JointState` — the object-oriented view of a status frame | +| `transport.hpp` | `transport.py` | transport interface + the real serial port (POSIX/Win32) + port discovery + the in-process port registry | +| `ack.hpp` | `_Ack` in `arm.py` | the **single reader** + queues split by (uplink id, echoed code) + `expect` | +| `cart.hpp` | `cart.py` | Cartesian FIFO pairing / the absorb budget / `CartPlan` / the three entry points | +| `arm.hpp` | `arm.py` | the `Arm` main class / `Msg` / `LicenseInfo` / zero-gravity sessions / CLI | +| `params.hpp` | `params.py` | joint-level parameters (0x22/0x23/0x24/0x36) | +| `model.hpp` | `model.py` | online import of the dynamics model (0x30..0x39) | +| `log.hpp` | `log.py` | 300 Hz capture (0x2D/0x2E) | +| `diagnostics.hpp` | `diagnostics.py` | firmware self-test (0x49) + text parsing | +| `testing.hpp` | `testing.py` | a fake transport with scripted replies — it makes the whole test suite run offline | +| `msg.hpp` | `Msg` in `arm.py` | the return envelope `{value, hz, timestamp}` | + +**Design boundaries** (deliberately not done): an arbitrary `fk(q)` — the firmware's `CMD_GET_TCP` +can only compute the pose of the **currently fed-back q**, and there is no downlink command that +takes a q and returns a pose; impedance control — advanced cases go through pylitearm + server. +The host side does no planning and no kinematics; the heavy computation already lives in the +firmware. -```bash -mkdir build && cd build -cmake .. # system-installed protobuf is found automatically -cmake --build . -j$(nproc) -ctest --output-on-failure # run tests -``` +--- -Protobuf in a custom prefix (e.g. an activated conda env): +## 2. Wire protocol -```bash -cmake .. -DPROTOBUF_ROOT=$CONDA_PREFIX +```text +Frame: SOF(0xA5) CMD(1B) LEN(1B) PAYLOAD(0..255) CRC16_LO CRC16_HI +CRC: CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, no final XOR), covering [SOF..PAYLOAD] ``` -## 2. Quick Start - -```cpp -#include +Uplink status frames at 100 Hz (two layouts; the trailing `joint_fault u16` is new as of firmware +1.5.0): -int main() { - auto arm = litearm::Arm("tcp/192.168.1.100:7447", "armA"); - - auto state = arm.get_state(); // std::optional - if (state) { - auto& q = state->q; // joint positions - } +| Layout | Firmware | Length (7J / 1J) | +| --- | --- | --- | +| `4 + 21N` | ≤ 1.4.x | 151 / 25 | +| `6 + 21N` | ≥ 1.5.0 | 153 / 27 | - arm.movej({0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, /*speed=*/0.5); - auto [pos, rot] = arm.fk({0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}); +One joint is 21 B = `q f32 + dq f32 + tau f32 + t_mos f32 + t_coil f32 + err u8`. +Bit allocation in `flags`: **bit0..5 = safety flags**, **bit6..8 = mode**, **bit9 = enabled**, +**bit10 = cart_busy** — so `flag_names` looks only at bit0..bit5 (see the +`for (k = 0; k < 6; ++k)` in `protocol.cpp`; raising that bound would turn mode/enabled/cart_busy +into "fault bits" as well). - arm.request_stop(); // high-priority emergency stop - arm.close(); - return 0; -} -``` +**Caution:** two IDs are dual-purpose: `0x49` (downlink `CMD_KIN_BENCH` / uplink +`RSP_JOINT_PARAM`) and `0x41` (downlink `CMD_GET_FIRMWARE` / uplink `RSP_DETAIL`, the latter being +a **dead registration**). You tell them apart by **direction**; a single ID table cannot look them +up. -## 3. Connection Management +The coverage contract `command_coverage()` (49 entries) is the carrier for the assertion that +"every command the firmware implements has an SDK entry point"; `tests/test_protocol.cpp` pins it +against the table entry by entry. -```cpp -litearm::Arm arm("tcp/127.0.0.1:7447", "armA", - /*transport=*/nullptr); // optional pre-configured connection (advanced) -arm.close(); // close the connection -``` +### 2.1 The framer `FrameReader` (`include/litearm/framing.hpp`) -- `Arm` is non-copyable but movable; `arm_id()` returns the arm identifier. -- `get_state(refresh=false)` synchronously reads the state cache; returns - `std::nullopt` before the first update. +The state machine that cuts the byte stream into frames (SOF scanning / the half-frame window / +the noise trace) is **a class of its own**, and the two `SerialTransport` backends (POSIX and +Win32) **share the same one**. -## 4. Value Type: LiteArmValue +**Warning:** **its only reason to exist is to kill duplication.** The two backends **each used to +have their own copy** of the same state machine, with a comment in the code claiming it was "the +same logic as the POSIX side" — that was **a copy that drifts**, and the Windows branch **does not +even compile** on Linux, so a drift would not have been caught on the spot by anyone. After the +extraction the two backends hold nothing but I/O, and this logic is **compiled and unit-tested** +(9 cases in `tests/test_framing.cpp`) instead of being verifiable only indirectly, through a pty. -Interface parameters and return values use the dynamic value type `LiteArmValue` -(a tagged union). Construction and access: +**Caution:** **`feed()` has take-it semantics, not try-it semantics** — dropping its return value +drops that frame, and the symptom is extremely hard to see (data is flowing, yet you cannot read a +single frame). This was hit **twice in the same afternoon** in practice. The standard usage: ```cpp -using litearm::LiteArmValue; - -LiteArmValue v = LiteArmValue::from_vec({0.1, 0.2}); // vector → List -LiteArmValue m = LiteArmValue::from_mat({{1,0,0},{0,1,0},{0,0,1}}); // nested → List - -double x = v.as_list()[0].as_double(); // access -auto vec = v.to_vec(); // extract vector -auto mat = m.to_mat(); // extract vector> -auto cfg = LiteArmValue::make_map({{"type", LiteArmValue("gripper")}}); +if (auto f = r.feed(chunk.data(), chunk.size())) return *f; // this batch may already hold a whole frame +while (auto f = r.feed(nullptr, 0)) { /* handle f */ } // then drain the empty buffer ``` -`Kind` enum: `Null / Bool / Int / Double / String / Bytes / List / Map`. Convenience -aliases `Vec7`, `Mat3x3`. - -## 5. API Reference +**Caution:** `tick(now)` returns **whether the buffer was modified**: if it was, you must retry +assembly immediately (a real frame very likely follows right behind the fake header you just +dropped). Without this return value, the caller either misses frames or spins. -> Motion methods return `bool`; pure-computation methods return data; other -> interfaces return `LiteArmValue`. - -### 5.1 Computation (no motors driven) +--- -| Method | Description | -|---|---| -| `fk(q)` | Forward kinematics → `(position, rotation matrix)` | -| `ik(pos_d, R_d, q_seed=nullopt)` | Inverse kinematics → `(q, success)` | -| `plan_movel(q_start, pose_goal)` | Cartesian line path planning → joint path | -| `plan_movec(q_start, pose_via, pose_goal)` | Circular-arc path planning (via a waypoint) | -| `plan_movep(q_start, poses_goal)` | Multi-waypoint path planning | +## 3. Read path: one reader + split queues -### 5.2 Motion Control +**Exactly one place in the whole package touches `transport->read_frame`**: `Ack::reader_loop`. It +obeys only two rules: -| Method | Description | -|---|---| -| `movej(q_target, speed=1.0, settle_s=1.0, max_cycles=nullopt, allow_start_collision_recovery=false)` | Joint-space point-to-point | -| `home(speed=0.3, settle_s=0.5, max_cycles=nullopt)` | Home all joints to zero — bypasses joint-limit and self-collision path checks | -| `recover_joint_limits(speed=0.05, settle_s=0.5, max_cycles=nullopt, inset_rad=0.0)` | Slowly return out-of-limit joints to the safe boundary (requires server `allow_limit_recovery=True`) | -| `movel(pose_goal, speed=1.0, settle_s=0.8, max_cycles=nullopt)` | Cartesian line move | -| `movec(pose_via, pose_goal, speed=1.0, settle_s=0.8, max_cycles=nullopt)` | Circular arc move | -| `movep(poses_goal, speed=1.0, settle_s=0.8, max_cycles=nullopt)` | Multi-waypoint move with corner blending | -| `replay_joint_path(q_path, speed=1.0, settle_s=0.5, goto_start=true, goto_speed=0.3, max_cycles=nullopt)` | Replay a joint sequence | -| `replay_trajectory(traj, speed=1.0, goto_start=true, goto_speed=0.3, max_cycles=nullopt, check_singularity=true)` | Replay a `JointTrajectory` (also a `LiteArmValue` overload) | -| `replay_timed_trajectory(traj_q, traj_t, speed=1.0, goto_start=true, goto_speed=0.3, simplify_tolerance_rad=0.01, max_cycles=nullopt)` | Replay on the original time axis (auto-stretch for safety) | -| `play_trajectory(trajectory, speed=1.0, goto_start=true, goto_speed=0.3, verify_robot=true, simplify_tolerance_rad=0.01, max_cycles=nullopt)` | Replay a saved trajectory (`JointTrajectory` or server-side path string, dual overload) | -| `record_trajectory(output="trajectories", duration_s=nullopt, sample_rate_hz=100.0, filter_alpha=0.15, name=nullopt)` | Record by drag → `JointTrajectory` | -| `hold(kp_scale=3.0, max_cycles=nullopt)` | Hold with higher stiffness | -| `zero_gravity(max_cycles=nullopt, duration_s=nullopt, measured_overspeed_factor=nullopt, vel_max=nullopt)` | Zero-gravity (free-drag) mode | -| `joint_impedance(q_des, K, B, tau_max=nullopt, engage_sec=0.3, max_cycles=nullopt)` | Joint-space impedance control | -| `cartesian_impedance(q_des, K_cart, B_cart, v_des=nullopt, tau_max=nullopt, engage_sec=0.3, max_cycles=nullopt, sigma_min_thresh=nullopt, max_ori_err=nullopt, measured_overspeed_factor=nullopt, vel_max=nullopt)` | Cartesian impedance control | -| `joint_follow(K=nullopt, B=nullopt, speed_limit=nullopt, accel_limit=nullopt, engage_sec=0.3, max_cycles=nullopt, duration_s=nullopt)` | Follow an external target | +1. **Deliver only, never decide** — "whose frame is this" is decided by the **queue**, not by the thread; +2. **Die loudly** — the cause of death goes into `reader_error` and wakes every waiter; never exit silently. -### 5.3 State Reading +A frame's **ownership = which queue it lands in**, keyed by `(uplink id, echoed code or kNoEcho)`: -| Method | Description | -|---|---| -| `get_state(refresh=false)` | Latest cached state (`std::optional`, sync) | -| `get_tcp_pose()` | Current TCP pose → `(position, rotation matrix)` | +- only `RSP_ACK` / `RSP_ERR` have "the original command code" in `payload[0]`, so only they need the second dimension; for every other frame `payload[0]` is **data** (joint index / item index / status byte). +- So `ACK{0x10}` and `ACK{0x11}` naturally land in **two** different queues — two concurrent commands eating each other's replies is **structurally impossible**. -`RobotState` fields: `q / dq / tau / faults / errs / temps / state / feedback / watchdog / robot_serial / config_checksum_sha256`. +Three special deliveries: -### 5.4 Emergency Stop / Enable +| Frame | Destination | Reason | +| --- | --- | --- | +| `RSP_STATUS` | the **single slot** `state` + `status_seq++` | a 100 Hz **broadcast** frame. In a queue it would fill `kQueueMax` within 0.6 s; and take-it-and-it-is-gone queue semantics never fit a broadcast | +| `RSP_CART_PLAN` | handed to `CartPending::on_reply` **outside** the lock | avoids calling another subsystem's lock while holding the global lock | +| everything else | the `queues[(id, echo)]` queue | each queue caps at `kQueueMax`; going over drops the **oldest** and bumps `dropped` | -| Method | Description | -|---|---| -| `request_stop()` | High-priority emergency stop (independent channel) | -| `clear_stop()` | Clear the stop condition and return to ready | -| `enable()` | Enable all motors and lock the current pose | -| `disable()` | ⚠️ Disables all motors (the arm drops under gravity!), CAN stays connected | +`expect(want, timeout, label, raise_on_err, echo_cmd, err_waits_for_ack)`: when `want` is +`ACK`/`ERR`, **`echo_cmd` is mandatory** (without it you cannot tell "my ACK" from "someone else's +ACK"); -### 5.5 DIRECT Mode — Per-frame MIT Direct Control +- `err_waits_for_ack=true`: an `ERR` matching `echo_cmd` is **not final** — keep waiting for `ACK{echo_cmd}`; if you find it ⇒ that ERR was **someone else's** (this command was in fact accepted, do not throw); if the window runs out with only the ERR ⇒ that ERR belongs to this command, throw it. The cost: **a command that really is rejected waits out the whole window**. That is why only the three Cartesian entry points turn this switch on. -> DIRECT mode is LiteArm's per-frame MIT direct control channel. Send five-parameter -> (kp/kd/q_ref/dq_ref/tau_ff) commands at a typical 250Hz to control joint motors in real-time, -> with 4 never-disableable core safety guardrails built in. +**Caution:** **this shape — "an accepted command always gets an ACK, a rejected one never gets a +compensating ACK" — holds only on the generating side, not on the arriving side**: the acceptance +`ACK{cmd}` **can itself be dropped** (the firmware's reply FIFO drops the newest when full). -**Comparison with ordinary motion control:** +### Three kinds of reader -| Feature | Ordinary motion control (`movej` etc.) | DIRECT mode (`send_mit`) | +| Kind | Where frames are taken | Examples | | --- | --- | --- | -| Control method | Target position + velocity, auto-planned | Per-frame 5-parameter MIT command | -| Frame rate | One call, auto-execution | User loop control (typical 250Hz) | -| Blocking | Blocking, waits for completion | Non-blocking, returns immediately | - -**Entry and exit:** +| queue waiter | `Ack::wait` (waiting on a named queue) | `expect` / `enable` / parameter read-back | +| driver-style reader | `Arm::pump_until` (**claims not a single frame**) | `read_status` / `get_status_now` / `wait_settled` | +| event waiter | the condition variable on `token` | `CartPending::wait` | -- **Entry**: Automatically enters DIRECT mode on the first `send_mit` call -- **Exit**: `request_stop()` / watchdog timeout / motor fault +The driver-style reader is the necessary consequence of "broadcast frames never enter a queue": it +merely **waits for the single slot to advance**, so several concurrent `get_state()` calls no +longer starve one another. -#### send_mit — Send MIT Control Frame +--- -**Description:** Async publish a five-parameter MIT control frame to the arm command -channel. Non-blocking, returns immediately. First call auto-enters DIRECT mode. +## 4. Write path: one write point + two hooks -**Function Definition:** +`Arm::raw_write` is the single exit for **every** downlink frame (including the zero-gravity +keepalive thread, which deliberately bypasses `write_cmd`'s zero-gravity guard but **must not** +bypass the queue clear). The order of the hooks is load-bearing: -```cpp -void send_mit( - const std::vector& kp, // length 7, position stiffness - const std::vector& kd, // length 7, velocity damping - const std::vector& q_ref, // length 7, target joint angles (rad) - const std::vector& dq_ref, // length 7, target angular velocity (rad/s) - const std::vector& tau_ff // length 7, feedforward torque (N·m) -); +```text +① fork guard / terminal-state guard —— structurally guarantees "a child process sends nothing" and "a terminal session sends no more frames" +② clear the queue (cart_->clear_pending) —— must come **before** drain_for +③ drain_for(cmd) —— must come **before** the write +④ tr_->write_frame(...) ``` -**Parameters:** +- **Why ③ must come before the write**: our reply can only arrive **after** the write, so clearing the queue at this point cannot eat our own reply. The other order (write first, clear after) eats it 100% of the time. -| Name | Type | Description | -| --- | --- | --- | -| `kp` | `const std::vector&` | Position stiffness, length 7, range `[0, 500]`. Typical: 15–200 | -| `kd` | `const std::vector&` | Velocity damping, length 7, range `[0, 5]`. Typical: 0.5–3.0 | -| `q_ref` | `const std::vector&` | Target joint angles (rad), length 7. Inter-frame jumps are slew-limited | -| `dq_ref` | `const std::vector&` | Target angular velocity (rad/s), length 7. Clamped to `±DQ_MAX` | -| `tau_ff` | `const std::vector&` | Feedforward torque (N·m), length 7. Clamped to `±min(guards_tau_max, TAU_MAX)` | +**Warning:** **2026-09-28: this used to hold a "same-frame throttle" as well (ordered before ②), +and it has been removed as a whole.** Back then, placing it ahead of the queue clear had a bearing +(the queue clear is justified by "the firmware only invalidates an in-flight plan once it +**receives** this command", and a frame that gets dropped leaves the firmware state untouched). +Removing it makes **that bearing disappear on its own** — the single write point's new invariant +is "**reaching this function means the frame will be written out**", and the half-state "queue +cleared but the frame never sent" is **impossible to construct**. See README, "Four deliberate +divergences from the original", item 1. -**Return Value:** `void` — async send, no acknowledgment. +Two exits at the layer above: -**Usage Example:** +| Exit | Zero-gravity guard | Use | +| --- | --- | --- | +| `write_query` | none | **Queries** (get_tcp/get_ik/parameter read-back/capture/self-test) — you must still be able to read state during freedrive | +| `write_cmd` | **yes** (can be turned off with `guarded=false`) | **Actions**. `guarded=false` is only for actions in the energy-reducing direction (emergency stop / disable) — those must always be reachable | -```cpp -// Single frame (auto-enters DIRECT mode) -arm.send_mit( - {50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0}, - {1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5}, - {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, - {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, - {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0} -); -``` +--- -#### set_guards — Configure Global Guardrails +## 5. Cartesian: FIFO pairing and the absorb budget -**Description:** One-time global guardrail configuration (RPC). All params are -`std::optional`, `std::nullopt` = no change. **Globally persistent**: not reset on DIRECT exit. +The firmware's native Cartesian replies (`0x3A/0x3B/0x3E`) carry **no command id** in the payload, +so they can only be paired FIFO by **acceptance order**; and the firmware holds only **one slot** +for those replies ⇒ when 3 or more are registered within the same drain window, the middle request +gets zero replies. -**Function Definition:** +The three entry points (`move_l` / `move_c` / `move_path`) **hold the serialization lock the whole +way**: from registering the token until the `0x4E` pairing completes; with `wait=true` they **hold +it until the arm settles** as well. -```cpp -LiteArmValue set_guards( - std::optional slew_limit = std::nullopt, - std::optional tau_max = std::nullopt, - std::optional watchdog_timeout = std::nullopt, - std::optional position_bounds = std::nullopt, - std::optional velocity_bounds = std::nullopt, - std::optional jerk_limit = std::nullopt -); -``` +### The two guards -**Parameters:** +**"One too many"** (the queue is empty yet a `0x4E` arrives): count it and throw the base +`LiteArmError`. Giving it no dedicated exception type is deliberate — it means the **pairing model +has lost sync with the firmware** and an internal invariant is broken; no caller can make a correct +decision from it, and it **should not be caught specifically**. -| Name | Type | Description | -| --- | --- | --- | -| `slew_limit` | `std::optional` | Global slew rate limit (rad/s). `std::nullopt` = no change | -| `tau_max` | `std::optional` | Global torque limit (N·m). `std::nullopt` = no change | -| `watchdog_timeout` | `std::optional` | Watchdog timeout (s), range `[0.05, 2.0]` | -| `position_bounds` | `std::optional` | Enable position soft-limits. Default `false` | -| `velocity_bounds` | `std::optional` | Enable velocity soft-limits. Default `false` | -| `jerk_limit` | `std::optional` | Enable jerk limiting. Default `false` | +**"One too few"** (the token times out without being closed out): remove that token and throw +`CartReplyLostError` (**not** `MotionTimeoutError` — what this has to express is "unknown outcome"; +lumping it in with a generic timeout would make the caller resend on the assumption that nothing +took effect). -**Return Value:** `LiteArmValue` — RPC reply. +### The absorb budget -**Usage Example:** +When you clear the queue or abandon a token, that firmware reply **may already be on the wire**. +Without a budget, it arrives and hits the "queue is empty" criterion ⇒ a `LiteArmError` blows up +out of a **completely unrelated read path**. -```cpp -// Combined configuration -arm.set_guards(1.0, 10.0, 0.10, true); -// slew_limit=1.0, tau_max=10.0, watchdog_timeout=0.10, position_bounds=true +The budget = the number of entries removed, and it lives for `absorb_ttl = max(move_timeout, 12.0)` +seconds. -// Single parameter change -arm.set_guards(std::nullopt, std::nullopt, 0.05); // tighten watchdog to 50ms -``` +**Caution:** **both directions go wrong**: -#### get_guards — Read Current Guardrail Configuration +- **too large** ⇒ a genuinely out-of-sync reply is absorbed silently (a hard error degrades into **silence**); +- **too small / expired** ⇒ a reply still on the wire hits "the queue is not empty" and gets paired with a **newly registered** token ⇒ the caller receives **someone else's** result and **reports success** = a **false success**. -**Description:** Read the currently active guardrail configuration (RPC, synchronous). +Faced with two evils you must **lean large**. The firmware basis for the 12.0 s floor is a sum of +three terms (planning 3 s + erase/write pause 8 s + link 1 s); the source for each term is in the +comment on `kAbsorbTtlFloor` in `cart.hpp`. -**Function Definition:** +**Two correctness conditions for the queue clear**: -```cpp -LiteArmValue get_guards(); -``` +- when `n == 0` **do not refresh the deadline** — the zero-gravity keepalive sends a queue-clear opcode every 40 ms (almost always an empty clear), and if an empty clear refreshed the deadline too, the budget would **never expire**; +- the budget **accumulates across queue clears** — if it were written as "reset to this call's count", the late reply left over from the previous clear would degrade into a hard error. -**Return Value:** `LiteArmValue` — map with 6 fields. +**The single exception in `drop_and_absorb`**: `timed_out == true` and the budget **has already +been consumed** within this request's window ⇒ **do not top it up** (absorption happens **before** +pairing, so what got eaten is this request's own reply; topping up again would make it +**self-sustaining** — before the fix, this was measured to make every subsequent Cartesian command +in a new session report "unknown outcome"). -**Usage Example:** +**Caution:** this exception **holds only for the timeout branch**: the pump branch (where the read +link blows up on the spot) never waited out a window at all, so that premise has no basis for it. -```cpp -auto guards = arm.get_guards(); -auto kw = guards.as_map(); -std::cout << "slew_limit = " << kw["slew_limit"].as_double() << " rad/s\n"; -``` +--- -#### Full Control Loop Example +## 6. Lock order -```cpp -/** DIRECT mode 250Hz control loop — sine wave on joint 1. */ -#include -#include -#include -#include -#include -#include -#include "litearm/arm.hpp" -#include "litearm/transport.hpp" - -static volatile sig_atomic_t running = 1; -void handle_signal(int) { running = 0; } - -int main() { - signal(SIGINT, handle_signal); - - constexpr double DT = 0.004; // 4ms → 250Hz - constexpr double FREQ = 0.5; - constexpr double AMP = 0.5; - constexpr int N = 7; - - auto tp = std::make_shared("tcp/192.168.1.100:7447"); - litearm::Arm arm("", "armA", tp); - - arm.set_guards(2.0, 20.0, 0.10, true); - - double t = 0.0; - while (running) { - auto loop_start = std::chrono::steady_clock::now(); - - std::vector q_ref(N, 0.0); - q_ref[0] = AMP * std::sin(2.0 * M_PI * FREQ * t); - - arm.send_mit( - std::vector(N, 50.0), - std::vector(N, 1.5), - q_ref, - std::vector(N, 0.0), - std::vector(N, 0.0) - ); - - t += DT; - auto elapsed = std::chrono::steady_clock::now() - loop_start; - auto sleep_ns = std::chrono::duration_cast( - std::chrono::duration(DT) - elapsed); - if (sleep_ns.count() > 0) std::this_thread::sleep_for(sleep_ns); - } - - arm.request_stop(); - std::cout << "DIRECT mode exited" << std::endl; - return 0; -} +```text +cart_serial_ → CartPending::lock_ → transport::wlock_ +zg_lock_ → CartPending::lock_ → transport::wlock_ ``` -#### Safety Guardrails +**There is no cycle in the reverse direction**, and the criterion is "no lower layer knows about +an upper layer": neither `CartPending` nor `SerialTransport` references `cart_serial_` / `zg_lock_`. -| Guardrail | Description | Disableable? | -| --- | --- | --- | -| **Guard 1: Protocol param clamp** | kp≤500, kd≤5, dq_ref≤DQ_MAX, tau_ff≤TAU_MAX | Never | -| **Guard 2: Command slew limit** | Inter-frame q_ref jump ≤ slew_limit × dt, dt capped at 0.10s | Never; `slew_limit` can be tightened | -| **Guard 3: Watchdog fail-soft** | Command interruption → auto-hold (low-stiffness PD) | Never; `watchdog_timeout` adjustable | -| **Guard 4: Single ownership** | Rejects motion commands and teleop while DIRECT active | Never | -| **Position bounds (optional)** | q_ref clamped to joint soft-limits per frame | Off by default; `position_bounds: true` | -| **Velocity bounds (optional)** | dq_ref clamped to `±DQ_MAX` per frame | Off by default; `velocity_bounds: true` | -| **Jerk limit (optional)** | dq_ref change rate limited per frame | Off by default; `jerk_limit: true` | +- `cart_serial_` is a **`std::recursive_mutex`** — there is exactly one nesting point: `move_path` wraps the whole `BEGIN + ADD×n + RUN` sequence in this lock, and the RUN part itself also goes through `request_and_wait`. +- `zg_lock_` serializes start/stop only; **the keepalive thread does not take it** (it takes `zg_err_mu_`) — otherwise it would deadlock with `zero_g_stop`, which holds `zg_lock_` while waiting to join. +- **`emergency_stop`/`disable`/`zero_g*`/`get_tcp` must not acquire `cart_serial_` themselves** — energy-reducing actions and read-only queries must always be reachable (the lock holder may block for up to `move_timeout`). **Caution:** but **an entry point calling `get_tcp()` inside its own critical section is a different matter**: that is exactly what `move_c`'s start-point validation does. +- `Ack::mu` is **a leaf lock, reinforced**: it is held only inside `deliver`/`wait`, and `deliver` calls **no subsystem at all** (`on_reply` is deliberately kept outside the lock). -> **Safety bottom line: The arm must never fly away.** -> Command slew limit + single ownership + watchdog fail-soft + firmware fallback. +--- -### 5.6 Parameters +## 7. Safety invariants -| Method | Description | -|---|---| -| `set_gains(kp=nullopt, kd=nullopt)` / `get_gains()` | Get/set PD gains | -| `clear_faults()` | Clear motor faults | -| `set_payload(mass, com={0,0,0})` / `get_payload()` | End-effector payload (mass + center of mass) | -| `set_installation(base_rpy=nullopt, gravity=nullopt)` / `get_installation()` | Mounting orientation (base RPY or gravity vector) | +Each one is pinned by a test: -### 5.7 Peripheral Devices +1. **a forked child sends nothing** — `ForkedSessionError` hangs off three structural choke points (`require` / `Ack::wait` / `raw_write`) rather than being enumerated entry by entry. A `close()` in the child **deliberately leaks** the `Ack` and transport handles. There are three **independent** deadlocks here, each measured: ① the read lock that `SerialTransport::close()` wants to take is very likely held by the parent's reader thread; ② `join()` on a thread that **does not exist** in the child blocks forever; ③ **Caution:** the most insidious one — destructing a `std::condition_variable` (`Ack::stop_cv_`) that **still has someone parked on it** blocks forever (glibc's group switch takes the condvar's internal lock, and that lock is tied to waiters that no longer exist). ③ happens inside **member destruction**, so it cannot be dodged ⇒ the caller must **not destruct that object at all**. See the fork branch of `close()` in `src/arm.cpp` and `Ack::~Ack()` (the latter describes a minimal reproduction). -```cpp -auto hand = arm.device("hand_0"); // RemoteDevice -hand.open(); hand.close(); // open / close -hand.set_force(0.5); // grip force -hand.set_gesture("pinch"); hand.list_gestures(); -hand.finger_move({0.1, 0.2, 0.3, 0.4, 0.5, 0.6}); -hand.set_speed({...}); hand.set_torque({...}); -hand.get_state(); - -auto gripper = arm.device("gripper_0"); -gripper.set_width(0.5); double w = gripper.get_width(); - -auto teach = arm.device("teach_0"); -teach.get_joints(); teach.get_buttons(); - -// Common: get_status / get_info / connect / disconnect / clear_faults -// Device manager: arm.devices()["hand_0"] equals arm.device("hand_0") -``` + **Corollary (for the next person to add a thread)**: any object that "may be destructed in a child process and holds a condvar" has this shape. Before adding such a member, work out whether anything will destruct it after a fork. +2. **a terminal session sends nothing** — the flag is set once `enter_dfu` confirms the device is gone, and from then on every entry point throws `ArmIsInDfuError`. +3. **a write failure ⟹ the whole frame was not delivered** — `write_frame` reports a throwing `write()` and a throwing `flush()` **separately**: the former throws `TransportError`, the latter **does not throw** (the whole frame is already in the driver; a failure cannot be taken back) and only bumps `flush_failures`. This invariant is the precondition that lets `CartPending::request` remove a token safely. +4. **`RSP_CART_PLAN` replies carry no command id** ⟹ any FIFO scheme must misjudge in one branch ⟹ you can only pick **the branch that misjudges safely**: report "unknown outcome", **never** report "success". +5. **arrival ≠ stopped** — `CartPlan::settled` requires two things together: the tail of the arrival criterion **and** the **actual TCP**, read back **after** the arrival criterion is met, matching the target. Looking at `bit10` alone reports "torn off halfway, TCP nowhere near the target" as arrived. +6. **during zero gravity, action commands are refused while queries and energy-reducing actions pass** — the two throw sites share **the same constant** (`ZERO_G_GUARD_MESSAGE`), so changing one will not drift silently. +7. **`0x00` always means "the firmware does not have this command"** — it is the only stable sentinel, more reliable than a version number. +8. **irreversible commands are not done for you** — `save_params` / `reset_factory` / `model.commit` all require the caller to call `disable()` first; the library does not make safety decisions on the caller's behalf. -`RemoteDevice::call(method, kwargs)` invokes any device method, automatically -adding the `device.{id}.` prefix. +--- -### 5.8 System / Settings / Trajectories / Devices / Teleop (extended interfaces) +## 8. Error surface -All return `LiteArmValue`: +```text +LiteArmError every error in this package +├─ NotConnectedError not connected / link already closed +│ └─ ForkedSessionError reusing the parent's session in a child process (fail-closed) +├─ TransportError serial read/write / frame CRC / link loss +├─ FirmwareMismatchError version does not match the convention, or is too old +├─ InvalidCommandError illegal argument/command +├─ MotorFaultError status frame FAULT / EMERGENCY / single-joint dropout +├─ MotionTimeoutError move timed out without arriving +├─ IKError IK failed +├─ CommandRejectedError the firmware explicitly returned ERR{cmd, code} +│ └─ UnsupportedByFirmwareError ERR code == 0x00 ("the firmware lacks this command") +├─ CartesianPlanError plan rejected (IK/collinear/over capacity/out of limits) — the arm did not move a step +├─ MotionSupersededError superseded by a newer request (an expected takeover, **not a failure**) +├─ CartReplyLostError the 0x4E was lost — outcome unknown +├─ ArmIsInDfuError terminal session state +└─ NotRemoteable / NotSupportedOnThisBackend / TeleopLockedError / TeleopBusyError +``` -| Group | Methods | -|---|---| -| System | `get_system_stats()`、`get_logs(page=1, size=50, search="")`、`restart_service()`、`reconnect()` | -| Settings | `get_joint_limits/set_joint_limits(limits)`、`get_zero_offsets/set_zero_offsets(offsets)`、`get_end_effector/set_end_effector(config)`、`get_cartesian_limits/set_cartesian_limits(limits)`、`get_collision_config/set_collision_config(config)` | -| Trajectories | `start_recording/stop_recording/discard_recording/get_recording_state/get_playback_state/list_trajectories/save_trajectory(id,name,points,duration=nullopt)/delete_trajectory(id)` | -| Devices | `list_device_types()`、`connect_device(category, subtype, device_id="end_0", can_iface="", config={})`、`disconnect_device(device_id="end_0")`、`get_active_device(device_id="end_0")` | -| Teleop | `enter_teleop(mode, params={})`、`exit_teleop()`、`get_teleop_status()` | +Three relationships that **deliberately do not inherit** (each pinned by a test): -> In teleop mode the service rejects all manual-control commands; only read-only, -> emergency-stop, and `exit_teleop` calls are allowed. +- `MotionSupersededError` does **not** inherit `CartesianPlanError` — lumping it into "planning failed" would route a normal preemption down the fault branch; +- `CartesianPlanError` does **not** inherit `CommandRejectedError` — what the firmware returns here is a **planning result**, not the `ERR{cmd, code}` shape, and forcing it in would conjure up `cmd`/`code` fields with the wrong meaning; +- `ArmIsInDfuError` does **not** inherit `NotConnectedError` — the latter means "just connect and you are fine", while the terminal state is not a recoverable disconnect (mixing them would make "reconnect on disconnect" logic treat DFU as an ordinary dropout). -## 6. Exceptions +`err_reason(cmd, code)` is the **only** place that interprets `(cmd, code)`, with a three-level +lookup: -All exceptions inherit from `LiteArmError` (a `std::runtime_error` subclass). -Server exceptions are re-thrown with the same type. +1. the specific tier `err_text()` hits ⇒ use it (the same `0x03` means different things on `0x01`/`0x10`/`0x23`); +2. the generic tier hits ⇒ use it **and append the raw code**; +3. neither ⇒ say plainly "not registered", **with the raw cmd/code**. -```cpp -#include - -try { - arm.movej(...); -} catch (const litearm::SafetyViolationError& e) { // timeout/follow/fault/watchdog - // e.details is an unordered_map -} catch (const litearm::LiteArmError& e) { // fallback - std::cerr << e.what(); -} -``` +Why tiers 2 and 3 must carry the raw code: when the firmware adds a new error code, this path is +the **only** place where the host will see "this is a code I have not seen before". -Common types: `NotConnectedError`, `ConfigurationError`, `InvalidCommandError`, -`CartesianPlanError`, `MotionTimeoutError`, `MotorFaultError`, `ArmFault`, -`WatchdogError`, `MotionCancelled`. +--- -## 7. Safety Notes +## 9. Tests -- ⚠️ `disable()` drops the arm under gravity — make sure the area is clear. -- `request_stop()` is a high-priority emergency stop; bind it to an independent - physical e-stop channel. -- Manual-control commands are rejected during teleop. -- `recover_joint_limits` is only available when the server runs with - `allow_limit_recovery=True`. +```bash +./build.sh # build + ctest +./build.sh --asan # AddressSanitizer + UBSan +ctest --test-dir build -R test_cart -V +./build/tests/... --filter movej # filter by name within one file (each test_*.cpp is a standalone executable) +``` -## 8. Comparison with litearm-python +| Test | Coverage | +| --- | --- | +| `test_protocol` | CRC (standard check value + an independent reference implementation) / frame assembly and parsing / version parsing / boot signature / both status-frame layouts / the coverage contract checked against the table entry by entry | +| `test_rot` | rpy<->matrix / gimbal lock / SO(3) log and exp / slerp / the direction of the pose error / the three ways of writing a pose | +| `test_errors` | the hierarchy / the three-level lookup / the whitelist / the `ERR` -> exception type mapping | +| `test_state` | enabled/cart_busy/faulted/joint dropout/`drop_hold_inferred` | +| `test_transport` | **a real pty**: byte-by-byte round trip / noise skipping and the noise trace / resync after a bad frame / a half frame surviving across calls / `timeout=0` meaning "give me whatever there is" / a fake header dropped on timeout / endpoint exclusivity / registration released on the failure path | +| `test_framing` | the framer **unit-tested directly** (no longer only indirectly, through a pty): feed several frames at once and drain them / a half frame across calls / the noise trace and its cap / fake headers timing out / resync after a bad frame / `reset` | +| `test_register` | format gatekeeping for the unverified register: five columns / every entry has a source / ids are exactly 1..N / no invisible rows missing the leading `\|` | +| `test_packaging` | the categories you can still guard before shipping: a single source for the version / every sibling header of a public header is in the installed directory / the packaging template is present | +| `test_arm_assembly` | handshake / version rejection / idempotent connect / retargeting / teardown / the four DFU terminal-state paths | +| `test_commands` | the enable retry whitelist / safety commands / joint motion and arrival / servo pass-through arity / state reads / IK | +| `test_ff` | 0x26/0x27/0x28/0x31 + 0x2B/0x2C read-back / the item table | +| `test_params` | 0x22/0x23/0x24/0x36 + the armed gate | +| `test_model` | probing / the three levels staging-bank-commit / the mask bit by bit / requires disable | +| `test_log` | sample layout / cursor continuation / drop retry / the infinite-loop guard when the cursor does not advance / the per-tick recording model | +| `test_diagnostics` | timing lines (a name joined to a number) / LINK lines (**keys whose names contain digits**) / both frames received / old-firmware compatibility | +| `test_cart` | `CartPlan` / exception mapping / the three entry points / **FIFO pairing** / **the absorb budget in both directions** / the queue clear / capability probing | +| `test_zero_g` | the keepalive thread / the guards in both directions / RAII / an interrupted keepalive is visible / close stops the thread | +| `test_license` | the 26 B record / not activated / read-back qualification of the `0x02` aggregate tier / the enable gate when unlicensed | +| `test_threading` | concurrent state readers / concurrent Cartesian entry points serialized / close waking the waiters / `Msg.hz` semantics / no drop path on the single write point | +| `test_precheck` | client-side prechecks (non-finite values / soft limits / speed / tolerances) and the two rules for the soft-limit cache | +| `test_accessors` | the seven read-only diagnostic accessors (including not throwing in the terminal state, and the forwarding chain through the wrapper layer) | +| `test_clock` | the injectable time source: the real clock by default / injection is visible / "too old ⇒ link lost" is deterministically verifiable under a fake clock | +| `test_frame_ownership` | **frame ownership**: the single read point (source scan) / the ownership criterion / two threads not eating each other's replies / stale frames clearing the queue / the queue cap / the guards | +| `test_full_coverage` | **the dynamic command-coverage sentinel** (every implemented command really was sent out) / no extra ids / the public-member list guard / a full sweep of error types while not connected | +| `test_protocol_sync` | **a two-way comparison against the firmware headers** — **needs `LITEARM_FW_DIR`, otherwise SKIP** (see the end of §9) | +| `test_fork_guard` | a real `fork()`: the child sends nothing / teardown does not hang | + +**All of it is offline** — using `testing::FakeTransport` (a fake transport whose replies are +scripted per command). The serial port itself is tested with **a real pty**: a stub ignores +`timeout` entirely, so defects like "`read_frame(0)` actually touches not a single byte" come out +all green on a stub (that is exactly how upstream once missed a real-hardware failure). + +**The only exception** is `test_protocol_sync`: it reads source from the **firmware repository** +and **SKIPs** when it cannot find it (`LITEARM_FW_DIR`, default `~/litearm-stm32`). The +frame-ownership suite holds two more **source-scanning** criteria (the single read point, the +public member list) — they derive a path from `__FILE__` through `lt::repo_root()`, independent of +the current working directory (ctest's cwd is the build directory, and a relative path will not +open there — hit in practice). + +**Caution:** **a SKIP is not a pass**: these are counted separately in the report and printed one +by one. Seeing "N skipped" means that part **was not tested**. -| Aspect | litearm-python | litearm-cpp | -|---|---|---| -| Value type | Native `dict`/`list` | `LiteArmValue` (tagged union) | -| Dependencies | Auto-installed | protobuf (gtest tests only) | -| Use case | Rapid prototyping / scripting | Low-latency embedded / real-time systems | +--- -The three SDKs expose the same interface, method-for-method — code can be -migrated across languages. +## 10. Comparison with the Python version -## License +The port is a **line-by-line comparison**: the same constant values, the same error strings, the +same order of criteria. The only differences are at the language level: -Proprietary +| Topic | Python | C++ | +| --- | --- | --- | +| pose argument | shape decided at runtime | `rot::PoseInput` (a named factory for each of the three spellings; a 6-vector converts implicitly) | +| unavailable values | `None` | `std::nullopt` | +| attributes | `arm.n` | `arm.n()` (read-only) / public members (tunable knobs) | +| context manager | `with arm.zero_g():` | RAII `ZeroGSession`, the destructor does not throw | +| overloads | same name, polymorphic | convenience overloads such as `Arm::write_frame(cmd, {payload})` | +| weak references | `weakref` (the reader thread does not pin `Arm`) | not applicable: the reader thread is joined in `close()`, and the lifetime is guaranteed by the join order | +| `__del__` | GC as a teardown backstop | the destructor as a teardown backstop (likewise delegating only to `close()`) | + +**Caution:** **two known inherited gaps** (they exist in the original and were deliberately +preserved by the port): + +1. `license()` does not pass `echo_cmd`, so on firmware **below 1.8.0** it reports "no reply timeout" instead of "the firmware lacks this command" (evidence in `tests/test_license.cpp`). +2. outside `linux` there is no automatic CDC discovery (macOS has no sysfs; Windows needs SetupAPI to read the VID:PID reliably, and guessing one would produce false positives). diff --git a/docs/DEVELOPER_GUIDE.zh-CN.md b/docs/DEVELOPER_GUIDE.zh-CN.md index 8b4cc51..5f29986 100644 --- a/docs/DEVELOPER_GUIDE.zh-CN.md +++ b/docs/DEVELOPER_GUIDE.zh-CN.md @@ -1,462 +1,387 @@ -# litearm-cpp 开发指南与接口说明 +# litearm-cpp 开发者指南 -`litearm-cpp` 是 LiteArm 机械臂的 C++ 客户端 SDK(C++17), -通过网络连接机械臂控制服务,适合低延迟嵌入式场景。**纯客户端,无运动学/动力学依赖**。 +LiteArm 机械臂的 C++ SDK —— 经 USB 串口直连固件。 -```text -你的程序 ──→ 机械臂控制服务 ──→ 机械臂 / CAN -``` - ---- +规划、运动学与动力学都在固件里; PC 侧只做三件事: **编解码帧**、**发命令**、**判到位**。 ## 目录 -- [1. 环境要求与构建](#1-环境要求与构建) - - [1.1 安装 protobuf](#11-安装-protobuf) - - [1.2 构建](#12-构建) -- [2. 快速开始](#2-快速开始) -- [3. 连接管理](#3-连接管理) -- [4. 值类型 LiteArmValue](#4-值类型-litearmvalue) -- [5. 接口说明](#5-接口说明) - - [5.1 计算(不驱动电机)](#51-计算不驱动电机) - - [5.2 运动控制](#52-运动控制) - - [5.3 状态读取](#53-状态读取) - - [5.4 急停 / 使能](#54-急停--使能) - - [5.5 DIRECT 模式 —— 逐帧 MIT 直接控制](#55-direct-模式--逐帧-mit-直接控制) - - [5.6 参数调节](#56-参数调节) - - [5.7 外设设备](#57-外设设备) - - [5.8 系统 / 设置 / 轨迹 / 设备管理 / 遥操](#58-系统--设置--轨迹--设备管理--遥操) -- [6. 异常处理](#6-异常处理) -- [7. 安全提示](#7-安全提示) -- [8. 与 litearm-python 对比](#8-与-litearm-python-对比) - -## 1. 环境要求与构建 - -| 项目 | 要求 | -|---|---| -| 编译器 | C++17(GCC 8+ / Clang 7+) | -| CMake | 3.16+ | -| Protobuf | 3.19+(headers、库、`protoc`) | -| Google Test | 仅测试需要(自动下载) | - -### 1.1 安装 protobuf - -先检查版本: - -```bash -protoc --version # 需 "libprotoc 3.19" 或更新版本 -``` +1. [模块地图](#1-模块地图) +2. [线协议](#2-线协议) +3. [读路径: 唯一读者 + 分队列](#3-读路径-唯一读者--分队列) +4. [写路径: 唯一写口 + 两道钩子](#4-写路径-唯一写口--两道钩子) +5. [笛卡尔: FIFO 配对与吸收额度](#5-笛卡尔-fifo-配对与吸收额度) +6. [锁序](#6-锁序) +7. [安全不变量](#7-安全不变量) +8. [错误面](#8-错误面) +9. [测试](#9-测试) +10. [与 Python 版的对照](#10-与-python-版的对照) -若未安装或版本过低,按需选择一种方式: +--- -```bash -# Debian / Ubuntu(24.04+ 自带 protobuf 3.21) -sudo apt update && sudo apt install -y protobuf-compiler libprotobuf-dev - -# 或 conda(任意系统) -conda install -c conda-forge protobuf=3.19.6 - -# 或源码编译(任意系统) -cd /tmp -curl -LO https://github.com/protocolbuffers/protobuf/releases/download/v3.19.6/protobuf-cpp-3.19.6.tar.gz -tar xzf protobuf-cpp-3.19.6.tar.gz && cd protobuf-3.19.6 -./configure && make -j$(nproc) && sudo make install -sudo ldconfig -``` +## 1. 模块地图 -### 1.2 构建 +| 头文件 | 对应 Python | 职责 | +| --- | --- | --- | +| `protocol.hpp` | `_protocol.py` | 帧编解码 / CRC16 / 常量 / 状态帧布局 / 版本解析 / 覆盖契约 | +| `errors.hpp` | `errors.py` | 异常层级 + `(cmd, code)` 语义表 + `ERR` -> 异常的映射 | +| `rot.hpp` | `_rot.py` | 旋转/位姿纯数学 (ZYX 内旋, 与固件 `kin.c` 同约定) + 位姿形态归一化 | +| `state.hpp` | `state.py` | `RobotState` / `JointState` —— 状态帧的面向对象视图 | +| `transport.hpp` | `transport.py` | 传输接口 + 真串口 (POSIX/Win32) + 端口发现 + 进程内端口登记 | +| `ack.hpp` | `arm.py` 的 `_Ack` | **唯一读者** + 按 (上行 id, 回显码) 分队列 + `expect` | +| `cart.hpp` | `cart.py` | 笛卡尔 FIFO 配对 / 吸收额度 / `CartPlan` / 三条入口 | +| `arm.hpp` | `arm.py` | `Arm` 主类 / `Msg` / `LicenseInfo` / 零重力会话 / CLI | +| `params.hpp` | `params.py` | 关节级参数 (0x22/0x23/0x24/0x36) | +| `model.hpp` | `model.py` | 动力学模型在线导入 (0x30..0x39) | +| `log.hpp` | `log.py` | 300Hz 采集 (0x2D/0x2E) | +| `diagnostics.hpp` | `diagnostics.py` | 固件自检 (0x49) + 文本解析 | +| `testing.hpp` | `testing.py` | 脚本化应答的假传输 —— 让全套测试离线可跑 | +| `msg.hpp` | `arm.py` 的 `Msg` | 返回值信封 `{value, hz, timestamp}` | + +**设计边界** (刻意不做): 任意 `fk(q)` —— 固件 `CMD_GET_TCP` 只能算**当前反馈 q** 的位姿, +没有"给 q 求位姿"的下行命令; 阻抗控制 —— 高级场景走 pylitearm + server。 +PC 侧不做规划、不做运动学, 重型计算本来就在固件里。 -```bash -mkdir build && cd build -cmake .. # 系统安装的 protobuf 会被自动找到 -cmake --build . -j$(nproc) -ctest --output-on-failure # 运行测试 -``` +--- -protobuf 在自定义前缀(例如已激活的 conda 环境)时: +## 2. 线协议 -```bash -cmake .. -DPROTOBUF_ROOT=$CONDA_PREFIX +``` +帧: SOF(0xA5) CMD(1B) LEN(1B) PAYLOAD(0..255) CRC16_LO CRC16_HI +CRC: CRC-16/CCITT-FALSE (poly 0x1021, init 0xFFFF, 无末异或), 覆盖 [SOF..PAYLOAD] ``` -## 2. 快速开始 - -```cpp -#include - -int main() { - auto arm = litearm::Arm("tcp/192.168.1.100:7447", "armA"); +上行 100Hz 状态帧 (两种布局, 尾部 `joint_fault u16` 是固件 1.5.0 起新增的): - auto state = arm.get_state(); // std::optional - if (state) { - auto& q = state->q; // 关节位置 - } +| 布局 | 固件 | 长度 (7J / 1J) | +| --- | --- | --- | +| `4 + 21N` | ≤ 1.4.x | 151 / 25 | +| `6 + 21N` | ≥ 1.5.0 | 153 / 27 | - arm.movej({0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, /*speed=*/0.5); - auto [pos, rot] = arm.fk({0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}); +单关节 21B = `q f32 + dq f32 + tau f32 + t_mos f32 + t_coil f32 + err u8`。 +`flags` 的位分配: **bit0..5 = 安全 flag**, **bit6..8 = mode**, **bit9 = enabled**, +**bit10 = cart_busy** —— 所以 `flag_names` 只看 bit0..bit5 (见 `protocol.cpp` 里那个 +`for (k = 0; k < 6; ++k)`; 把它改大就会让 mode/enabled/cart_busy 一起变成"故障位")。 - arm.request_stop(); // 高优先级急停 - arm.close(); - return 0; -} -``` +⚠ **两个双 ID**: `0x49` (下行 `CMD_KIN_BENCH` / 上行 `RSP_JOINT_PARAM`) 与 +`0x41` (下行 `CMD_GET_FIRMWARE` / 上行 `RSP_DETAIL`, 后者是**死登记**)。 +靠**方向**区分, 不可用单张 ID 表查。 -## 3. 连接管理 +覆盖契约 `command_coverage()` (49 条) 是"固件每条已实现命令都有 SDK 入口"的断言载体, +由 `tests/test_protocol.cpp` 逐条对表钉住。 -```cpp -litearm::Arm arm("tcp/127.0.0.1:7447", "armA", - /*transport=*/nullptr); // 可选预配置连接(高级用途) -arm.close(); // 关闭连接 -``` +### 2.1 分帧器 `FrameReader` (`include/litearm/framing.hpp`) -- `Arm` 不可拷贝、可移动;`arm_id()` 返回机械臂标识。 -- `get_state(refresh=false)` 同步读取状态缓存,未收到返回 `std::nullopt`。 +把字节流切成帧的那台状态机 (SOF 扫描 / 半帧窗口 / 噪声留痕) **单独一个类**, +`SerialTransport` 的两条后端 (POSIX 与 Win32) **共用同一份**。 -## 4. 值类型 LiteArmValue +⚠⚠ **它存在的唯一理由是消灭重复。** 从前两条后端**各写过一份**同样的状态机, +代码里自称"与 POSIX 侧同一段逻辑" —— 那是**会漂的拷贝**, 而 Windows 那条分支在 +Linux 上**连编都编不了**, 漂了也不会有人当场发现。抽出来之后两条后端只剩 I/O, +而这段逻辑**被编译、被单测** (`tests/test_framing.cpp` 9 条), 不再只能经 pty 间接验。 -接口参数/返回值统一用动态值类型 `LiteArmValue`(tagged union),构造与访问: +⚠ **`feed()` 是"取走"语义, 不是"试试看"** —— 丢掉它的返回值就是丢掉那一帧, 症状极隐蔽 +(数据在流, 却一帧都读不出来)。实测**在同一个下午踩了两次**。标准用法: ```cpp -using litearm::LiteArmValue; - -LiteArmValue v = LiteArmValue::from_vec({0.1, 0.2}); // vector → List -LiteArmValue m = LiteArmValue::from_mat({{1,0,0},{0,1,0},{0,0,1}}); // 嵌套 → List - -double x = v.as_list()[0].as_double(); // 访问 -auto vec = v.to_vec(); // 提取 vector -auto mat = m.to_mat(); // 提取 vector> -auto cfg = LiteArmValue::make_map({{"type", LiteArmValue("gripper")}}); +if (auto f = r.feed(chunk.data(), chunk.size())) return *f; // 这一批里可能就有整帧 +while (auto f = r.feed(nullptr, 0)) { /* 处理 f */ } // 再取空缓冲 ``` -`Kind` 枚举:`Null / Bool / Int / Double / String / Bytes / List / Map`。便捷别名 `Vec7`、`Mat3x3`。 +⚠ `tick(now)` 返回**缓冲有没有被改动**: 改动过就要立刻重试装配 (刚丢掉的假帧头后面 +很可能就跟着一条真帧)。少了这个返回值, 调用方要么漏帧、要么空转。 -## 5. 接口说明 - -> 运动类方法返回 `bool`;纯计算返回数据;其他接口返回 `LiteArmValue`。 - -### 5.1 计算(不驱动电机) +--- -| 方法 | 说明 | -|---|---| -| `fk(q)` | 正运动学 → `(位置, 旋转矩阵)` | -| `ik(pos_d, R_d, q_seed=nullopt)` | 逆运动学 → `(q, 是否成功)` | -| `plan_movel(q_start, pose_goal)` | 直线笛卡尔路径规划 → 关节路径 | -| `plan_movec(q_start, pose_via, pose_goal)` | 圆弧路径规划(过中间点) | -| `plan_movep(q_start, poses_goal)` | 多航点路径规划 | +## 3. 读路径: 唯一读者 + 分队列 -### 5.2 运动控制 +**全包只有一个地方碰 `transport->read_frame`**: `Ack::reader_loop`。它只有两条纪律: -| 方法 | 说明 | -|---|---| -| `movej(q_target, speed=1.0, settle_s=1.0, max_cycles=nullopt, allow_start_collision_recovery=false)` | 关节空间点到点 | -| `home(speed=0.3, settle_s=0.5, max_cycles=nullopt)` | 回零:所有关节归零,绕开限位和自碰路径检查 | -| `recover_joint_limits(speed=0.05, settle_s=0.5, max_cycles=nullopt, inset_rad=0.0)` | 越限关节缓慢回安全边界(需 server `allow_limit_recovery=True`) | -| `movel(pose_goal, speed=1.0, settle_s=0.8, max_cycles=nullopt)` | 笛卡尔直线 | -| `movec(pose_via, pose_goal, speed=1.0, settle_s=0.8, max_cycles=nullopt)` | 笛卡尔圆弧 | -| `movep(poses_goal, speed=1.0, settle_s=0.8, max_cycles=nullopt)` | 多航点带拐角平滑 | -| `replay_joint_path(q_path, speed=1.0, settle_s=0.5, goto_start=true, goto_speed=0.3, max_cycles=nullopt)` | 回放关节序列 | -| `replay_trajectory(traj, speed=1.0, goto_start=true, goto_speed=0.3, max_cycles=nullopt, check_singularity=true)` | 回放 `JointTrajectory`(另有 `LiteArmValue` 重载) | -| `replay_timed_trajectory(traj_q, traj_t, speed=1.0, goto_start=true, goto_speed=0.3, simplify_tolerance_rad=0.01, max_cycles=nullopt)` | 按原始时间轴回放(自动拉伸保安全) | -| `play_trajectory(trajectory, speed=1.0, goto_start=true, goto_speed=0.3, verify_robot=true, simplify_tolerance_rad=0.01, max_cycles=nullopt)` | 回放已保存轨迹(`JointTrajectory` 或 server 侧路径字符串,双载) | -| `record_trajectory(output="trajectories", duration_s=nullopt, sample_rate_hz=100.0, filter_alpha=0.15, name=nullopt)` | 拖动录轨迹 → `JointTrajectory` | -| `hold(kp_scale=3.0, max_cycles=nullopt)` | 提高刚度持位 | -| `zero_gravity(max_cycles=nullopt, duration_s=nullopt, measured_overspeed_factor=nullopt, vel_max=nullopt)` | 零重力(自由拖动)模式 | -| `joint_impedance(q_des, K, B, tau_max=nullopt, engage_sec=0.3, max_cycles=nullopt)` | 关节空间阻抗控制 | -| `cartesian_impedance(q_des, K_cart, B_cart, v_des=nullopt, tau_max=nullopt, engage_sec=0.3, max_cycles=nullopt, sigma_min_thresh=nullopt, max_ori_err=nullopt, measured_overspeed_factor=nullopt, vel_max=nullopt)` | 笛卡尔空间阻抗控制 | -| `joint_follow(K=nullopt, B=nullopt, speed_limit=nullopt, accel_limit=nullopt, engage_sec=0.3, max_cycles=nullopt, duration_s=nullopt)` | 跟随外部目标 | +1. **只投递, 不判定** —— "这帧是谁的"由**队列**决定, 不由线程决定; +2. **死了要响亮** —— 死因进 `reader_error` 并唤醒所有等待者, 绝不静默退出。 -### 5.3 状态读取 +帧的**归属 = 它落在哪条队列**, 键是 `(上行 id, 回显码或 kNoEcho)`: -| 方法 | 说明 | -|---|---| -| `get_state(refresh=false)` | 状态缓存最近状态(`std::optional`,同步) | -| `get_tcp_pose()` | 当前 TCP 位姿 → `(位置, 旋转矩阵)` | +- 只有 `RSP_ACK` / `RSP_ERR` 的 `payload[0]` 是"原命令码", 所以只有它们需要第二维; + 别的帧的 `payload[0]` 是**数据** (关节号 / item 索引 / 状态字节)。 +- 于是 `ACK{0x10}` 与 `ACK{0x11}` 天然落在**两条**队列里 —— 并发命令互吃应答这件事在 + **结构上不可能发生**。 -`RobotState` 字段:`q / dq / tau / faults / errs / temps / state / feedback / watchdog / robot_serial / config_checksum_sha256`。 +三类特殊投递: -### 5.4 急停 / 使能 +| 帧 | 去处 | 理由 | +| --- | --- | --- | +| `RSP_STATUS` | **单槽** `state` + `status_seq++` | 100Hz **广播**帧。进队列会在 0.6s 内撑满 `kQueueMax`; 而"取走就没"的队列语义天生不适合广播 | +| `RSP_CART_PLAN` | 锁**外**交给 `CartPending::on_reply` | 避免在全局锁里调另一个子系统的锁 | +| 其它 | `queues[(id, echo)]` 队列 | 每条队列封顶 `kQueueMax`, 越界丢**最旧**并计 `dropped` | -| 方法 | 说明 | -|---|---| -| `request_stop()` | 高优先级急停(独立急停通道) | -| `clear_stop()` | 清除停止状态回到就绪 | -| `enable()` | 使能全部电机并锁住当前姿态 | -| `disable()` | ⚠️ 失能全部电机(机械臂会掉臂!),CAN 保持连接 | +`expect(want, timeout, label, raise_on_err, echo_cmd, err_waits_for_ack)`: +`want` 是 `ACK`/`ERR` 时 **`echo_cmd` 必填** (不给就分不清"我的 ACK"与"别人的 ACK"); -### 5.5 DIRECT 模式 —— 逐帧 MIT 直接控制 +- `err_waits_for_ack=true`: 一条匹配 `echo_cmd` 的 `ERR` **不是终局** —— 继续等 + `ACK{echo_cmd}`; 找到 ⇒ 那条 ERR 是**别人的** (本命令其实已被受理, 不抛); + 窗口耗尽仍只有 ERR ⇒ 那条 ERR 就是本命令的, 抛它。 + 代价: **真被拒时要等满整个窗口**。所以只有笛卡尔三条入口开这个开关。 -> DIRECT 模式是 LiteArm 的逐帧 MIT 直接控制通道。通过 `send_mit` 以 250Hz 典型频率 -> 发送五参数 (kp/kd/q_ref/dq_ref/tau_ff) 实时控制关节电机,内置 4 条永不关闭的核心安全护栏。 +⚠ **这条"受理必回 ACK、拒绝绝不补 ACK"的形状只在生成侧成立, 到达侧不是**: +受理那条 `ACK{cmd}` **自己也会丢** (固件应答 FIFO 满时丢最新)。 -**与普通运动控制的区别:** +### 三种读者 -| 特性 | 普通运动控制 (`movej` 等) | DIRECT 模式 (`send_mit`) | +| 类型 | 取帧口 | 例子 | | --- | --- | --- | -| 控制方式 | 目标位置 + 速度,自动规划 | 逐帧五参数 MIT 命令 | -| 帧率 | 一次调用,自动执行 | 用户循环控制(典型 250Hz) | -| 阻塞 | 阻塞,等运动完成 | 非阻塞,立即返回 | +| 队列等待者 | `Ack::wait` (在指定队列上等) | `expect` / `enable` / 参数读回 | +| 驱动型读者 | `Arm::pump_until` (**一条帧都不认领**) | `read_status` / `get_status_now` / `wait_settled` | +| 事件等待者 | `token` 上的条件变量 | `CartPending::wait` | -**进入与退出:** +驱动型读者是"广播帧不进队列"这条设计的必然结果: 它只是**等单槽前进**, 于是多个并发 +`get_state()` 不再互相饿。 -- **进入**:首次调用 `send_mit` 时自动进入 DIRECT 模式 -- **退出**:`request_stop()` 主动退出 / 看门狗超时自动回 hold / 电机故障自动退出 - -#### send_mit —— 发送 MIT 控制帧 +--- -**Description:** 异步 pub 五参数 MIT 控制帧到机械臂命令通道。非阻塞,立即返回。首次调用自动进入 DIRECT 模式。 +## 4. 写路径: 唯一写口 + 两道钩子 -**Function Definition:** +`Arm::raw_write` 是**所有**下行帧的唯一出口 (含零重力保活线程, 它刻意绕过 +`write_cmd` 的零重力守卫, 但**不该**绕过清队)。钩子顺序是承重的: -```cpp -void send_mit( - const std::vector& kp, // 长度 7,位置刚度 - const std::vector& kd, // 长度 7,速度阻尼 - const std::vector& q_ref, // 长度 7,目标关节角度 (rad) - const std::vector& dq_ref, // 长度 7,目标角速度 (rad/s) - const std::vector& tau_ff // 长度 7,前馈力矩 (N·m) -); +``` +① fork 守卫 / 终态守卫 —— 结构性保证"子进程零下发""终态不再发帧" +② 清队 (cart_->clear_pending) —— 必须在 drain_for **之前** +③ drain_for(cmd) —— 必须在写**之前** +④ tr_->write_frame(...) ``` -**Parameters:** - -| Name | Type | Description | -| --- | --- | --- | -| `kp` | `const std::vector&` | 位置刚度,长度 7,范围 `[0, 500]`。典型值 15–200 | -| `kd` | `const std::vector&` | 速度阻尼,长度 7,范围 `[0, 5]`。典型值 0.5–3.0 | -| `q_ref` | `const std::vector&` | 目标关节角度(rad),长度 7。相邻帧跳变受斜率限制 | -| `dq_ref` | `const std::vector&` | 目标角速度(rad/s),长度 7。被 clamp 到 `±DQ_MAX` | -| `tau_ff` | `const std::vector&` | 前馈力矩(N·m),长度 7。被 clamp 到 `±min(guards_tau_max, TAU_MAX)` | +- **③ 为什么必须在写之前**: 我们的应答只可能在写**之后**到达, 故此刻清队不可能吃掉自己的 + 应答。反过来 (先写后清) 会 100% 吃掉。 -**Return Value:** `void` — 异步发送,不等待回执。 +⚠⚠ **2026-09-28: 这里原来还有一道"同帧节流" (排在 ② 之前), 已整套删除。** +它当年排在清队之前有射程 (清队的理据是"固件**收到**这条命令才会作废在途规划", 而帧被丢掉时 +固件状态一点没变)。删掉之后**那条射程自动消失** —— 唯一写口的新不变式是 +「**进了这个函数就一定会写出去**」, "清队了但帧没发"这种半状态**不可构造**。 +见 README「与原版的差异」①。 -**Usage Example:** +上层两个出口: -```cpp -// 发送单帧(自动进入 DIRECT 模式) -arm.send_mit( - {50.0, 50.0, 50.0, 50.0, 50.0, 50.0, 50.0}, - {1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5}, - {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, - {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, - {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0} -); -``` +| 出口 | 零重力守卫 | 用途 | +| --- | --- | --- | +| `write_query` | 无 | **查询类** (get_tcp/get_ik/参数读回/采集/自检) —— 拖动示教期间仍应能读状态 | +| `write_cmd` | **有** (可 `guarded=false` 关掉) | **动作类**。`guarded=false` 只给降能量方向的动作 (急停/失能) —— 它们必须永远可达 | -#### set_guards —— 配置全局护栏 +--- -**Description:** 全局一次性配置护栏参数(RPC)。所有参数为 `std::optional`,`std::nullopt` 表示不改变。**全局持久**:退出 DIRECT 后不重置。 +## 5. 笛卡尔: FIFO 配对与吸收额度 -**Function Definition:** +固件原生笛卡尔 (`0x3A/0x3B/0x3E`) 的应答载荷里**没有命令 id**, 只能按**受理顺序** +FIFO 配对; 而固件那份应答只有**一个槽位** ⇒ 同一排空窗口内登记 ≥3 条时, 中段请求零应答。 -```cpp -LiteArmValue set_guards( - std::optional slew_limit = std::nullopt, - std::optional tau_max = std::nullopt, - std::optional watchdog_timeout = std::nullopt, - std::optional position_bounds = std::nullopt, - std::optional velocity_bounds = std::nullopt, - std::optional jerk_limit = std::nullopt -); -``` +三条入口 (`move_l` / `move_c` / `move_path`) **全程持串行锁**: 从登记 token 一直持到 +`0x4E` 配对完成; `wait=true` 时**再持到停稳**。 -**Parameters:** +### 两条守卫 -| Name | Type | Description | -| --- | --- | --- | -| `slew_limit` | `std::optional` | 全局斜率限制(rad/s)。`std::nullopt` = 不改变 | -| `tau_max` | `std::optional` | 全局力矩上限(N·m)。`std::nullopt` = 不改变 | -| `watchdog_timeout` | `std::optional` | 看门狗超时(秒),范围 `[0.05, 2.0]` | -| `position_bounds` | `std::optional` | 是否开启位置软限位。默认 `false` | -| `velocity_bounds` | `std::optional` | 是否开启速度软限位。默认 `false` | -| `jerk_limit` | `std::optional` | 是否开启加加速度限制。默认 `false` | +**"多了一条"** (队列空却收到 `0x4E`): 计数 + 抛基类 `LiteArmError`。 +不给它专门的异常类型是刻意的 —— 那是**配对模型与固件脱同步**, 内部不变量被破坏, +没有任何调用方能据它做出正确决定, 也**不该被专门 catch**。 -**Return Value:** `LiteArmValue` — RPC 回复值。 +**"少了一条"** (token 超时没收尾): 摘除该 token 并抛 `CartReplyLostError` (**不是** +`MotionTimeoutError` —— 这里要表达的是"未知结局", 混进通用超时会让调用方按"没生效"去重发)。 -**Usage Example:** +### 吸收额度 -```cpp -// 组合配置 -arm.set_guards(1.0, 10.0, 0.10, true); -// slew_limit=1.0, tau_max=10.0, watchdog_timeout=0.10, position_bounds=true +清队/放弃一条 token 时, 固件那条应答**可能已经在路上**。不留额度的话, 它一到就撞上 +"队列空"判据 ⇒ `LiteArmError` 从**毫不相干的读路径**炸出来。 -// 仅修改单个参数 -arm.set_guards(std::nullopt, std::nullopt, 0.05); // 仅收紧看门狗到 50ms -``` +额度 = 摘掉的条数, 存活 `absorb_ttl = max(move_timeout, 12.0)` 秒。 -#### get_guards —— 读取当前护栏配置 +⚠ **两个方向都会出错**: -**Description:** 读取当前生效的护栏配置(RPC 同步)。 +- **取大** ⇒ 一条真·脱同步的应答被静默吸收 (硬错误降级成**静默**); +- **取小/过期** ⇒ 一条还在路上的应答撞上"队列非空"而被配给**新登记**的 token ⇒ + 调用方拿到**别人那条**的结果并**报成功** = **假成功**。 -**Function Definition:** +两害相权必须**偏向取大**。下限 12.0s 的固件依据是三段相加 (规划 3s + 擦写停顿 8s + +链路 1s), 逐段出处见 `cart.hpp` 里 `kAbsorbTtlFloor` 的注释。 -```cpp -LiteArmValue get_guards(); -``` +**清队的两条正确性条件**: -**Return Value:** `LiteArmValue` — 包含 6 个字段的 map。 +- `n == 0` 时**不刷新截止** —— 零重力保活每 40ms 发一条清队 opcode (几乎永远是空清队), + 若空清队也刷新截止, 额度就**永远不过期**; +- 额度**跨多次清队累加** —— 写成"重置为本次条数"的话, 上一次那条待吸收的迟到应答会退化 + 成硬错误。 -**Usage Example:** +**`drop_and_absorb` 的唯一例外**: `timed_out == true` 且本条请求的窗口里**已经消费过额度** +⇒ **不补** (吸收**先于**配对, 所以被吃掉的就是它自己那条; 再补就会**自持** —— +实测改前能让新会话此后每一条笛卡尔命令都报"结局未知")。 +⚠ 这条例外**只对超时支成立**: pump 支 (读链路当场就炸) 根本没等过窗口, 那句前提在它身上 +没有依据。 -```cpp -auto guards = arm.get_guards(); -auto kw = guards.as_map(); -std::cout << "slew_limit = " << kw["slew_limit"].as_double() << " rad/s\n"; -``` +--- -#### 完整控制循环示例 +## 6. 锁序 -```cpp -/** DIRECT 模式 250Hz 控制循环 —— 正弦波扫关节1。 */ -#include -#include -#include -#include -#include -#include -#include "litearm/arm.hpp" -#include "litearm/transport.hpp" - -static volatile sig_atomic_t running = 1; -void handle_signal(int) { running = 0; } - -int main() { - signal(SIGINT, handle_signal); - - constexpr double DT = 0.004; // 4ms → 250Hz - constexpr double FREQ = 0.5; - constexpr double AMP = 0.5; - constexpr int N = 7; - - auto tp = std::make_shared("tcp/192.168.1.100:7447"); - litearm::Arm arm("", "armA", tp); - - // 配置护栏(一次性) - arm.set_guards(2.0, 20.0, 0.10, true); - - double t = 0.0; - while (running) { - auto loop_start = std::chrono::steady_clock::now(); - - std::vector q_ref(N, 0.0); - q_ref[0] = AMP * std::sin(2.0 * M_PI * FREQ * t); - - arm.send_mit( - std::vector(N, 50.0), - std::vector(N, 1.5), - q_ref, - std::vector(N, 0.0), - std::vector(N, 0.0) - ); - - t += DT; - auto elapsed = std::chrono::steady_clock::now() - loop_start; - auto sleep_ns = std::chrono::duration_cast( - std::chrono::duration(DT) - elapsed); - if (sleep_ns.count() > 0) std::this_thread::sleep_for(sleep_ns); - } - - arm.request_stop(); - std::cout << "已退出 DIRECT 模式" << std::endl; - return 0; -} +``` +cart_serial_ → CartPending::lock_ → transport::wlock_ +zg_lock_ → CartPending::lock_ → transport::wlock_ ``` -#### 安全护栏说明 +**反向无环**, 判据是"下层都不知道上层": `CartPending` 与 `SerialTransport` 都不引用 +`cart_serial_` / `zg_lock_`。 -| 护栏 | 说明 | 可关闭? | -| --- | --- | --- | -| **护栏1:协议参数 clamp** | kp≤500、kd≤5、dq_ref≤DQ_MAX、tau_ff≤TAU_MAX | 不可关闭 | -| **护栏2:命令通道斜率限制** | 相邻帧 q_ref 跳变 ≤ slew_limit × dt,dt 有上界 0.10s | 不可关闭,`slew_limit` 可收紧 | -| **护栏3:看门狗 fail-soft** | 命令中断超时自动回 hold | 不可关闭,`watchdog_timeout` 可调 | -| **护栏4:单一所有权** | DIRECT 激活时拒绝运动命令和遥操 | 不可关闭 | -| **位置限制(附加)** | q_ref 逐帧 clamp 到关节软限位 | 默认关,`position_bounds: true` 开启 | -| **速度限制(附加)** | dq_ref 逐帧 clamp 到 `±DQ_MAX` | 默认关,`velocity_bounds: true` 开启 | -| **加加速度限制(附加)** | dq_ref 变化率受限 | 默认关,`jerk_limit: true` 开启 | +- `cart_serial_` 是 **`std::recursive_mutex`** —— 嵌套点只有一个: `move_path` 把 + `BEGIN + ADD×n + RUN` 整段圈进本锁, 而 RUN 那一段本身也走 `request_and_wait`。 +- `zg_lock_` 只串行化启停; **保活线程不取它** (它取 `zg_err_mu_`) —— + 否则会与"持着 `zg_lock_` 等 join"的 `zero_g_stop` 死锁。 +- **`emergency_stop`/`disable`/`zero_g*`/`get_tcp` 自己不许获取 `cart_serial_`** —— + 降能量方向的动作与只读查询必须永远可达 (持锁者可能阻塞到 `move_timeout`)。 + ⚠ 但**入口在自己的临界区里调用 `get_tcp()` 是另一回事**: `move_c` 的起点校验就是那样。 +- `Ack::mu` 是**叶子锁的加强版**: 只在 `deliver`/`wait` 内部持有, 且 `deliver` 里 + **不调用任何子系统** (`on_reply` 刻意留在锁外)。 -> **安全底线:机械臂永远不允许乱飞。** 命令通道斜率限制 + 单一收口 + 看门狗 fail-soft + 固件兜底。 +--- -### 5.6 参数调节 +## 7. 安全不变量 + +逐条都有测试钉着: + +1. **fork 的子进程零下发** —— `ForkedSessionError` 挂在三个结构性收口 + (`require` / `Ack::wait` / `raw_write`) 上, 而不是逐入口枚举。 + 子进程里的 `close()` **故意泄漏** `Ack` 与传输层两个句柄。三条**独立**的死锁, + 各自实测过: ① `SerialTransport::close()` 要取的读锁很可能正被父进程的读线程持着; + ② `join()` 一条在子进程里**不存在**的线程会永久阻塞; + ③ ⚠ 最隐蔽的一条 —— 析构一个**有人停在上面的** `std::condition_variable` + (`Ack::stop_cv_`) 会永久阻塞 (glibc 组切换要取 condvar 的内部锁, 而那把锁与已不存在的 + 等待者绑在一起)。③ 在**成员析构**里, 躲不掉 ⇒ 调用方必须**根本不析构**那个对象。 + 见 `src/arm.cpp` 里 `close()` 的 fork 分支与 `Ack::~Ack()` (那里有最小复现的描述)。 + + **推论 (写给下一个加线程的人)**: 任何"子进程里可能被析构、且持有 condvar"的对象都有 + 这个形状。新增这类成员之前先想清楚它在 fork 之后会不会被谁析构。 +2. **终态零下发** —— `enter_dfu` 确认设备消失后置位, 之后每个入口抛 `ArmIsInDfuError`。 +3. **写失败 ⟹ 整帧未送达** —— `write_frame` 把 `write()` 抛与 `flush()` 抛**分开报**: + 前者抛 `TransportError`, 后者**不抛** (整帧已在驱动里, 失败收不回来), 只计 + `flush_failures`。这条不变量是 `CartPending::request` 能安全摘 token 的前提。 +4. **`RSP_CART_PLAN` 应答里没有命令 id** ⟹ 任何 FIFO 方案必有一支判错 ⟹ 只能选 + **判错得安全**的那一支: 宁可报"结局未知", **绝不报"成功"**。 +5. **到位 ≠ 停止** —— `CartPlan::settled` 要两条一起成立: 判到位收的尾 **且** + 到位判据满足**之后**回读的**实际 TCP** 与目标对得上。只看 `bit10` 会把"中途被扯断、 + TCP 根本不在目标上"报成到位。 +6. **零重力期拒绝动作命令, 但放行查询与降能量动作** —— 两处抛出点共用**同一个常量** + (`ZERO_G_GUARD_MESSAGE`), 改一处不会静默漂移。 +7. **`0x00` 恒等于"固件没有这条命令"** —— 它是唯一稳定的哨兵, 比版本号可靠。 +8. **不可逆命令不代劳** —— `save_params` / `reset_factory` / `model.commit` 都要求调用方 + 自己先 `disable()`, 库不替调用方做安全决策。 -| 方法 | 说明 | -|---|---| -| `set_gains(kp=nullopt, kd=nullopt)` / `get_gains()` | PD 增益设置/读取 | -| `clear_faults()` | 清除电机故障 | -| `set_payload(mass, com={0,0,0})` / `get_payload()` | 末端负载(质量 + 质心) | -| `set_installation(base_rpy=nullopt, gravity=nullopt)` / `get_installation()` | 安装姿态(基座 RPY 或重力向量) | +--- -### 5.7 外设设备 +## 8. 错误面 -```cpp -auto hand = arm.device("hand_0"); // RemoteDevice -hand.open(); hand.close(); // 开/合 -hand.set_force(0.5); // 抓取力 -hand.set_gesture("pinch"); hand.list_gestures(); -hand.finger_move({0.1, 0.2, 0.3, 0.4, 0.5, 0.6}); -hand.set_speed({...}); hand.set_torque({...}); -hand.get_state(); - -auto gripper = arm.device("gripper_0"); -gripper.set_width(0.5); double w = gripper.get_width(); - -auto teach = arm.device("teach_0"); -teach.get_joints(); teach.get_buttons(); - -// 通用:get_status / get_info / connect / disconnect / clear_faults -// 设备管理器:arm.devices()["hand_0"] 等价于 arm.device("hand_0") +``` +LiteArmError 本包所有错误 +├─ NotConnectedError 未连接 / 链路已关 +│ └─ ForkedSessionError 子进程里沿用父会话 (fail-closed) +├─ TransportError 串口读写 / 帧 CRC / 链路丢失 +├─ FirmwareMismatchError 版本不符合约定或过旧 +├─ InvalidCommandError 参数/命令非法 +├─ MotorFaultError 状态帧 FAULT / EMERGENCY / 单轴断轴 +├─ MotionTimeoutError move 超时未到位 +├─ IKError IK 失败 +├─ CommandRejectedError 固件显式 ERR{cmd, code} +│ └─ UnsupportedByFirmwareError ERR code == 0x00 ("固件没这条命令") +├─ CartesianPlanError 规划被拒 (IK/共线/超容量/越限) —— 臂一步没动 +├─ MotionSupersededError 被新请求取代 (预期内的接管, **不是失败**) +├─ CartReplyLostError 0x4E 丢了 —— 结局未知 +├─ ArmIsInDfuError 会话终态 +└─ NotRemoteable / NotSupportedOnThisBackend / TeleopLockedError / TeleopBusyError ``` -`RemoteDevice::call(method, kwargs)` 可调用任意设备方法,自动加 `device.{id}.` 前缀。 +三条**刻意不继承**的关系 (都有测试钉着): -### 5.8 系统 / 设置 / 轨迹 / 设备管理 / 遥操 +- `MotionSupersededError` **不**继承 `CartesianPlanError` —— 混进"规划失败"会让正常抢占 + 走成故障分支; +- `CartesianPlanError` **不**继承 `CommandRejectedError` —— 固件这里回的是**规划结果**, + 不是 `ERR{cmd, code}` 形态, 硬套会凭空多出语义错误的 `cmd`/`code` 字段; +- `ArmIsInDfuError` **不**继承 `NotConnectedError` —— 后者语义是"连上即可", 而终态不是 + 一次可恢复的掉线 (混在一起会让"断连就重连"的逻辑把 DFU 当普通掉线)。 -均返回 `LiteArmValue`: +`err_reason(cmd, code)` 是 `(cmd, code)` 的**唯一**解读处, 三级查找: -| 分组 | 方法 | -|---|---| -| 系统 | `get_system_stats()`、`get_logs(page=1, size=50, search="")`、`restart_service()`、`reconnect()` | -| 设置 | `get_joint_limits/set_joint_limits(limits)`、`get_zero_offsets/set_zero_offsets(offsets)`、`get_end_effector/set_end_effector(config)`、`get_cartesian_limits/set_cartesian_limits(limits)`、`get_collision_config/set_collision_config(config)` | -| 轨迹 | `start_recording/stop_recording/discard_recording/get_recording_state/get_playback_state/list_trajectories/save_trajectory(id,name,points,duration=nullopt)/delete_trajectory(id)` | -| 设备 | `list_device_types()`、`connect_device(category, subtype, device_id="end_0", can_iface="", config={})`、`disconnect_device(device_id="end_0")`、`get_active_device(device_id="end_0")` | -| 遥操 | `enter_teleop(mode, params={})`、`exit_teleop()`、`get_teleop_status()` | +1. 具体档 `err_text()` 命中 ⇒ 用它 (同一个 `0x03` 在 `0x01`/`0x10`/`0x23` 上语义不同); +2. 通用档命中 ⇒ 用它 **并附上原始码**; +3. 都没有 ⇒ 明说"未登记", **带上原始 cmd/code**。 -> 遥操态下服务端拒绝一切手动控制指令,只放行只读 / 急停 / `exit_teleop`。 +第 2/3 档之所以必须带原始码: 固件新增一档错误码时, 这条路径就是**唯一**会让上位机看见 +"这是我没见过的码"的地方。 -## 6. 异常处理 +--- -所有异常继承 `LiteArmError`(`std::runtime_error` 子类),服务端异常原样抛出。 +## 9. 测试 -```cpp -#include - -try { - arm.movej(...); -} catch (const litearm::SafetyViolationError& e) { // 超时/跟随/故障/看门狗 - // e.details 为 unordered_map -} catch (const litearm::LiteArmError& e) { // 兜底 - std::cerr << e.what(); -} +```bash +./build.sh # 构建 + ctest +./build.sh --asan # AddressSanitizer + UBSan +ctest --test-dir build -R test_cart -V +./build/tests/... --filter movej # 单文件内按名字过滤 (每个 test_*.cpp 是独立可执行文件) ``` -常用类型:`NotConnectedError`、`ConfigurationError`、`InvalidCommandError`、`CartesianPlanError`、 -`MotionTimeoutError`、`MotorFaultError`、`ArmFault`、`WatchdogError`、`MotionCancelled`。 - -## 7. 安全提示 - -- ⚠️ `disable()` 会使机械臂在重力作用下坠落,务必确认安全。 -- `request_stop()` 为高优先级急停,应绑定到独立物理急停通道。 -- 遥操态下不会执行手动控制指令。 -- `recover_joint_limits` 仅在 server 以 `allow_limit_recovery=True` 启动时可用。 +| 测试 | 覆盖 | +| --- | --- | +| `test_protocol` | CRC (标准检查值 + 独立参考实现) / 组帧解帧 / 版本解析 / 开机签名 / 两种状态帧布局 / 覆盖契约逐条对表 | +| `test_rot` | rpy<->矩阵 / 万向锁 / SO(3) 对数指数 / slerp / 姿态误差方向 / 三种位姿写法 | +| `test_errors` | 层级关系 / 三级查找 / 白名单 / `ERR` -> 异常的类型映射 | +| `test_state` | enabled/cart_busy/faulted/断轴/`drop_hold_inferred` | +| `test_transport` | **真 pty**: 逐字节往返 / 噪声跳过与留痕 / 坏帧重同步 / 半帧跨调用保留 / `timeout=0` 的"有就给我" / 假帧头超时丢弃 / 端点独占 / 失败路径释放登记 | +| `test_framing` | 分帧器**直接**单测 (不再只能经 pty 间接测): 一次喂多帧取空 / 跨调用半帧 / 噪声留痕与封顶 / 假帧头超时 / 坏帧重同步 / `reset` | +| `test_register` | 《未验证登记册》的格式守门: 五列 / 每条都有出处 / id 恰好 1..N / 没有丢了行首 `\|` 的隐形行 | +| `test_packaging` | 卖出去之前能守住的那几类: 版本单一口径 / 公开头的兄弟头都在装出去的目录里 / 打包模板在 | +| `test_arm_assembly` | 握手 / 版本拒绝 / 幂等 connect / 改靶 / 收尾 / DFU 终态四条路径 | +| `test_commands` | 使能重试白名单 / 安全命令 / 关节运动与到位 / 伺服透传 arity / 状态读取 / IK | +| `test_ff` | 0x26/0x27/0x28/0x31 + 0x2B/0x2C 读回 / item 表 | +| `test_params` | 0x22/0x23/0x24/0x36 + 武装门禁 | +| `test_model` | 探测 / staging-bank-commit 三层 / 掩码逐位 / 需失能 | +| `test_log` | 样本布局 / 游标续读 / 掉帧重试 / 游标不前进的死循环保护 / 逐拍记录模型 | +| `test_diagnostics` | 计时行 (名字与数字相连) / LINK 行 (**名字带数字的键**) / 两帧收齐 / 旧固件兼容 | +| `test_cart` | `CartPlan` / 异常映射 / 三条入口 / **FIFO 配对** / **吸收额度两个方向** / 清队 / 能力探测 | +| `test_zero_g` | 保活线程 / 双向守卫 / RAII / 保活中断可见 / close 停线程 | +| `test_license` | 26B 记录 / 未激活 / `0x02` 聚合档的回读定性 / 未授权时的使能门禁 | +| `test_threading` | 并发状态读者 / 并发笛卡尔入口串行 / close 唤醒等待者 / `Msg.hz` 语义 / 唯一写口无丢弃路径 | +| `test_precheck` | 客户端预检 (非有限值 / 软限 / 速度 / 容差) 与软限缓存的两条纪律 | +| `test_accessors` | 七个只读诊断访问器 (含终态下不抛、以及包装层转发链) | +| `test_clock` | 可注入时间源: 默认是真钟 / 注入可见 / 假钟下"超龄判失联"确定性可验 | +| `test_frame_ownership` | **帧归属**: 唯一读口 (源码扫描) / 归属判据 / 两线程互不吃应答 / 陈旧帧清队 / 队列封顶 / 守卫 | +| `test_full_coverage` | **命令覆盖动态哨兵** (每条已实现命令都真发出去过) / 无多余 id / 公开成员名单守卫 / 未连接时的错误类型全扫 | +| `test_protocol_sync` | **与固件头文件双向比对** —— **需 `LITEARM_FW_DIR`, 否则 SKIP** (见 §9 末) | +| `test_fork_guard` | 真 `fork()`: 子进程零下发 / 收尾不挂 | + +**全部离线** —— 用 `testing::FakeTransport` (按命令脚本化应答的假传输)。 +串口本身用**真 pty** 测: 桩会把 `timeout` 整个忽略掉, 于是"`read_frame(0)` 其实一个字节 +都不碰"这类缺陷在桩上全绿 (上游就这么漏掉过一次真机故障)。 + +**唯一的例外**是 `test_protocol_sync`: 它要读**固件仓库**的源码, 找不到时**SKIP** +(`LITEARM_FW_DIR`, 默认 `~/litearm-stm32`)。帧归属那套里还有两处**源码扫描**判据 +(唯一读口、公开成员名单) —— 它们用 `lt::repo_root()` 从 `__FILE__` 推路径, 与当前工作 +目录无关 (ctest 的 cwd 是构建目录, 相对路径在那边打不开 —— 实测踩过)。 + +⚠ **SKIP 不是通过**: 报告里单独计数并逐条打印。看到"N 跳过"就说明那部分**没测**。 -## 8. 与 litearm-python 对比 - -| 方面 | litearm-python | litearm-cpp | -|---|---|---| -| 值类型 | 原生 `dict`/`list` | `LiteArmValue`(tagged union) | -| 依赖 | 自动安装 | protobuf(gtest 仅测试) | -| 适用 | 快速原型 / 脚本 | 低延迟嵌入式 / 实时系统 | +--- -三个版本接口方法一一对应,代码可跨语言迁移。 +## 10. 与 Python 版的对照 -## License +移植是**逐条对照**的: 同一个常量值、同一句错误文案、同一个判据顺序。 +语言层的差异只有这些: -Proprietary +| 主题 | Python | C++ | +| --- | --- | --- | +| 位姿入参 | 运行期判形态 | `rot::PoseInput` (三种写法各有具名工厂; 6 向量隐式转换) | +| 取值 | `None` | `std::nullopt` | +| 属性 | `arm.n` | `arm.n()` (只读) / 公开成员 (可调旋钮) | +| 上下文管理器 | `with arm.zero_g():` | RAII `ZeroGSession`, 析构不抛 | +| 重载 | 同名多态 | `Arm::write_frame(cmd, {payload})` 之类便捷重载 | +| 弱引用 | `weakref` (读线程不钉住 Arm) | 不适用: 读线程在 `close()` 里被 join, 生命周期由 join 顺序保证 | +| `__del__` | GC 兜底收尾 | 析构兜底收尾 (同样只委托给 `close()`) | + +⚠ **两处已知的继承差异** (原版就有, 移植时刻意保留): + +1. `license()` 不传 `echo_cmd`, 于是在**低于 1.8.0** 的固件上报"无应答超时"而不是 + "固件没有这条命令" (证据见 `tests/test_license.cpp`)。 +2. `linux` 之外不做 CDC 自动发现 (macOS 无 sysfs; Windows 需要 SetupAPI 才能可靠读到 + VID:PID, 猜一个会给出假阳性)。 diff --git a/env.ps1 b/env.ps1 new file mode 100644 index 0000000..028ec64 --- /dev/null +++ b/env.ps1 @@ -0,0 +1,35 @@ +# ============================================================================ +# litearm-cpp 运行环境 (Windows / PowerShell, dot-source 用) +# +# . .\env.ps1 +# .\run_example.ps1 01_hello +# +# 作用: 找到/构建 build\ 并把可执行文件目录加进 PATH; LITEARM_PORT 可覆盖 CDC 端口。 +# +# ⚠ SDK 本身不读任何环境变量 —— LITEARM_PORT 只被样例/脚本这一层用。 +# ⚠ Windows 上的**端口自动发现不可用** (要 SetupAPI 才能可靠读到 VID:PID), +# 请显式传端口或用 LITEARM_PORT。 +# ============================================================================ +$ErrorActionPreference = "Stop" + +$LiteArmRepo = $PSScriptRoot +$LiteArmBuild = if ($env:LITEARM_BUILD) { $env:LITEARM_BUILD } else { Join-Path $LiteArmRepo "build" } + +$probe = Join-Path $LiteArmBuild "Release\01_hello.exe" +if (-not (Test-Path $probe)) { $probe = Join-Path $LiteArmBuild "01_hello.exe" } +if (-not (Test-Path $probe)) { + Write-Host "[litearm-cpp env] 首次运行, 正在构建 -> $LiteArmBuild" + cmake -S $LiteArmRepo -B $LiteArmBuild -DCMAKE_BUILD_TYPE=Release | Out-Null + cmake --build $LiteArmBuild --config Release | Out-Null +} + +$env:LITEARM_REPO = $LiteArmRepo +$env:LITEARM_BUILD = $LiteArmBuild +$env:LITEARM_BIN = $LiteArmBuild +$env:PATH = "$LiteArmBuild;$LiteArmBuild\Release;$env:PATH" +if (-not $env:LITEARM_PORT) { $env:LITEARM_PORT = "" } + +Write-Host "[litearm-cpp env] repo=$LiteArmRepo" +Write-Host " build = $LiteArmBuild" +Write-Host " LITEARM_PORT = '$($env:LITEARM_PORT)'" +Write-Host " ⚠ Windows 上端口自动发现不可用, 请显式给 --port 或设 LITEARM_PORT" diff --git a/env.sh b/env.sh new file mode 100755 index 0000000..b9e3c65 --- /dev/null +++ b/env.sh @@ -0,0 +1,33 @@ +#! /usr/bin/env bash +# ============================================================================ +# litearm-cpp 运行环境 (source 用) —— 让 examples / tests 免安装即可跑 +# +# source env.sh +# ./run_example.sh 01_hello # 推荐 +# "$LITEARM_BIN/01_hello" # 等价 +# +# 作用: +# 1) 把 build/ 里的可执行文件放进 PATH; +# 2) 没构建过就自动构建一次 (CMake + make); +# 3) LITEARM_PORT 可覆盖 CDC 端口; 不设则自动发现 (VID:PID 1d50:606f)。 +# +# ⚠ SDK 本身**不读任何环境变量** —— LITEARM_PORT 只被样例/脚本这一层用。 +# ============================================================================ +set -euo pipefail + +export LITEARM_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export LITEARM_BUILD="${LITEARM_BUILD:-${LITEARM_REPO}/build}" + +if [ ! -x "${LITEARM_BUILD}/01_hello" ]; then + echo "[litearm-cpp env] 首次运行, 正在构建 -> ${LITEARM_BUILD}" + cmake -S "${LITEARM_REPO}" -B "${LITEARM_BUILD}" -DCMAKE_BUILD_TYPE=Release >/dev/null + cmake --build "${LITEARM_BUILD}" -j"$(nproc 2>/dev/null || echo 4)" >/dev/null +fi + +export LITEARM_BIN="${LITEARM_BUILD}" +export PATH="${LITEARM_BIN}:${PATH}" +export LITEARM_PORT="${LITEARM_PORT:-}" + +echo "[litearm-cpp env] repo=${LITEARM_REPO}" +echo " build = ${LITEARM_BUILD}" +echo " LITEARM_PORT = '${LITEARM_PORT}' (空=自动发现 1d50:606f)" diff --git a/examples/01_hello.cpp b/examples/01_hello.cpp new file mode 100644 index 0000000..a3c6cb3 --- /dev/null +++ b/examples/01_hello.cpp @@ -0,0 +1,59 @@ +// 样例 01 · 连接握手 + 只读状态/末端位姿 (安全, 不需要 --go)。 +// +// 演示: +// Arm().connect() 自动找 CDC 并校验固件版本约定 (Litearm>=1.5.0) +// firmware()/n() 固件版本串 + 关节数 +// get_state() 状态帧: q/dq/tau/温度/err/flags/mode (返回 Msg 信封) +// get_tcp() 当前末端 pos[3]+rpy[3] (固件 FK, 无运动学模型) +// +// 运行: ./01_hello +#include + +#include "_common.hpp" + +using namespace litearm; + +int main(int argc, char** argv) { + return example::run_example( + argc, argv, "样例 01 · 连接握手 + 只读状态", + [](Arm& arm, const example::Args&) -> int { + std::printf("固件: %s (n=%d 关节)\n", arm.firmware().c_str(), arm.n()); + + const auto st_msg = arm.get_state(); + if (!st_msg.value) { + std::printf("\n[状态] 取不到状态帧 (链路无上行)\n"); + return 1; + } + const RobotState& st = *st_msg.value; + std::printf("\n[状态] mode=%s hz=%.1f flags=", st.mode_name.c_str(), st_msg.hz); + if (st.flag_names.empty()) { + std::printf("-"); + } else { + for (size_t i = 0; i < st.flag_names.size(); ++i) { + std::printf("%s%s", i ? "," : "", st.flag_names[i].c_str()); + } + } + std::printf("\n"); + std::printf(" q = %s\n", example::fmt(st.q()).c_str()); + std::printf(" dq = %s\n", example::fmt(st.dq()).c_str()); + std::printf(" tau = %s\n", example::fmt(st.tau()).c_str()); + if (st.faulted()) { + std::printf(" ⚠️ 检测到 FAULT/EMERGENCY (%s) —— 先排查再运动!\n", + st.fault_detail().c_str()); + } + + const auto tcp = arm.get_tcp().value; + if (tcp) { + std::printf( + "\n[末端] pos = %s rpy = %s\n", + example::fmt(std::vector(tcp->begin(), tcp->begin() + 3), 4) + .c_str(), + example::fmt(std::vector(tcp->begin() + 3, tcp->end()), 3) + .c_str()); + } else { + std::printf("\n[末端] 取不到 TCP\n"); + } + std::printf("\n已断开 (只读, 未改电机状态)\n"); + return 0; + }); +} diff --git a/examples/02_movej.cpp b/examples/02_movej.cpp new file mode 100644 index 0000000..6ed4ea4 --- /dev/null +++ b/examples/02_movej.cpp @@ -0,0 +1,55 @@ +// 样例 02 · movej 关节轨迹 (单发, 固件 S 曲线自完成 + 静止保持)。 +// +// 演示: +// arm.enable() +// arm.movej(q_target, speed) 一次下发; 固件规划 S 曲线到位并静止保持, +// 无需 PC 逐帧保活。 +// 到位判定: 后端采样状态, 各轴 |q-target|<容差 且 dq≈0 连续 N 帧。 +// +// ⚠ 跑完**刻意不 disable**: 该位形失能会因自重坠回, 保持使能持位才是安全终态。 +// +// 安全: 需 --go 才 enable+运动。默认目标 = 当前位形小步试探。 +// +// 运行: +// ./02_movej --go # 当前位置 + 小步 +// ./02_movej --go 0.1 0 -0.1 0 0 0 0 # 显式目标 (7 角) +#include + +#include "_common.hpp" + +using namespace litearm; + +int main(int argc, char** argv) { + return example::run_example( + argc, argv, "样例 02 · movej 关节轨迹", + [](Arm& arm, const example::Args& args) -> int { + const auto st0 = arm.get_state().value; + if (!args.go) { + std::printf("只读连接: 加 --go 才会 enable 并运动 (跳过)\n"); + if (st0) std::printf("当前 q = %s\n", example::fmt(st0->q()).c_str()); + return 0; + } + arm.enable(); + std::vector target; + if (!args.rest.empty()) { + if (int(args.rest.size()) != arm.n()) { + std::printf("需 %d 个关节角, 给了 %zu 个\n", arm.n(), args.rest.size()); + return 1; + } + target = args.rest; + } else { + target = st0 ? st0->q() : std::vector(size_t(arm.n()), 0.0); + if (target.size() > 2) target[2] += 0.1; // 默认小步: J3 +0.1 + std::printf("默认目标 = 当前位置 + 小步: %s\n", + example::fmt(target).c_str()); + } + arm.movej(target, args.speed); + const auto st = arm.get_state().value; + if (st) { + std::printf("movej 到位 q = %s\n", example::fmt(st->q()).c_str()); + std::printf(" tau = %s\n", example::fmt(st->tau(), 2).c_str()); + } + return 0; + }, + []() { std::printf("断开 (保持使能持位)\n"); }); +} diff --git a/examples/03_move_p.cpp b/examples/03_move_p.cpp new file mode 100644 index 0000000..8b01b0f --- /dev/null +++ b/examples/03_move_p.cpp @@ -0,0 +1,45 @@ +// 样例 03 · move_p 位姿运动 (单 pose, 固件内置 IK + S 曲线)。 +// +// 演示: +// arm.get_tcp() 读当前末端 pos[3]+rpy[3] +// arm.move_p(pose, speed) 一次下发 pose; 固件后台 DLS-IK 求 q -> S 曲线到位 +// 后端轮询 get_tcp 至 TCP≈目标 (pos 容差/rpy 容差) 判定到位。 +// +// 注意: move_p 是【点到点】**关节空间**插值 (非笛卡尔直线), 且**只收单个位姿** —— +// 要末端走直线/圆弧/多路点见 06_cartesian.cpp。 +// +// 安全: 需 --go; 默认在当前 TCP 上沿 +X 平移 1cm (小步可回退)。 +// ⚠ 跑完刻意不 disable (同 02)。 +// +// 运行: ./03_move_p --go +#include + +#include "_common.hpp" + +using namespace litearm; + +int main(int argc, char** argv) { + return example::run_example( + argc, argv, "样例 03 · move_p 位姿运动", + [](Arm& arm, const example::Args& args) -> int { + const auto tcp = arm.get_tcp().value; + if (!tcp) { + std::printf("取不到当前 TCP\n"); + return 1; + } + std::printf("当前 TCP: %s\n", example::fmt(*tcp, 4).c_str()); + if (!args.go) { + std::printf("只读连接: 加 --go 才会执行 move_p (跳过)\n"); + return 0; + } + arm.enable(); + std::array target = *tcp; + target[0] += 0.01; // 世界 +X 1cm 小步 + std::printf("move_p 目标: %s\n", example::fmt(target, 4).c_str()); + arm.move_p(target, args.speed); + const auto tcp2 = arm.get_tcp().value; + if (tcp2) std::printf("move_p 到位 TCP: %s\n", example::fmt(*tcp2, 4).c_str()); + return 0; + }, + []() { std::printf("断开 (保持使能持位)\n"); }); +} diff --git a/examples/04_ik_tcp.cpp b/examples/04_ik_tcp.cpp new file mode 100644 index 0000000..10b3183 --- /dev/null +++ b/examples/04_ik_tcp.cpp @@ -0,0 +1,41 @@ +// 样例 04 · IK / TCP 查询 (只读, 不需 --go)。 +// +// 演示: +// arm.get_tcp() 当前末端位姿 (固件当前反馈 FK) +// arm.ik(pose) pose[6] -> q[7] (固件后台 DLS IK, seed=当前 q) +// 常见组合: 读当前 pose -> 验证 ik(pose) 解回 q 应与当前关节角相近 (自洽)。 +// +// 运行: ./04_ik_tcp +#include +#include +#include + +#include "_common.hpp" + +using namespace litearm; + +int main(int argc, char** argv) { + return example::run_example( + argc, argv, "样例 04 · IK / TCP 查询", + [](Arm& arm, const example::Args&) -> int { + const auto st = arm.get_state().value; + const auto tcp = arm.get_tcp().value; + if (!st || !tcp) { + std::printf("取不到状态或 TCP\n"); + return 1; + } + std::printf("当前 q = %s\n", example::fmt(st->q()).c_str()); + std::printf("当前 TCP = %s\n", example::fmt(*tcp, 4).c_str()); + + const std::vector q_ik = arm.ik(*tcp); // 用当前 pose 反解 + std::printf("\nik(当前pose) = %s\n", example::fmt(q_ik).c_str()); + double dev = 0.0; + const auto cur = st->q(); + for (size_t i = 0; i < q_ik.size() && i < cur.size(); ++i) { + dev = std::max(dev, std::fabs(q_ik[i] - cur[i])); + } + std::printf("与当前关节角最大偏差 = %.3f rad (自洽应 <~0.05)\n", dev); + std::printf("\n已断开 (只读)\n"); + return 0; + }); +} diff --git a/examples/05_ff_tune.cpp b/examples/05_ff_tune.cpp new file mode 100644 index 0000000..498194b --- /dev/null +++ b/examples/05_ff_tune.cpp @@ -0,0 +1,53 @@ +// 样例 05 · 内置动力学/控制律调参 (RAM 生效, 需 save 才持久)。 +// +// 演示: +// arm.ff_preset(1) 出厂组合: master+G+惯量+科氏+摩擦+积分 (开箱) +// arm.set_gravity_scale(gs) 重力逐关节缩放 (承重 J2/J3 需 2~4.5x) +// arm.set_inertia_scale(is) 惯量逐关节缩放 +// arm.set_gravity_vector(g) 换安装/倒装改重力方向 +// arm.set_payload(mass, com) 末端负载 (点质量, com 在 ee 法兰系) +// arm.save_params() 持久化到 Flash (0x25) —— ⚠ 写 flash, 不可逆 +// +// 安全: 需 --go 才真正下发; 调参后建议用 02 的小步 movej 验证手感想硬还是软。 +// +// 运行: +// ./05_ff_tune # 只打印将发的内容 +// ./05_ff_tune --go # 下发出厂 preset + 显示性 set +#include + +#include "_common.hpp" + +using namespace litearm; + +namespace { +const std::vector GS{2.7, 2.1, 1.75, 1.85, 2.9, 3.1, 1.2}; // 出厂 yaml +const std::vector IS{2.84, 8.62, 9.5, 2.56, 5.27, 4.02, 3.56}; +} // namespace + +int main(int argc, char** argv) { + return example::run_example( + argc, argv, "样例 05 · 动力学/控制律调参", + [](Arm& arm, const example::Args& args) -> int { + std::printf("将演示的固件 FF 命令 (0x26-0x28/0x31):\n"); + std::printf(" ff_preset(1) -> master+G+惯量+科氏+摩擦+积分\n"); + std::printf(" set_gravity_scale(%s)\n", example::fmt(GS).c_str()); + std::printf(" set_inertia_scale(%s)\n", example::fmt(IS).c_str()); + std::printf(" set_gravity_vector(0,0,-9.81) / set_payload(...) / save_params()\n"); + if (!args.go) { + std::printf("只读: 加 --go 才下发 (不改固件参数)\n"); + return 0; + } + arm.ff_preset(1); + arm.set_gravity_scale(GS); + arm.set_inertia_scale(IS); + arm.set_gravity_vector({0.0, 0.0, -9.81}); + // 负载演示: 先清空(默认出厂无负载), 注释掉即不清 + // arm.set_payload(0.0, {0.0, 0.0, 0.0}); + arm.save_params(); + std::printf("已下发出厂 preset + gs/is/gravity 并保存到 Flash\n"); + std::printf("读回校验: gravity_scale[0] = %.3f, inertia_scale[0] = %.3f\n", + arm.get_ff_vec(7).value[0], arm.get_ff_vec(8).value[0]); + return 0; + }, + []() { std::printf("已断开\n"); }); +} diff --git a/examples/06_cartesian.cpp b/examples/06_cartesian.cpp new file mode 100644 index 0000000..56caebf --- /dev/null +++ b/examples/06_cartesian.cpp @@ -0,0 +1,110 @@ +// 样例 06 · 笛卡尔路径 (固件规划): move_l / move_c / move_path。 +// +// 与 03 的区别: 03 的 `move_p(单 pose)` 是固件做**关节空间**插值 (不是直线); +// 本样例这三条把**末端路径**交给固件规划 —— 末端真的沿直线/圆弧/折线走。 +// +// arm.move_l(goal, speed) 末端走**直线** (位置线性 + 姿态 slerp) +// arm.move_c(start, via, goal, speed) 末端走**圆弧** (三点定圆; via 的姿态被忽略) +// arm.move_path({p1, p2, ...}, speed) 依次经过多个位姿 (**尖角**) +// +// 位姿三种写法都收: 固件的 [x,y,z,roll,pitch,yaw] / (pos[3], R[3x3]) / 4x4 齐次矩阵。 +// 起点不是参数 —— move_l/move_path 由固件取**当前实测 TCP**, move_c 的 start 必须与实测 +// TCP 一致 (容差 6mm / 0.03rad, 超差抛 InvalidCommandError 并给出实际值)。 +// +// 返回值是 CartPlan。ok/err/n_wp/plan_us 是固件给的规划结果; +// started_busy/settled/q_final/settle_err_rad 只有 wait=true (默认) 才填真值, +// wait=false 时是"**没等**", 不是"没到位"。 +// ⚠ ok=true 只说明**固件受理并规划出来了**, 不说明臂停在了目标上 (settled=false 就是 +// "轨迹被别的运动作废了") —— 现场要回读 arm.get_tcp() 看真实落点。 +// +// ⚠ 三条已知降级 (相对被它取代的 PC 侧规划): +// * **无拐角倒角**: 协议里没有 blend 字段, 所以多路点过拐角是**尖的**; +// * **无下发前预览**: 固件没有 dry-run, 0x4E 要发出去才回 —— 圆弧实际扫多大只能 +// **事后**从 n_wp 判读; +// * **速度预检改由固件做**: 路径的可接受性由固件在规划阶段判, 入口按 plan.err 抛 +// CartesianPlanError。 +// +// 安全: 需 --go; 默认在当前 TCP 上沿 +X 平移 1cm 再回程 (小步可回退)。 +// ⚠ 跑完刻意不 disable (同 02)。 +// +// 运行: +// ./06_cartesian --go +// ./06_cartesian --go --speed 0.3 # 更慢 +#include + +#include "_common.hpp" + +using namespace litearm; + +namespace { +void show(const char* label, const CartPlan& plan) { + std::printf(" %s: ok=%d n_wp=%d plan=%.1fms busy=%d settled=%d settle_err=%.4frad\n", + label, int(plan.ok), plan.n_wp, double(plan.plan_us) / 1000.0, + int(plan.started_busy), int(plan.settled), plan.settle_err_rad); +} +} // namespace + +int main(int argc, char** argv) { + return example::run_example( + argc, argv, "样例 06 · 笛卡尔路径 (固件规划)", + [](Arm& arm, const example::Args& args) -> int { + const auto tcp0 = arm.get_tcp().value; + if (!tcp0) { + std::printf("取不到当前 TCP\n"); + return 1; + } + std::printf("当前 TCP: %s\n", example::fmt(*tcp0, 4).c_str()); + const std::array pos{tcp0->at(0), tcp0->at(1), tcp0->at(2)}; + const std::array rpy{tcp0->at(3), tcp0->at(4), tcp0->at(5)}; + + if (!arm.cart_supported()) { + std::printf("本机固件没编进 LITEARM_CART_PLAN —— 笛卡尔入口不可用\n"); + return 1; + } + if (!args.go) { + std::printf("只读连接: 加 --go 才会执行 (跳过)\n"); + return 0; + } + + arm.enable(); + + // ---- move_l: 直线 1cm ---- + const std::array goal{pos[0] + 0.010, pos[1], pos[2], + rpy[0], rpy[1], rpy[2]}; + std::printf("\n--- move_l 直线 +1cm ---\n"); + show("move_l", arm.move_l(goal, args.speed)); + + // ---- move_c: 小圆弧。⚠ start 必须与**此刻实测** TCP 一致 ---- + std::printf("\n--- move_c 圆弧 ---\n"); + const auto start_opt = arm.get_tcp().value; + if (start_opt) { + const std::array start = *start_opt; + const std::array via{start[0] + 0.008, start[1] + 0.008, + start[2], rpy[0], rpy[1], rpy[2]}; + const std::array end{start[0], start[1] + 0.016, start[2], + rpy[0], rpy[1], rpy[2]}; + const CartPlan plan = arm.move_c(start, via, end, args.speed); + show("move_c", plan); + std::printf(" (没有下发前预览: 扫了多大弧只能从 n_wp=%d 事后判读)\n", + plan.n_wp); + + // ---- move_path: 两个路点, 尖角 (协议没有倒角字段) ---- + const std::array w1{start[0] + 0.010, start[1] + 0.005, + start[2], rpy[0], rpy[1], rpy[2]}; + const std::array w2{start[0] + 0.010, start[1] + 0.015, + start[2], rpy[0], rpy[1], rpy[2]}; + std::printf("\n--- move_path 2 路点 (尖角, 无倒角) ---\n"); + show("move_path", arm.move_path({w1, w2}, args.speed)); + } + + // ---- 回起点 ---- + std::printf("\n回起点...\n"); + show("move_l", arm.move_l(*tcp0, args.speed)); + const auto final_tcp = arm.get_tcp().value; + if (final_tcp) { + std::printf("收尾 TCP: %s\n", example::fmt(*final_tcp, 4).c_str()); + } + return 0; + }, + []() { std::printf("断开 (保持使能持位)\n"); }); +} diff --git a/examples/07_vel_jitter_trace.cpp b/examples/07_vel_jitter_trace.cpp new file mode 100644 index 0000000..98871b3 --- /dev/null +++ b/examples/07_vel_jitter_trace.cpp @@ -0,0 +1,238 @@ +// 样例 07 · 一次运动的逐拍采集 (速度环抖动分析用, 真机, 需 --go)。 +// +// 做什么: 从当前位形出发, 让末端**水平**平移一段距离, 并同时录**两路**互补的数据 +// (速度环的输入/输出 vs 反馈): +// +// 路① `_status.csv` —— **100Hz 实测流**, 从状态帧取。给速度环补上**反馈**那一路 +// (实测位置/速度只在这里有)。 +// 列: t, seq, mode, flags, joint_fault, q0..6, dq0..6, tau0..6 +// 靠 seq 逐帧对账 (丢帧如实报数, 不掩盖)。走的是有间歇掉帧的 CDC 上行。 +// +// 路② `.csv` —— **300Hz 固件日志** (CMD_LOG_CTRL 0x2D / CMD_LOG_READ 0x2E), +// 固件侧逐拍记录, 绕开 CDC 掉帧: +// 列: tick, t, q_ref0..6, dq0..6, tau0..6 +// tick = 控制拍号 (300Hz), q_ref = **参考**位置, dq = **指令**速度, +// tau = **实测**力矩。 +// ⚠ 没有实测位置/速度 —— 那两路只在路①里。 +// ⚠ 出厂 ff_mask 一般含 FF_VELREF(0x100), 固件把 kd·dq 移进 tau 并令 dq_s=0, +// **且这发生在打点之前** => 出厂配置下日志的 dq 列恒为 0 (tau 列仍是真话)。 +// 要看真实 dq_s 就用 `--ff-mask 0x0BF` 把该位清掉。 +// +// ⚠ 缓冲区体检: 固件 LOG_MAX_SAMPLES=2400 (约 8s@300Hz)。装不下时固件写满自停、运动 +// 照走, 结果是"数据被截断但不报错" —— 所以本样例显式核对录到的拍数并报出来。 +// +// ⚠ 与前身 (Python 版 07) 的差别: 那一版还带**方向 IK 预检 + 终点软限位余量门槛**与 +// 四段 A/B 序列 (`--legs`)。本样例只保留"一次平移 + 双路采集"这条主干, 把方向选择 +// 交给调用方 —— 预检要用到 PC 侧模型, 而本包的定位是"不做 PC 侧运动学"。 +// +// 安全: 默认只读, 加 --go 才动。跑完**刻意不 disable**: 该位形失能会因自重坠回, +// 保持使能持位才是安全终态。急停在手边。 +// +// 运行: +// ./07_vel_jitter_trace # 只读: 打印计划 +// ./07_vel_jitter_trace --go # 真跑 (默认 40mm) +// ./07_vel_jitter_trace --go --dist 0.1 --dur 2.5 +#include +#include +#include +#include +#include +#include +#include +#include + +#include "_common.hpp" + +using namespace litearm; + +namespace { + +// 固件 LOG_MAX_SAMPLES / LITEARM_CTRL_HZ —— 直接用库里的常量, 免得两处各写一份。 +// (别名带 k 前缀: `using namespace litearm` 之下叫 CTRL_HZ 会与库里的那个撞成二义。) +constexpr int LOG_TICKS = litearm::LOG_MAX_SAMPLES; +constexpr double kCtrlHz = double(litearm::CTRL_HZ); + +/// 方向: 0=+X 1=+Y 2=-X 3=-Y (世界系水平四向)。 +const char* axis_name(int dir) { + switch (dir) { + case 0: return "+X"; + case 1: return "+Y"; + case 2: return "-X"; + default: return "-Y"; + } +} + +void apply_dir(int dir, std::array* p, double d) { + switch (dir) { + case 0: (*p)[0] += d; break; + case 1: (*p)[1] += d; break; + case 2: (*p)[0] -= d; break; + default: (*p)[1] -= d; break; + } +} + +/// 把 100Hz 状态流的逐帧差异写成 CSV; 返回 (写出的帧数, 丢帧数)。 +std::pair dump_status_csv(const std::string& path, + const std::vector>& rows) { + FILE* f = std::fopen(path.c_str(), "w"); + if (f == nullptr) return {0, 0}; + std::fprintf(f, "t,seq,mode,flags,joint_fault,q0,q1,q2,q3,q4,q5,q6," + "dq0,dq1,dq2,dq3,dq4,dq5,dq6,tau0,tau1,tau2,tau3,tau4,tau5,tau6\n"); + int written = 0; + int gaps = 0; + long prev_seq = -1; + for (const auto& row : rows) { + const RobotState& st = row.second; + if (prev_seq >= 0) { + const long delta = (long(st.seq) - prev_seq) & 0xFFFF; + if (delta != 1) ++gaps; // 丢帧如实报数, 不掩盖 + } + prev_seq = long(st.seq); + std::fprintf(f, "%.4f,%u,%d,%u,%u", row.first, st.seq, st.mode, st.flags, + st.joint_fault); + for (double v : st.q()) std::fprintf(f, ",%.6f", v); + for (double v : st.dq()) std::fprintf(f, ",%.6f", v); + for (double v : st.tau()) std::fprintf(f, ",%.6f", v); + std::fprintf(f, "\n"); + ++written; + } + std::fclose(f); + return {written, gaps}; +} + +} // namespace + +int main(int argc, char** argv) { + return example::run_example( + argc, argv, "样例 07 · 一次运动的双路逐拍采集", + [](Arm& arm, const example::Args& args) -> int { + // 本样例私有开关: --dist / --dur / --dir / --tag / --ff-mask + const double dist = args.num("dist", 0.040); + const double dur = args.num("dur", 1.5); + const int dir = int(args.num("dir", 0.0)); + const std::string tag = args.str("tag", "trace"); + const bool set_ff_mask = args.has("ff-mask"); + const uint32_t ff_mask = uint32_t(args.num("ff-mask", 0x0BF)); + + const auto tcp = arm.get_tcp().value; + const auto st = arm.get_state().value; + if (!tcp || !st) { + std::printf("取不到状态或 TCP\n"); + return 1; + } + std::array goal = *tcp; + apply_dir(dir, &goal, dist); + + // 时间预算: 固件是 300Hz 逐拍记录的, 运动时长 * 300 必须落在缓冲里。 + const int expect_ticks = static_cast(dur * kCtrlHz * 1.3) + 60; + std::printf("计划: 沿 %s 平移 %.0fmm (speed=%.2f, 约 %.1fs)\n", axis_name(dir), + dist * 1000.0, args.speed, dur); + std::printf(" 当前 TCP %s\n", example::fmt(*tcp, 4).c_str()); + std::printf(" 目标 TCP %s\n", example::fmt(goal, 4).c_str()); + std::printf(" 预计录到 %d 拍 (固件上限 %d 拍 ≈ %.1fs)\n", expect_ticks, + LOG_TICKS, LOG_TICKS / kCtrlHz); + if (expect_ticks > LOG_TICKS) { + std::printf("⚠️ 预计超出固件缓冲 —— 数据会被**静默截断**。请减小 --dur。\n"); + } + if (!args.go) { + std::printf("\n只读: 加 --go 才真跑 (不改电机状态)\n"); + return 0; + } + if (!arm.cart_supported()) { + std::printf("本机固件没编进 LITEARM_CART_PLAN —— 没有可用的直线入口, 退出\n"); + return 1; + } + if (set_ff_mask) { + std::printf("\n先写 ff_mask=0x%03X (该分支无武装门控, 使能态可写)\n", + ff_mask); + arm.set_ff_mask(ff_mask); + } + + arm.enable(); + + // ---- 路①: 起一个采集线程, 按 100Hz 抓状态帧 ---- + std::vector> rows; + std::atomic stop{false}; + std::thread grabber([&]() { + const double t0 = now_s(); + while (!stop.load()) { + try { + const auto m = arm.get_state(true, 0.05); + if (m.value) rows.emplace_back(now_s() - t0, *m.value); + } catch (...) { + break; + } + } + }); + + // ---- 路②: 开 300Hz 固件日志, 然后发运动 ---- + arm.log().start(LOG_TICKS); + const double t_move0 = now_s(); + const CartPlan plan = arm.move_l(goal, args.speed); + const double move_s = now_s() - t_move0; + stop.store(true); + grabber.join(); + + // 等固件把该录的录完 (逐拍记录, 运动结束时可能还差最后一拍) + int recorded_ticks = 0; + try { + LogReader reader = arm.log().reader(1.0, 3); + reader.wait_for(expect_ticks, 3.0, 0.02); + recorded_ticks = int(reader.total_bytes()) / sample_size(arm.n()); + } catch (const LiteArmError& e) { + std::printf("⚠️ 等待录满失败 (%s) —— 按此刻已录到的读回\n", e.what()); + } + + // ---- 落盘 ---- + const std::string status_csv = tag + "_status.csv"; + const std::string log_csv = tag + ".csv"; + const auto sc = dump_status_csv(status_csv, rows); + + FILE* lf = std::fopen(log_csv.c_str(), "w"); + int log_rows = 0; + if (lf != nullptr) { + std::fprintf(lf, "tick"); + for (int j = 0; j < arm.n(); ++j) std::fprintf(lf, ",q_ref%d", j); + for (int j = 0; j < arm.n(); ++j) std::fprintf(lf, ",dq%d", j); + for (int j = 0; j < arm.n(); ++j) std::fprintf(lf, ",tau%d", j); + std::fprintf(lf, "\n"); + try { + LogReader reader = arm.log().reader(1.0, 3); + for (const auto& s : reader.samples()) { + std::fprintf(lf, "%u", s.tick); + for (double v : s.q_ref) std::fprintf(lf, ",%.6f", v); + for (double v : s.dq) std::fprintf(lf, ",%.6f", v); + for (double v : s.tau) std::fprintf(lf, ",%.6f", v); + std::fprintf(lf, "\n"); + ++log_rows; + } + } catch (const LiteArmError& e) { + std::printf("⚠️ 日志读回失败: %s\n", e.what()); + } + std::fclose(lf); + } + + std::printf("\n=== 结果 ===\n"); + std::printf("规划: ok=%d n_wp=%d plan=%.1fms settled=%d\n", int(plan.ok), + plan.n_wp, double(plan.plan_us) / 1000.0, int(plan.settled)); + std::printf("运动耗时 %.2fs (预计 %.2fs)\n", move_s, dur); + std::printf("路① %s: %d 帧, %d 处丢帧\n", status_csv.c_str(), sc.first, + sc.second); + std::printf("路② %s: %d 拍 (固件录到 %d 拍)\n", log_csv.c_str(), log_rows, + recorded_ticks); + if (recorded_ticks >= LOG_TICKS - 2) { + std::printf("⚠️ 固件缓冲已写满 —— 数据可能被**静默截断** (减小 --dur)\n"); + } + const auto final_tcp = arm.get_tcp().value; + if (final_tcp) { + std::printf("收尾 TCP: %s\n", example::fmt(*final_tcp, 4).c_str()); + } + if (set_ff_mask) { + std::printf("提示: ff_mask 已改成 0x%03X, 需要恢复请写回原值或 reset\n", + ff_mask); + } + return 0; + }, + // 刻意不 disable: 该位形失能会因自重坠回。 + []() { std::printf("断开 (保持使能持位是安全终态)\n"); }); +} diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..23bfaf0 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,59 @@ +# Examples + +Seven runnable programs that exercise the SDK against a real arm; read this before running any of them. + +**Read-only by default.** Anything that moves requires an explicit `--go`. + +```bash +cd .. +source env.sh +./run_example.sh 01_hello +./run_example.sh 02_movej --go +``` + +| Example | What it does | Needs `--go` | +| --- | --- | --- | +| `01_hello` | Handshake, firmware version, state read (q/dq/tau/flags/mode), current TCP | No (read-only) | +| `02_movej` | Joint motion: `enable` → `movej` → wait for arrival → read back | **Yes** | +| `03_move_p` | Cartesian point-to-point: read TCP → `move_p` → read TCP back | **Yes** | +| `04_ik_tcp` | `get_tcp` + `ik(pose)` self-consistency check | No (read-only) | +| `05_ff_tune` | Dynamics and control-law tuning: `ff_preset` / gs / is / gravity / payload | **Yes** | +| `06_cartesian` | Cartesian line / arc / multi-waypoint (firmware planning) | **Yes** | +| `07_vel_jitter_trace` | Dual-channel per-tick capture: 100 Hz state stream + 300 Hz firmware log | **Yes** | + +## Common flags + +All examples accept these three: + +``` +--port PORT Serial port (defaults to autodetecting 1d50:606f, or the LITEARM_PORT environment variable) +--go Actually enable / move / write parameters. Without it the connection is read-only and no torque is applied. +--speed S Move speed scale 0~1 (default 0.3) +``` + +Per-example flags (run with `--help`): + +- `02_movej` positional arguments: seven target joint angles. Omitted, it defaults to "current position plus a small step on J3". +- `07_vel_jitter_trace`: `--dist` `--dur` `--dir` `--tag` `--ff-mask` + +## Safety conventions + +- **Read-only by default.** Without `--go` an example only connects and queries; it sends no motion command at all. +- **Small steps you can back out of.** Examples that move default to a small step such as 1 cm / 0.1 rad. +- **They deliberately do not `disable()` when finished** (02/03/06/07). Disabling at those poses lets the arm fall back under its own weight, so staying enabled and holding position is the safe end state. Keep the emergency stop within reach. +- **Irreversible command.** `05_ff_tune` calls `save_params()`, which **writes flash**. Do not run it casually on a calibrated arm. + +## Migrating from the Python examples + +Correspondence: + +| Python | C++ | +| --- | --- | +| `arm.get_state().value.q` | `arm.get_state().value->q()` | +| `arm.movej([...], speed=0.3)` | `arm.movej({...}, 0.3)` | +| `arm.move_l(pose, speed=0.5)` | `arm.move_l(pose, 0.5)` | +| `arm.model.get_body(1)` | `arm.model().get_body(1)` | +| `with arm.zero_g():` | `{ auto zg = arm.zero_g(); ... }` | +| `for jp in arm.params.all_joint_params()` | `for (const auto& jp : arm.params().all_joint_params())` | + +`07_vel_jitter_trace` is the only example with a **deliberate omission**. The Python version also carries a "directional IK precheck plus an end-point soft-limit margin gate" and a four-leg A/B sequence (`--legs`). Those prechecks need a PC-side model, and this package's stated position is that it **does no PC-side kinematics** — the heavy computation all lives in the firmware — so the C++ version keeps only the "one translation plus dual-channel capture" trunk and leaves the choice of direction to the caller. diff --git a/examples/_common.cpp b/examples/_common.cpp new file mode 100644 index 0000000..bf268aa --- /dev/null +++ b/examples/_common.cpp @@ -0,0 +1,115 @@ +#include "_common.hpp" + +#include +#include +#include +#include + +namespace example { + +namespace { + +bool is_known_switch(const std::string& a) { + return a == "--port" || a == "--go" || a == "--speed" || a == "-h" || a == "--help"; +} + +} // namespace + +bool parse_args(int argc, char** argv, const std::string& desc, Args* out) { + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "-h" || a == "--help") { + std::cout << desc << "\n\n" + << "开关:\n" + << " --port PORT 串口 (默认自动找 1d50:606f, 也可用 " + "LITEARM_PORT)\n" + << " --go 真正 enable/运动/改参 (默认只读连接, 不上力)\n" + << " --speed S move 速度倍率 0~1 (默认 0.3)\n" + << " (其余 --x [值] 开关由各样例自己解释)\n"; + return false; + } + if (a == "--port" && i + 1 < argc) { + out->port = argv[++i]; + } else if (a == "--go") { + out->go = true; + } else if (a == "--speed" && i + 1 < argc) { + out->speed = std::atof(argv[++i]); + } else if (a.size() > 2 && a.rfind("--", 0) == 0) { + // 样例私有的开关: `--dist 0.1` 或裸 `--verbose` + const std::string key = a.substr(2); + std::string val = "1"; + if (i + 1 < argc) { + const std::string nxt = argv[i + 1]; + if (!nxt.empty() && nxt[0] != '-') val = argv[++i]; + } + out->extra[key] = val; + } else if (!a.empty() && a[0] == '-' && !is_known_switch(a)) { + std::cerr << "未知开关: " << a << " (用 --help 看用法)\n"; + std::exit(2); + } else { + out->rest.push_back(std::atof(a.c_str())); + } + } + return true; +} + +std::unique_ptr make_arm(const Args& args) { + using namespace litearm; + std::string port = args.port; + if (port.empty()) { + const char* env = std::getenv("LITEARM_PORT"); + if (env != nullptr && *env != '\0') port = env; + } + ArmOptions opts; + if (!port.empty()) opts.port = port; + auto arm = std::make_unique(opts); + arm->connect(); + return arm; +} + +int run_example(int argc, char** argv, const std::string& desc, + const std::function& body, + const std::function& on_close) { + Args args; + if (!parse_args(argc, argv, desc, &args)) return 0; + std::unique_ptr arm; + try { + arm = make_arm(args); + const int rc = body(*arm, args); + if (on_close) on_close(); + arm->close(); + return rc; + } catch (const litearm::MotionSupersededError& e) { + // 接管是**预期内**的, 与"规划失败"分开报 —— 混成故障会让调用方走错误恢复。 + std::printf("被接管 (预期内): %s\n", e.what()); + } catch (const litearm::LiteArmError& e) { + std::printf("出错: %s\n", e.what()); + } catch (const std::exception& e) { + std::printf("出错: %s\n", e.what()); + } + if (arm) { + if (on_close) on_close(); + try { + arm->close(); + } catch (...) { + } + } + return 1; +} + +std::string fmt(const std::vector& v, int prec) { + std::string out = "["; + char buf[64]; + for (size_t i = 0; i < v.size(); ++i) { + std::snprintf(buf, sizeof(buf), "%.*f", prec, v[i]); + if (i) out += ", "; + out += buf; + } + return out + "]"; +} + +std::string fmt(const std::array& v, int prec) { + return fmt(std::vector(v.begin(), v.end()), prec); +} + +} // namespace example diff --git a/examples/_common.hpp b/examples/_common.hpp new file mode 100644 index 0000000..33dcebc --- /dev/null +++ b/examples/_common.hpp @@ -0,0 +1,66 @@ +// _common —— 样例共用样板: 命令行开关 + 建 Arm + 统一错误外壳。 +// +// 约定 (安全): 默认**只读** —— 连接后仅能 status/tcp/ik 等查询。 +// 任何会 enable / 运动 / 改参 的样例都要显式加 `--go` 才执行, 防误动真机。 +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "litearm/litearm.hpp" + +namespace example { + +struct Args { + std::string port; // 空 = 自动发现 (VID:PID 1d50:606f) + bool go = false; // 真正 enable/运动/改参 (默认只读连接, 不上力) + double speed = 0.3; // move 速度倍率 0..1 + std::vector rest; // 位置参数 (如 movej 的 7 个目标角) + /// 本样例私有的开关 (`--dist 0.1` -> extra["dist"] = "0.1")。 + /// 通用解析不认识的 `--x` 都落这里, 免得每个样例各写一遍扫描。 + std::map extra; + + /// 取一个位置参数 (越界返回 fallback)。 + double num(size_t i, double fallback = 0.0) const { + return i < rest.size() ? rest[i] : fallback; + } + double num(const std::string& key, double fallback) const { + const auto it = extra.find(key); + return it == extra.end() ? fallback : std::atof(it->second.c_str()); + } + bool has(const std::string& key) const { return extra.count(key) != 0; } + std::string str(const std::string& key, const std::string& fallback) const { + const auto it = extra.find(key); + return it == extra.end() ? fallback : it->second; + } +}; + +/// 解析 argv。`desc` 用于 --help。遇到 --help 时打印并返回 false。 +bool parse_args(int argc, char** argv, const std::string& desc, Args* out); + +/// 连接并校验固件版本约定 (Litearm<主.次.修>-{7J|1J} >= 1.5.0)。 +/// +/// 端口优先级: --port > 环境变量 LITEARM_PORT > 自动发现 (1d50:606f)。 +/// ⚠ SDK 本身**不读任何环境变量** —— LITEARM_PORT 是脚本/样例这一层的事。 +std::unique_ptr make_arm(const Args& args); + +/// 样例的统一外壳: 解析 -> 连接 -> 跑 body -> 收尾 -> 统一报错。 +/// +/// 为什么要它: 每个样例各写一遍 try/catch 时, "**连不上**"这一步 (它发生在 body 之外) +/// 最容易漏 —— 漏了就是一句 `terminate called after throwing ...` 的崩溃, 而不是 +/// 一句能读的错误。所有对外入口都挂在这一个外壳上。 +/// +/// `body` 返回进程退出码 (0 = 成功)。`on_close` 在关闭**之前**跑 (打印收尾提示之类)。 +int run_example(int argc, char** argv, const std::string& desc, + const std::function& body, + const std::function& on_close = {}); + +/// 把一组数打印成 `[a, b, ...]`。 +std::string fmt(const std::vector& v, int prec = 3); +std::string fmt(const std::array& v, int prec = 3); + +} // namespace example diff --git a/include/litearm/ack.hpp b/include/litearm/ack.hpp new file mode 100644 index 0000000..4c0d5b5 --- /dev/null +++ b/include/litearm/ack.hpp @@ -0,0 +1,286 @@ +// 应答等待器 —— 唯一读者 + 按 (上行 id, 回显码) 分队列的投递。 +// +// 本设计的全部秘密: 帧的归属 = 它落在哪条队列。ACK{0x10} 与 ACK{0x11} 进两条队列, +// 因此永远配不错。读线程只投递、不判定; 判定由队列决定。 +// +// 三条纪律: +// 1. **只投递, 不判定** —— "这帧是谁的"由队列决定, 不由线程决定; +// 2. **死了要响亮** —— 死因进 reader_error 并唤醒所有等待者, 绝不静默退出; +// 3. **正常关闭绝不许写那一格** —— 否则"我们主动收尾"会被读成"设备没了"。 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "litearm/clock.hpp" +#include "litearm/protocol.hpp" +#include "litearm/state.hpp" +#include "litearm/transport.hpp" + +namespace litearm { + +class Arm; + +/// 一条帧该进哪条队列 —— (上行 id, 回显码或 kNoEcho)。 +/// +/// 只有 RSP_ACK / RSP_ERR 的 payload[0] 是"原命令码", 因此只有它们需要第二维来区分 +/// 同 id 的不同命令。别的帧的 payload[0] 是数据 (关节号 / item 索引 / 文本首字节), +/// 拿它当归属键是错的。 +using QueueKey = std::pair; + +inline constexpr int kNoEcho = -1; + +QueueKey read_key(uint8_t c, const std::vector& payload); + +/// 等一条 want 应答时该看哪几条队列。 +/// +/// `want` 是 RSP_ACK/RSP_ERR 时 `echo_cmd` 必填 (归属判据就是那个回显码, 不给就等于 +/// "随便哪条 ACK 都算我的" —— 那正是并发下两条命令互吃应答的成因)。这里直接拒绝而不是 +/// 退化成通配。 +std::vector wait_keys(uint8_t want, std::optional echo_cmd); + +/// expect() 的返回: 收到的是 ACK / ERR / 目标应答。 +struct ExpectResult { + enum class Kind { Ack, Err, Rsp }; + Kind kind = Kind::Ack; + std::vector payload; + + bool is_ack() const { return kind == Kind::Ack; } + bool is_err() const { return kind == Kind::Err; } + bool is_rsp() const { return kind == Kind::Rsp; } +}; + +/// 一条队列项 —— 载荷 + 它的**到达序**(单调递增, 全队列统一)。 +/// 到达序是多条 key 上的队列同时有货时挑"最早到达那条"的依据。 +struct QueueEntry { + uint8_t id = 0; + std::vector payload; + uint64_t seq = 0; +}; + +using Queue = std::deque; + +/// 每条应答队列的深度上限。真堆到这个数说明主人再也不来取了 (孤儿), 丢最旧的并计 +/// dropped —— 与全包对"丢"的一贯口径一致 (不静默)。 +inline constexpr size_t kQueueMax = 64; + +/// 读线程读到"没有帧"时的退避 (秒)。 +/// 不能依赖"传输会阻塞": 那是 SerialTransport 的实现性质, 不是传输接口的契约 —— +/// 测试桩就完全忽略 timeout。不退避的空循环会跑出接近 100% 的 CPU。 +inline constexpr double kIdleSleepS = 0.001; + +/// 读线程每一拍允许阻塞的时长 (秒)。 +inline constexpr double kReadSliceS = 0.1; + +class Ack { +public: + explicit Ack(Arm* arm); + ~Ack(); + + Ack(const Ack&) = delete; + Ack& operator=(const Ack&) = delete; + + // ---- 读线程侧 ---- + + /// 起读线程。唯一调用点是 Arm::connect (时序见那里: Ack 已就绪、而下面那句握手的 + /// expect 必须已经有人读)。 + void start_reader(Transport* tr); + + /// 停读线程。**必须排在 transport->close() 之前**。 + /// + /// 否则读线程会从已经关掉的传输上抛 TransportError => reader_error 非空 => + /// 一次正常关闭被记成"链路丢失",而 Arm::wait_until_link_lost 正是拿那一格当 + /// "设备没了"的判据。也必须 join: 线程的 target 是成员函数 => 线程引用本对象。 + void stop_reader(); + + // ---- 等待者侧 ---- + + /// 清掉 cmd 的应答队列 —— 由 Arm::raw_write 在发帧之前调。 + /// + /// 这是"陈旧帧冒充新应答 => 假成功"的唯一一道闸, 位置是载重的: + /// * 必须在写之前 —— 我们的应答只可能在写之后到达, 故清队不可能吃掉自己的应答; + /// 反过来 (先写后清) 会 100% 吃掉; + /// * 必须与写同一个线程、紧挨着 —— 清队与写之间只隔几微秒, 那几微秒里到达的陈旧帧 + /// 是残余风险 (知情的、不可再约: 线上没有请求 id)。 + void drain_for(uint8_t cmd); + + /// 在 keys 这几条队列上等最早到达的那条; 超时返回 nullopt。 + /// + /// 队列在发帧之前刚被 drain_for 清过 => 这里不需要水位: 队列里出现的东西一定是写 + /// 之后到达的。多条 key 都有货时取 seq 最小的那条 (ACK 与 ERR 可能同时在队, 而 + /// err_waits_for_ack 要求按到达序先看到 ERR)。 + /// + /// 守卫排在取队列之前 (一个都不能少 —— 漏哪条都是从"响亮地报错"静默退化成 + /// "等满超时"): 终态 -> fork -> 未连接 -> 负 timeout。 + std::optional wait(const std::vector& keys, double timeout); + + /// 等 want 那条应答 —— 从属于本命令的那条队列里等。 + /// + /// `err_waits_for_ack=true` 时,一条匹配 echo_cmd 的 ERR 不是终局: 记下它, 继续等 + /// ACK{echo_cmd} —— 找到 => 那条 ERR 是别人的, 本命令其实已被受理 (按正常应答返回, + /// 不抛); 窗口耗尽仍只有 ERR => 那条 ERR 就是本命令的, 抛它。 + /// + /// 判据是固件对"受理/拒绝"的应答形状不同: 受理必生成一条 RSP_ACK{cmd}, 拒绝只生成 + /// RSP_ERR{cmd,code} (绝不补 ACK)。这条形状只在"生成侧"成立, 到达侧不是: 受理那条 + /// ACK 自己也会丢 (固件应答 FIFO 满时丢最新)。那时窗口里只剩一条 ERR => 本方法判 + /// "被拒"而本命令其实已被受理。 + /// + /// 代价: 真被拒时要等满整个窗口才返回。所以只有笛卡尔三条入口开这个开关。 + ExpectResult expect(uint8_t want, double timeout, const std::string& cmdlabel, + bool raise_on_err = true, + std::optional echo_cmd = std::nullopt, + bool err_waits_for_ack = false); + + /// 本会话里 id c 那类帧的 (hz, 最近一帧的本地时间)。 + /// + /// hz = 自本会话首次收到该类帧起的平均到达频率 (count-1)/(last-first)。分母是实际 + /// 到达的间隔, 故它不随链路空闲而衰减。样本不足 2 条时 hz 写死 0.0。 + /// 本会话从没收到过该类帧时返回 (0.0, 0.0) —— 0.0 是哨兵。 + std::pair recv_stats(uint8_t c); + + // ---- 共享状态 (读线程写, 等待者读) ---- + // + // ⚠ 这些是设计的一部分而不是"暴露内部": Arm 的驱动型读者 (_pump_until / _arrive + // / poll 系列) 直接看状态单槽与条件变量, 那正是"广播帧不进队列"这条设计的必然结果 + // (100Hz 连续流进队列会在 0.6s 内撑满 kQueueMax 并制造恒定的淘汰噪声)。 + mutable std::mutex mu; + std::condition_variable cv; + + /// 持 `mu` 的 RAII 锁。 + /// + /// **Debug 构建下它多带一条"锁纪律"检查**: 登记当前持锁线程, 于是任何碰受保护状态的 + /// 地方都能断言"我确实在锁内" (`dbg_check`)。Release 构建退化成 `std::unique_lock`, + /// 零额外开销。 + /// + /// 为什么值得常年留着: `Ack::queues` / `state` / `recv_` 的"必须在 `mu` 之下"这条 + /// 不变量, 全靠**人眼审**那 6 处访问点; 一旦被破坏, 后果是**静默的数据竞争** —— + /// 最难查的一类缺陷 (TSan 之外的工具有时候也说不清, 见下)。有了它, 破坏这条不变量 + /// 会在**下一次调试构建里当场 abort**, 并指名是哪个站点。 + /// + /// ⚠ 它不是 TSan 的替代品, 而是"当 TSan 报了这一带、而人眼又看不出问题时, 用来 + /// **一锤定音**的那件工具"。用例见 README 的"TSan 现状"。 + struct MuLock { +#ifndef NDEBUG + std::unique_lock lk; + std::atomic* owner; + + MuLock(std::mutex& m, std::atomic& o) : lk(m), owner(&o) { + owner->store(std::this_thread::get_id()); + } + ~MuLock() { owner->store(std::thread::id{}); } +#else + std::unique_lock lk; + + MuLock(std::mutex& m, std::atomic&) : lk(m) {} +#endif + MuLock(const MuLock&) = delete; + MuLock& operator=(const MuLock&) = delete; + /// 给 `condition_variable::wait_for` 用 (它要的是 `unique_lock&`)。 + std::unique_lock& ul() { return lk; } + }; + + /// 持 `mu` 的线程 (仅 Debug 构建维护; Release 下 `dbg_check` 是空操作)。 + std::atomic dbg_mu_owner{}; + + /// 断言"当前线程持有 `mu`"。仅 Debug 构建生效 —— 见 `MuLock` 的注释。 + void dbg_check(const char* where) const { +#ifndef NDEBUG + if (dbg_mu_owner.load() != std::this_thread::get_id()) { + std::fprintf(stderr, + "\n[litearm] **锁纪律被破坏**: %s 在未持有 Ack::mu 的情况下访问了" + "受保护状态 —— 这是一处静默的数据竞争。\n", + where); + std::fflush(stderr); + std::abort(); + } +#else + (void)where; +#endif + } + + /// 状态单槽 + 它的到达计数。 + /// status_seq 是原子的: 等待者要在**不持锁**的判据里读它 ("这一拍和上一拍是不是 + /// 同一帧"), 而写方在锁内递增。 + std::optional state; + std::atomic status_seq{0}; + + /// 取状态单槽的副本 (持锁)。state 由读线程在锁内写, 直接读它是数据竞争。 + std::optional state_copy(); + uint64_t status_seq_now() const { return status_seq.load(); } + + /// 读线程的死因 (传输层异常)。非空 = 链路没了。 + /// 正常关闭绝不许写这一格 (顺序见 stop_reader)。 + std::exception_ptr reader_error; + /// 上面那一格的文本形式 (构造异常消息用, 免去在持锁时 rethrow)。 + std::string reader_error_text; + + /// 投递时协议层抛出的异常 (典型: 笛卡尔收集器的"多了一条")。 + /// 读线程不当场抛、也不死, 存这里由下一个等待者抛出, 抛一次就出队 + /// => 不会 50Hz 刷屏。 + std::deque errors; + + /// 因队列封顶被挤掉的帧累计条数 —— 只做可观测性, 不改变任何行为。 + /// 它合并了从前的 unexpected_frames 与 foreign_frames —— 两者含义本就相同: + /// 「没等到主人的帧」。 + uint64_t dropped = 0; + + /// **CRC 对但解码失败**的状态帧累计条数 (只做可观测性)。 + /// + /// ⚠ 没有它, "链路一直在收帧、但每一帧都解不出来"与"链路根本没数据"在**外面看 + /// 一模一样**(都是状态单槽不更新), 而这两种处境要查的方向完全相反。 + /// 有了它, `host_stats()` 就能把二者分开。 + uint64_t bad_status_frames = 0; + + /// 队列表 —— (上行 id, 回显码) -> 按到达序的队列。 + std::map queues; + +private: + void reader_loop(Transport* tr); + void die(std::exception_ptr e); + void deliver(uint8_t c, std::vector payload, double now); + void note_recv(uint8_t c, double now); + void on_status(const std::vector& payload); + + struct RecvEnt { + uint64_t count = 0; + double first = 0.0; + double last = 0.0; + }; + + Arm* arm_ = nullptr; + + /// 时间源 —— 从会话 (`Arm`) 要, **不自己缓存一份**。 + /// + /// ⚠ 三条理由, 每条都踩过: + /// ① `Arm` 是可移动的 ⇒ 缓存引用会在移动后悬空; + /// ② `arm_` **可能为空** (可以直接 `Ack a(nullptr)`), 故要空安全; + /// ③ 时钟是**每会话**注入的 (`ArmOptions::clock`) ⇒ 它必须跟着会话走, + /// 不能是 `Ack` 自己的成员 (那样注入的钟根本到不了这里)。 + const Clock& clk() const; + Transport* tr_ = nullptr; + /// 建立这条读线程的进程。非属主进程 (fork 出来的子进程) 里绝不可 join —— + /// 那条线程在子进程里**不存在**, 而 `std::thread::join()` 会永久阻塞、 + /// `~std::thread` 对未 join 的线程会 `std::terminate`。 + int owner_pid_ = -1; + std::thread reader_; + std::atomic stop_{false}; + /// 退避用的独立条件变量 —— 不能被 mu 串住: 退避时若持 mu 会挡住所有等待者。 + std::mutex stop_mu_; + std::condition_variable stop_cv_; + uint64_t next_seq_ = 0; + std::map recv_; +}; + +} // namespace litearm diff --git a/include/litearm/arm.hpp b/include/litearm/arm.hpp index f053e8a..c5de4f0 100644 --- a/include/litearm/arm.hpp +++ b/include/litearm/arm.hpp @@ -1,414 +1,762 @@ +// Arm —— 高层子集: 直连 STM32 固件, 轨迹/IK/动力学/控制律全由固件内置承担。 +// +// 设计定位: +// - 单发语义: movej/move_p 一次下发, 固件 S 曲线自完成 + 到位受控静止保持; +// - 不做 PC 侧轨迹/运动学; fk 仅当前位姿 (get_tcp), ik 走固件 get_ik; +// - 笛卡尔运动只有一条路: 固件规划 move_l/move_c/move_path; +// - 连续伺服 (move_js/send_mit) 需调用方按 >=10Hz 重发, 否则 0.1s 看门狗 fail-soft。 +// +// 固件版本约定: get_firmware 应回 'Litearm<主.次.修>-{7J|1J}'; +// <1.5.0 拒绝 (依赖 6+21N 状态帧 / joint_fault / enabled 位 + 静止保持语义)。 #pragma once -/** - * @file arm.hpp - * @brief Remote Arm client — API-compatible with pylitearm.Arm. - * - * Connects to litearm-server via zenoh and forwards all calls as RPC. - * No pylitearm dependency, no Pinocchio, no Eigen. - */ - -#include + +#include +#include +#include #include +#include #include #include -#include +#include #include -#include "litearm/device.hpp" +#include "litearm/ack.hpp" +#include "litearm/cart.hpp" +#include "litearm/clock.hpp" +#include "litearm/diagnostics.hpp" +#include "litearm/errors.hpp" +#include "litearm/log.hpp" +#include "litearm/model.hpp" +#include "litearm/msg.hpp" +#include "litearm/params.hpp" +#include "litearm/protocol.hpp" +#include "litearm/rot.hpp" +#include "litearm/state.hpp" #include "litearm/transport.hpp" -#include "litearm/types.hpp" namespace litearm { -/** - * LiteArm remote client. API mirrors pylitearm.Arm for seamless migration. - * - * Usage: - * @code - * auto arm = Arm::create("tcp/192.168.1.100:7447"); - * auto state = arm->get_state(); - * arm->movej({0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, 0.5); - * arm->close(); - * @endcode - */ -class Arm { -public: - /// Connect to a litearm-server daemon. - /// @param endpoint Zenoh endpoint (e.g. "tcp/127.0.0.1:7447"). - /// Defaults to ``LITEARM_ENDPOINT`` env var, then ``tcp/127.0.0.1:7447``. - /// @param arm_id Arm identifier (default "armA") - /// @param transport Optional pre-configured Transport (for testing) - explicit Arm(const std::string& endpoint = "", - const std::string& arm_id = "armA", - std::shared_ptr transport = nullptr); - - ~Arm(); - - // Non-copyable, movable - Arm(const Arm&) = delete; - Arm& operator=(const Arm&) = delete; - Arm(Arm&&) = default; - Arm& operator=(Arm&&) = default; - - // ── Pure computation API (no hardware needed on server) ───────────────── - - /// Forward kinematics: joint angles → (position, rotation_matrix). - std::pair, std::vector>> - fk(const std::vector& q); - - /// Inverse kinematics: (position, rotation) → (q, success). - std::pair, bool> - ik(const std::vector& pos_d, - const std::vector>& R_d, - const std::optional>& q_seed = std::nullopt); - - /// Plan a straight-line Cartesian path. Returns list of joint configs. - std::vector> - plan_movel(const std::vector& q_start, - const LiteArmValue& pose_goal); - - /// Plan a circular-arc Cartesian path through via-point. - std::vector> - plan_movec(const std::vector& q_start, - const LiteArmValue& pose_via, - const LiteArmValue& pose_goal); - - /// Plan a multi-waypoint Cartesian path. - std::vector> - plan_movep(const std::vector& q_start, - const LiteArmValue& poses_goal); - - // ── Motion execution ──────────────────────────────────────────────────── - - /// Move to joint target. - bool movej(const std::vector& q_target, - double speed = 1.0, - double settle_s = 1.0, - std::optional max_cycles = std::nullopt, - bool allow_start_collision_recovery = false); - - /// Slowly return every out-of-limit joint to the nearest safe boundary. - /// Requires server connected with allow_limit_recovery=True. - bool recover_joint_limits(double speed = 0.05, - double settle_s = 0.5, - std::optional max_cycles = std::nullopt, - double inset_rad = 0.0); - - /// Home all joints to zero position ([0, 0, 0, 0, 0, 0, 0]). - /// Unlike movej, home() bypasses joint-limit and self-collision path - /// safety checks. Hardware-level fault / temperature / overspeed / - /// following-error protection remains active. - /// Default speed is 0.3 (slow) — homing is a recovery operation. - bool home(double speed = 0.3, - double settle_s = 0.5, - std::optional max_cycles = std::nullopt); - - /// Move in a straight Cartesian line. - bool movel(const LiteArmValue& pose_goal, - double speed = 1.0, - double settle_s = 0.8, - std::optional max_cycles = std::nullopt); - - /// Move in a circular arc through via-point. - bool movec(const LiteArmValue& pose_via, - const LiteArmValue& pose_goal, - double speed = 1.0, - double settle_s = 0.8, - std::optional max_cycles = std::nullopt); - - /// Move through a sequence of Cartesian waypoints with corner blending. - bool movep(const LiteArmValue& poses_goal, - double speed = 1.0, - double settle_s = 0.8, - std::optional max_cycles = std::nullopt); - - /// Replay a sequence of joint configurations. - bool replay_joint_path(const std::vector>& q_path, - double speed = 1.0, - double settle_s = 0.5, - bool goto_start = true, - double goto_speed = 0.3, - std::optional max_cycles = std::nullopt); - - /// Replay a JointTrajectory or path. - /// check_singularity: treat singular Jacobian as a hard error (server-side default). - bool replay_trajectory(const JointTrajectory& traj, - double speed = 1.0, - bool goto_start = true, - double goto_speed = 0.3, - std::optional max_cycles = std::nullopt, - bool check_singularity = true); - - /// Replay from raw value (dict-like). - bool replay_trajectory(const LiteArmValue& traj_q, - double speed = 1.0, - bool goto_start = true, - double goto_speed = 0.3, - std::optional max_cycles = std::nullopt, - bool check_singularity = true); - - /// Replay a measured trajectory on its recorded time axis. - /// Safety-enforced: automatically stretches time to respect vel/acc/jerk limits. - bool replay_timed_trajectory( - const std::vector>& traj_q, - const std::vector& traj_t, - double speed = 1.0, - bool goto_start = true, - double goto_speed = 0.3, - double simplify_tolerance_rad = 0.01, - std::optional max_cycles = std::nullopt); - - /// Load and replay a saved JointTrajectory (serialized for transport). - bool play_trajectory(const JointTrajectory& trajectory, - double speed = 1.0, - bool goto_start = true, - double goto_speed = 0.3, - bool verify_robot = true, - double simplify_tolerance_rad = 0.01, - std::optional max_cycles = std::nullopt); - - /// Load and replay a saved trajectory by server-side path (e.g. "trajectories/traj_001.json"). - bool play_trajectory(const std::string& trajectory, - double speed = 1.0, - bool goto_start = true, - double goto_speed = 0.3, - bool verify_robot = true, - double simplify_tolerance_rad = 0.01, - std::optional max_cycles = std::nullopt); - - /// Record a trajectory by dragging the arm (zero_gravity mode). - JointTrajectory record_trajectory( - const std::string& output = "trajectories", - std::optional duration_s = std::nullopt, - double sample_rate_hz = 100.0, - double filter_alpha = 0.15, - std::optional name = std::nullopt); - - /// Hold current position with increased stiffness. - bool hold(double kp_scale = 3.0, - std::optional max_cycles = std::nullopt); - - /// Enable zero-gravity (free-drag) mode. - /// measured_overspeed_factor / vel_max kept for server compatibility - /// (deprecated, no effect in pylitearm). - bool zero_gravity(std::optional max_cycles = std::nullopt, - std::optional duration_s = std::nullopt, - std::optional measured_overspeed_factor = std::nullopt, - std::optional> vel_max = std::nullopt); - - /// Joint-space impedance control. - bool joint_impedance(const std::vector& q_des, - const LiteArmValue& K, - const LiteArmValue& B, - std::optional tau_max = std::nullopt, - double engage_sec = 0.3, - std::optional max_cycles = std::nullopt); - - /// Cartesian-space impedance control. - bool cartesian_impedance(const std::vector& q_des, - const LiteArmValue& K_cart, - const LiteArmValue& B_cart, - std::optional v_des = std::nullopt, - std::optional tau_max = std::nullopt, - double engage_sec = 0.3, - std::optional max_cycles = std::nullopt, - std::optional sigma_min_thresh = std::nullopt, - std::optional max_ori_err = std::nullopt, - std::optional measured_overspeed_factor = std::nullopt, - std::optional> vel_max = std::nullopt); - - /// Follow an external target provider. - bool joint_follow(std::optional K = std::nullopt, - std::optional B = std::nullopt, - std::optional speed_limit = std::nullopt, - std::optional accel_limit = std::nullopt, - double engage_sec = 0.3, - std::optional max_cycles = std::nullopt, - std::optional duration_s = std::nullopt); - - // ── State reading ─────────────────────────────────────────────────────── - - /// Get latest robot state from broadcast cache (no RPC call). - /// Returns nullptr if no state received yet. - std::optional get_state(bool refresh = false); - - /// Get current TCP pose as (position, rotation_matrix). - std::pair, std::vector>> - get_tcp_pose(); - - // ── Emergency stop ────────────────────────────────────────────────────── - - /// Send high-priority emergency stop signal. - void request_stop(); - - /// Clear the stop condition and return to ready state. - void clear_stop(); - - // ── Parameter tuning ──────────────────────────────────────────────────── - - /// Set PD controller gains. - LiteArmValue set_gains(std::optional kp = std::nullopt, - std::optional kd = std::nullopt); - - /// Get current PD controller gains. - LiteArmValue get_gains(); - - /// Clear motor faults. Returns list of cleared (motor_id, fault_code). - LiteArmValue clear_faults(); - - /// Direct joint motor control via pure MIT five-parameter per-frame publish. - /// Async pub to the command topic — no RPC round-trip. - void send_mit(const std::vector& kp, const std::vector& kd, - const std::vector& q_ref, const std::vector& dq_ref, - const std::vector& tau_ff); - - /// Configure global safety guards (RPC). nullopt leaves the guard unchanged. - LiteArmValue set_guards( - std::optional slew_limit = std::nullopt, - std::optional tau_max = std::nullopt, - std::optional watchdog_timeout = std::nullopt, - std::optional position_bounds = std::nullopt, - std::optional velocity_bounds = std::nullopt, - std::optional jerk_limit = std::nullopt); - - /// Read current guard configuration (RPC). - LiteArmValue get_guards(); - - /// Enable all motors and hold current pose (re-enable after disable()). - void enable(); - - /// Disable all motors (arm will drop under gravity!). CAN stays connected. - void disable(); - - /// Set end-effector payload (mass + center of mass). - LiteArmValue set_payload(double mass, - const std::vector& com = {0.0, 0.0, 0.0}); - - /// Get current payload configuration. - LiteArmValue get_payload(); - - /// Set installation orientation (base RPY or gravity vector). - LiteArmValue set_installation( - std::optional> base_rpy = std::nullopt, - std::optional> gravity = std::nullopt); - - /// Get current installation configuration. - LiteArmValue get_installation(); - - /// Set per-joint gravity calibration scale (7 non-negative values, - /// multiplicative on the CAD gravity torque). Default transition_s=2.0s gives - /// a smooth linear ramp so the compensation torque never jumps; transition_s<=0 - /// applies instantly (offline/static only). - LiteArmValue set_gravity_scale(std::vector scale, - double transition_s = 2.0); - - /// Get current per-joint gravity calibration scale. During a transition the - /// returned "scale" is the mid-transition value and "target" is set. - LiteArmValue get_gravity_scale(); - - /// Persist current gravity scale to the server yaml (effective after restart). - LiteArmValue save_gravity_scale(); - - /// Persist current payload (mass + com) to the server yaml (effective after restart). - LiteArmValue save_payload(); - - /// Persist current installation orientation (base_rpy) to the server yaml. - LiteArmValue save_installation(); - - // ── Peripheral devices ─────────────────────────────────────────────────── - - /// Get a remote device interface (hand/gripper/teach), e.g. "hand_0". - /// Methods are proxied through "device.{device_id}.{method}" RPCs. - RemoteDevice device(const std::string& device_id); - - /// Access the lazy device manager (devices()["hand_0"] syntax). - DeviceManager& devices(); - - // ── Server extension RPCs (system / settings / trajectory / device / teleop) ── - - /// Get system stats (CPU, memory, board temperature, uptime). - LiteArmValue get_system_stats(); - - /// Get server logs (paginated). - LiteArmValue get_logs(int page = 1, int size = 50, - const std::string& search = ""); - - /// Request restart of the arm service. - LiteArmValue restart_service(); - - /// Reconnect hardware from any state — re-initialize motors after arm hot-restart. - /// Returns { "state": str, "success": bool, "error": str (optional) }. - LiteArmValue reconnect(); - - LiteArmValue get_joint_limits(); - LiteArmValue set_joint_limits(const LiteArmValue& limits); - - LiteArmValue get_zero_offsets(); - LiteArmValue set_zero_offsets(const LiteArmValue& offsets); +/// 后端要求的最低固件 (move_j 受控静止保持语义 + 6+21N 状态帧/joint_fault/enabled 位)。 +inline const proto::FirmwareVersion MIN_FW{1, 5, 0, ""}; +inline constexpr const char* FIRMWARE_PREFIX = "Litearm"; + +/// 零重力保活周期。固件 watchdog_timeout_s = 0.10s, 且 0x06 自带 watchdog_kick +/// (「重发即保活」)—— 取 0.04s 留 2.5 倍余量。 +inline constexpr double ZG_KEEPALIVE_S = 0.04; +/// 等保活线程停下来的上限 (超过说明串口写阻塞)。 +inline constexpr double ZG_JOIN_S = 1.0; + +/// 状态帧**超龄**阈值 —— 超过这么久没收到新状态帧, `is_connected()` 就判"链路不健康"。 +/// +/// ⚠ 取值要比固件的广播周期 (100Hz ⇒ 10ms) **大两个数量级**: 这个判据要能容忍 +/// 一次 USB 抖动/丢帧, 只在"对方**持续**不发"时才翻转。取 2.0s 与 A 侧一致。 +/// ⚠ 它与"读线程死了"是**两条独立的信号**: 后者立刻判死, 前者管"线程活着但没人喂它"。 +inline constexpr double STATUS_STALE_MAX_S = 2.0; + +/// `Arm::enter_dfu` 等 RSP_ACK{0x15} 的窗口。 +inline constexpr double DFU_ACK_TIMEOUT_S = 1.2; +/// `Arm::enter_dfu` 观察"设备真的消失"的窗口 —— 取 0.3s 量级。 +/// 固件侧从登记到交棒的上界是 100ms (三道门控共用同一个 age), 本窗口留的是 USB 重枚举 +/// 的余量, 不是"等固件那个上界"。 +inline constexpr double DFU_VANISH_TIMEOUT_S = 0.3; + +/// "零重力保活期间拒绝其它下行命令"这条守卫的唯一文案 —— 两个抛出点共用。 +/// +/// 为什么必须是同一个常量: 两处的判据必须一致 —— 从前是各抄一份逐字节相同的字符串, +/// 改一侧就静默漂移 (调用方按文案归因, 两句不一样会被读成两种不同的拒绝)。 +/// 文案本身仍是用户可见的归因入口: 它要说清"为什么拒"与"怎么退出"。 +extern const char* const ZERO_G_GUARD_MESSAGE; + +/// "笛卡尔在途时拒绝进入零重力"这条反向守卫的文案。 +/// +/// 只拒绝而不给出路会把操作员卡住 —— 文案必须把更好的停机动作写在里面: +/// movej() 是受控接管 (新 S 曲线从当前状态收口), 比 0x06 的"丢位置环、靠摩擦滑停"好得多。 +extern const char* const CART_IN_FLIGHT_GUARD_MESSAGE; +/// 上面那条的"没能确认是否在途"变体 —— 保守拒绝时必须说清是哪一种拒绝。 +extern const char* const CART_IN_FLIGHT_UNCONFIRMED_MESSAGE; +/// enter_dfu 的本地使能态预检文案。 +extern const char* const DFU_ENABLED_MESSAGE; +/// enter_dfu 的"登记被撤销/未执行"文案 —— 收到 ACK{0x15} 之后设备在等待窗口内没有从 +/// CDC 上消失。这条异常是那条静默撤销路径的唯一出口: 说清"什么都没发生", 并让 Arm 保持 +/// 可用。 +extern const char* const DFU_REVOKED_MESSAGE; + +/// 设备授权记录 —— CMD_GET_LICENSE(0x2F) 的应答 (RSP_LICENSE 0x4F, 26B)。 +/// +/// 未激活时也回 UID, 而 cust_id/issued/flags 全 0。签发工具必须从本记录取 uid +/// (uid_hex 就是它要的形态) —— 不得改用 USB 序列号字符串, 两者不是一个东西。 +/// +/// 本包不解释 uid 那 12 字节的语义 (固件是 HAL_GetUIDw0/1/2 各 4B 直接 memcpy, 原始 +/// 寄存器内存序), 只把设备给的原样转交 —— 签发器读的也是同一份字节, 故二者天然自洽。 +struct LicenseInfo { + int state = 0; //: 0=未激活 / 1=已激活 / 2=已激活且产线模式 + int ver = 0; //: 记录版本 (LICENSE_REC_VER, 当前 1) + std::array uid{}; //: 12B, 本机 UID (原始寄存器内存序) + uint32_t cust_id = 0; //: 客户号 (未激活恒 0) + uint32_t issued = 0; //: 签发日 YYYYMMDD (未激活恒 0) + uint32_t flags = 0; //: bit0 = 产线码 (未激活恒 0) + + /// 是否已激活 —— 判据只是 state != 0 (未激活时其余字段无意义)。 + bool activated() const { return state != 0; } + /// 产线码 (flags bit0) —— 不表示"激活与否", 故别拿它替代 activated()。 + bool factory_mode() const { return (flags & 0x1u) != 0; } + /// state 的可读名; 认不出的码带上原值回 (不静默)。 + std::string state_name() const; + /// UID 的 24 位小写 hex —— 签发器要的就是这个形态。 + std::string uid_hex() const; + + static LicenseInfo decode(const std::vector& payload); +}; - LiteArmValue get_end_effector(); - LiteArmValue set_end_effector(const LiteArmValue& config); +/// RSP_LICENSE 的 state 字节 -> 可读名。 +const char* license_state_name(int state); + +/// 构造参数 —— 对应 Python 侧 Arm(...) 的关键字参数。 +struct ArmOptions { + /// 串口路径。不设则自动发现 (VID:PID 1d50:606f)。 + std::optional port; + /// 传输工厂 (缺省真串口)。给它是为了接假传输做离线运行 + /// (litearm::testing::FakeTransport) —— 不需要真硬件就能起一个完整会话。 + /// 注入了工厂就必须同时给一个占位 port (如 "fake"), 否则无硬件时会在走到本工厂之前 + /// 就抛 "未找到 STM32 CDC"。 + /// + /// ⚠ **所有权**: 工厂返回的 `unique_ptr` 由 `Arm` 接管 ⇒ 它**活不过 `close()`** + /// (`close()` 会把传输 reset 掉)。调用方若在 `close()` 之后还握着指向那个传输的 + /// 裸指针/引用, 之后再解引用就是 UB (实测踩过: 包装传输的审计表在 close 后读, + /// 打印出一条 `len=1.5e19` 的垃圾随即段错误)。要在 close 之后还用, 自己拿 + /// `shared_ptr` 兜住。 + std::function(const std::string& port)> transport_factory; + /// 时间源。空 = `SteadyClock` (生产默认)。 + /// + /// ⚠⚠ 注入假钟之前**务必先读 `clock.hpp` 开头那段**: 本仓的等待走条件变量的**真实** + /// 时间, 只有 deadline/时间戳走注入的钟 ⇒ 一个不会自己前进的假钟会让"等超时真的 + /// 发生"的判据**挂死**。假钟只给"**纯比较**"类判据用。 + std::shared_ptr clock; + proto::FirmwareVersion min_firmware = MIN_FW; + double q_tol = 0.03; + double dq_tol = 0.10; + int arrive_frames = 3; + double move_timeout = 15.0; +}; - LiteArmValue get_cartesian_limits(); - LiteArmValue set_cartesian_limits(const LiteArmValue& limits); +/// 宿主机侧计数快照 (`Arm::host_stats()`) —— 纯本地, 不发帧、终态下也返回。 +/// +/// ⚠ 逐字段的**判读方向**都写在各自的注释里, 别只看数字。这几个计数合起来的用处只有 +/// 一个: 把**几种在调用方看来一模一样**的"静默"分开 —— +/// "对方没发" / "发了但解不出" / "解出了但没人认领" / "认领了但队列爆了"。 +struct HostStats { + /// 队列封顶被挤掉的帧 (丢最旧) —— 「帧到了, 但没有等待者, 且积压超过上限」。 + uint64_t dropped = 0; + /// **CRC 对但解码失败**的状态帧 —— 「链路活着, 但双方对帧格式的理解已经不一致」。 + /// ⚠ 它与 `dropped` 的区别是承重的: 前者是"读得懂但没人要", 后者是"根本读不懂"。 + uint64_t bad_status_frames = 0; + /// 传输层 flush 失败累计 —— 「写下去了, 但没保证出得去」(只计数、不抛)。 + uint64_t flush_failures = 0; + /// 笛卡尔在途配对表封顶挤出的条数。 + uint64_t cart_evicted_unclaimed = 0; + /// 笛卡尔回帧比请求多的条数 (配对已错位的信号)。 + uint64_t cart_extra_replies = 0; + /// 笛卡尔能力探测"3 次全安静 ⇒ fail-closed 判不支持"**是否发生过**。 + /// + /// ⚠ 本仓把它建成**布尔**而不是计数 (A 侧是计数): 探测每个会话只跑一次 + /// (`connect()` 里), 计数的第二个 1 永远不会有。语义见 `cart_supported()`。 + bool cart_probe_silent = false; +}; - LiteArmValue get_collision_config(); - LiteArmValue set_collision_config(const LiteArmValue& config); +class Arm; +class _AckFriend; +//: 测试专用的访问口 —— 定义在 tests/test_support.hpp (必须是 litearm 命名空间里的名字, +//: `friend` 才认得它)。见 `Arm` 私有段里那条 friend 的说明。 +struct TestAccess; + +/// 零重力会话 —— `Arm::zero_g()` 的返回值: 既已启动保活, 又可用作 RAII 守卫。 +/// +/// { +/// auto zg = arm.zero_g(); // 进入 + 保活 +/// ... // 可 get_state() 读状态, 不可发其它动作命令 +/// } // 退出块时自动收尾 (不抛) +/// +/// 或显式配对: +/// arm.zero_g_start(); +/// ... +/// arm.zero_g_stop(); // 抛出版本 +/// +/// ⚠ C++ 的析构函数不能抛, 故析构走的是 zero_g_stop(false) 那一档 (吞掉保活中断与退出帧 +/// 失败)。要拿到那些异常请显式调 stop()。 +class ZeroGSession { +public: + explicit ZeroGSession(Arm* arm = nullptr) : arm_(arm) {} + ZeroGSession(const ZeroGSession&) = delete; + ZeroGSession& operator=(const ZeroGSession&) = delete; + ZeroGSession(ZeroGSession&& o) noexcept : arm_(o.arm_) { o.arm_ = nullptr; } + ZeroGSession& operator=(ZeroGSession&& o) noexcept { + if (this != &o) { + reset(); + arm_ = o.arm_; + o.arm_ = nullptr; + } + return *this; + } + ~ZeroGSession() { reset(); } + + /// 显式停止 (抛出版本, 语义同 Arm::zero_g_stop(true))。 + void stop(); + /// 提前收尾, 等价于析构 (不抛)。 + void reset(); + bool active() const; + Arm* arm() const { return arm_; } - /// Server-side recording / trajectory CRUD. - LiteArmValue start_recording(); - LiteArmValue stop_recording(); - LiteArmValue discard_recording(); - LiteArmValue get_recording_state(); - LiteArmValue get_playback_state(); - LiteArmValue list_trajectories(); - LiteArmValue save_trajectory(const std::string& id, - const std::string& name, - const std::vector>& points, - std::optional duration = std::nullopt); - LiteArmValue delete_trajectory(const std::string& id); +private: + Arm* arm_ = nullptr; +}; - /// On-demand device daemon management (server forks device_daemon). - LiteArmValue list_device_types(); - LiteArmValue connect_device(const std::string& category, - const std::string& subtype, - const std::string& device_id = "end_0", - const std::string& can_iface = "", - const LiteArmValue& config = LiteArmValue(nullptr)); - LiteArmValue disconnect_device(const std::string& device_id = "end_0"); - LiteArmValue get_active_device(const std::string& device_id = "end_0"); +class Arm { +public: + Arm(); + explicit Arm(const std::string& port); + explicit Arm(const ArmOptions& opts); + ~Arm(); - /// Master/slave teleoperation (shares state with CLI --teleop-mode). - LiteArmValue enter_teleop( - const std::string& mode, - const std::map& params = {}); - LiteArmValue exit_teleop(); - LiteArmValue get_teleop_status(); - - // ── Lifecycle ─────────────────────────────────────────────────────────── + Arm(const Arm&) = delete; + Arm& operator=(const Arm&) = delete; - /// Close the transport connection. + // ---------- 连接 ---------- + + /// 连上 CDC 并握手 (幂等)。 + /// + /// 已经连着同一个目标时是 no-op (返回 *this, 不关链路、不重建会话) —— 重复调用最不该 + /// 打断的正是正在跑的那个会话。幂等不是"永远不做事": 显式给了另一个端口时真的改靶。 + /// 想强制重建同一个目标用 reconnect()。 + Arm& connect(); + Arm& connect(const std::string& port); + /// 强制重建会话 —— connect() 已连即 no-op, 本方法无条件重建。 + Arm& reconnect(); + Arm& reconnect(const std::string& port); + + /// 收尾 (幂等) —— 收保活线程 -> 停读线程 -> 关链路 -> 清会话状态。 + /// 这是全部收尾路径的唯一实现 (disconnect 与析构都委托到这里)。 void close(); - - /// Arm identifier. - const std::string& arm_id() const { return arm_id_; } + /// close() 的别名 —— 同一个操作的两个名字, 判据也完全一样。 + void disconnect() { close(); } + + // ---------- 状态读取 ---------- + + /// 当前状态 (固件 100Hz 被动状态流的最近一帧)。 + /// refresh=false 且本会话已有缓存时不取帧, 直接回缓存。取不到帧时 value 为空。 + Msg> get_state(bool refresh = false, double timeout = 0.5); + + /// 主动发 GET_STATUS 0x40 取一帧状态。 + /// 与 get_state() 的区别: 后者只消费被动流, 链路静默时只能靠超时发现。 + /// timeout <= 0 时语义是"立刻返回当前缓存"。 + Msg get_status_now(double timeout = 0.5); + + /// 当前末端位姿 pos[3]+rpy[3] (固件当前反馈 FK)。取不到时 value 为空。 + Msg>> get_tcp(double timeout = 0.6); + + // ---------- 关节运动 ---------- + + /// 关节运动 (CMD_MOVE_J 0x01): 一次下发, 固件 S 曲线自完成, 本方法等到位。 + RobotState movej(const std::vector& q, double speed = 1.0); + + /// 同步 PTP movej (0x07): 全关节同时到达, 末端沿关节空间直线 q(s) = q0 + s*dq。 + /// + /// 与 movej 的唯一差别是轨迹形状: movej 每轴一条独立 S 曲线, 各轴先到先停, 末端中间 + /// 轨迹不可预测; movej_sync 用一条路径标量 s: 0->1 的 S 曲线同时驱动全轴, 故所有关节 + /// 同一拍到达, 且末端落在关节空间线段上 (可预测、可校验)。 + /// 代价: 同步被最慢的轴拖住, 整体比 movej 慢 —— 这是可选模式而非替换。 + /// 固件状态帧仍报 mode=MOVE_J, 无法从状态区分同步/异步。 + RobotState movej_sync(const std::vector& q, double speed = 1.0); + + /// 点到点运动 (CMD_MOVE_P 0x02): 固件后台自己 IK + 走 S 曲线, 本方法轮询 get_tcp + /// 判到位, 返回 RobotState。 + /// + /// **这是关节空间插值, 不是直线** —— 实测 30 mm 的目标, 末端横向摆出 17.6 mm。 + /// 要末端走直线/圆弧/多路点见 move_l / move_c / move_path。 + /// 本方法只收单个位姿; 多个位姿请用 move_path (语义不同)。 + RobotState move_p(const rot::PoseInput& pose, double speed = 1.0, + double pos_tol = 0.006, double rpy_tol = 0.03); + + /// 固件 CMD_HOME 0x2A: 各轴回 URDF 零位舒展姿。 + /// + /// 固件侧速度写死 0.10 (低安全速度), 故本方法不接受 speed。与 movej({0,...}) 的差异: + /// 固件注释明确该命令「允许从当前越软限/贴端发起」。仍受 tau 上限 / 看门狗 / 可急停 + /// 约束, 须先 enable()。 + RobotState home(double timeout = -1.0); + + /// 单发 move_js。连续伺服需调用方按 >=10Hz 重发, 否则 0.1s 看门狗 fail-soft。 + void move_js(const std::vector& q, const std::vector& dq = {}, + const std::vector& tau_ff = {}); + /// 单关节 MIT 透传 (0x04)。⚠ 绕过规划; 须调用方维持 >=10Hz。 + void send_mit(int idx, double q, double dq, double kp, double kd, double tau); + /// 全臂单帧透传 (0x05)。⚠ 同上。 + void send_mit_all(const std::vector& q, const std::vector& dq, + const std::vector& kp, const std::vector& kd, + const std::vector& tau); + + // ---------- 客户端预检 (⚠ 与 Python 参照**分叉**, 按移植纪律登记) ---------- + // + // 下面两条是**发帧之前**的本地拒发。它们**不是**参照实现的行为: Python 直接把参数打包 + // 发走、由固件的 `clampf` 静默钳制。C++ 侧改成在本地拦下的理由**只有一条**: + // 参数错到"比较全为假"时, 静默放行的后果是**臂真的动了**(或判据判错), 而调用方 + // 完全无从察觉。见 `src/arm.cpp` 里各自的实现注释。 + // + // ⚠ 与 Python 分叉 ⇒ **不进**跨语言对拍(见 README「与原版的已知差异」)。 + // + // ⚠ 公开是**刻意**的(与 A 侧一致): 它们是纯函数、无副作用, 调用方可以拿它做**预演** + // ("这一组目标发出去会不会被本地拒"), 而不必真的发一条命令。 + + /// 关节目标预检: 非有限值 / 越软限 ⇒ `InvalidCommandError`。**发帧前**调用。 + /// + /// ⚠ 未连接或软限缓存为空时**不拦**(fail-open) —— 只有"非有限值"那一条无条件生效。 + void precheck_q_(const std::vector& q, const char* who) const; + /// 速度预检: `speed` 必须 ∈ [0, 1]。**发帧前**调用。 + void precheck_speed_(double speed, const char* who) const; + + // ---------- 生活/安全 ---------- + + /// 使能全关节 (CMD_ENABLE 0x10), 必要时重试。 + /// + /// **只有固件明说"可重试"的码才重试** —— 判据是白名单 (= {0x03}), 不是"除锁存外都 + /// 重试"。每次重试之间 sleep(0.3), 12 次尝试只有 11 次 sleep 约 3.3 秒。 + /// 对锁存故障 (EMERGENCY / 单关节故障) 那 3.3 秒是纯浪费 —— 固件在第一次答复里就已经 + /// 说清了"须先 RESET", 而重试不可能让它变绿。 + void enable(int attempts = 12); + /// 失能 —— 绕过零重力守卫 (降能量方向)。 + /// ⚠ 一旦失能, 臂不再被位置环抱住, 有负载会下坠。 + void disable(); + /// 急停 —— 绕过零重力守卫 (降能量方向的安全动作必须永远可达)。 + void emergency_stop(); + /// 清故障 + 重新锚定 (不是 MCU 重启)。 + void reset(); + /// 只清 RAM 里的故障位。 + void clear_faults(); + + /// 发 SET_MOTION_MODE 0x20。 + /// + /// **本固件只识别 mode=0**: 固件实现就是一行 park_requested = (mode == 0) —— mode + /// 不写进 g_arm.mode, 其它值只是"清除 park 声明", 回 ACK 而模式不变。 + /// 非 0 值响亮失败 (从前只发一条告警就照发命令, 调用方拿到的是"成功"而臂还在旧模式)。 + void set_motion_mode(int mode); + /// SET_MOTION_MODE J=0: 声明 park, 静止保持用全刚度。 + void park(); + + /// 设置全局调速器百分比 0..100 (固件 0x21)。 + /// + /// 这是整数百分比, 且固件侧是全局且持续的 (gov_ratio = percent/100), 与 + /// movej(speed=0..1) 的单条轨迹倍率不是一回事: set_speed(1) 是 1% 速度 (全局生效), + /// 按 0..1 思维调用会得到"臂爬行"。 + void set_speed(int percent); + + // ---------- 零重力 (拖动示教) ---------- + + /// 进入零重力拖动示教, 并启动保活。返回 RAII 会话对象。 + /// + /// 固件 CMD_ZERO_G 自带 watchdog_kick (「重发即保活」), 且 SET_MOTION_MODE / + /// SET_SPEED_PERCENT 都刻意不 kick —— 固件里没有别的命令能维持一个非 MOVE_J 模式不被 + /// 0.10s 看门狗掐死。故保活必须由后台线程按 period (默认 0.04s) 周期重发; 只发一次会在 + /// 0.1s 后掉回 fail-soft 持位, 而调用方只看到 ACK (静默失败)。 + /// + /// ⚠ 退出前先把关节推回软限位内: 零重力模式会跳过位置越限锁存 (否则拖动到限位附近会 + /// 永久锁死), 但一旦退出, 越限锁存立刻生效且重启会重锁。 + /// ⚠ 笛卡尔在途时会被拒绝 (InvalidCommandError) —— 中途进场会丢掉位置环、靠摩擦滑停。 + ZeroGSession zero_g(double period = ZG_KEEPALIVE_S); + + /// 进入零重力并启动保活线程 (幂等: 已激活时直接返回)。 + void zero_g_start(double period = ZG_KEEPALIVE_S); + /// 停保活并退出零重力 (幂等)。退出的 on=0 固件侧也幂等。 + /// raise_on_lost=true 时, 若保活曾因写失败中断, 在此抛出原异常 —— 臂已脱离零重力, 不能静默。 + void zero_g_stop(bool raise_on_lost = false); + /// 保活是否仍在维持 (保活期写失败会立刻变 false)。 + bool zero_g_active() const; + /// 保活中断的原因 (无则空)。zero_g_stop() 会消费并清空它。 + std::exception_ptr zero_g_error(); + /// 保活中断原因的文本 (无则空串)。 + std::string zero_g_error_text(); + + // ---------- FF / 动力学调参 ---------- + + /// 写 ff_mask (固件 0x27)。 + /// + /// 只接受 FF_ALL 范围内的位。固件侧会做 mask & FF_ALL_MASK, 于是 0x1000 这类误用被 + /// 静默折成 0 = 前馈全关 (重力补偿被关掉, 臂会垂下来), -1 折成全开。宁可在这里报错, + /// 也不让一个危险值悄悄变成另一个"合法"值。 + void set_ff_mask(uint32_t mask); + /// 前馈预设: 0 全关 / 1 出厂 / 2 全开。 + void ff_preset(int preset); + + /// set_ff_vec 的 item -> 含义 (与固件 params_ff_vec 的守卫/case 一一对应)。 + static const std::vector>& ff_vec_items(); + /// set_ff_scalar 的 item -> 含义 (item 9 保留: 写口拒绝, 只给 0x2C 读 ff_mask)。 + static const std::vector>& ff_scalar_items(); + /// 0x2C 只读 item (写口不接受的)。 + static const std::vector>& ff_scalar_ro_items(); + + /// 写 0x26 向量。item 见 ff_vec_items() (1..15), values 恒 7 个。 + void set_ff_vec(int item, const std::vector& values); + /// 写 0x28 标量。item 见 ff_scalar_items(); sub 仅 item 5/6 有意义 (0..2)。 + void set_ff_scalar(int item, int sub, double value); + + /// 读回 0x26 向量 (7 值)。与 set_ff_vec 同 item 编号。 + Msg> get_ff_vec(int item, double timeout = 1.0); + /// 读回 0x28 标量。item 9 为只读扩展 (ff_mask)。 + Msg get_ff_scalar(int item, int sub = 0, double timeout = 1.0); + /// 读回 ff_mask (走 0x2C item 9; 值 <=0x1FF, f32 精确)。 + /// 刻意保持裸整数 (不返回 Msg): 它是 get_ff_scalar(9, 0) 的标量投影, round(Msg) + /// 没有意义, 它只能消费裸值。 + int get_ff_mask(double timeout = 1.0); + + /// 重力缩放向量 (item 7, 7 值)。 + void set_gravity_scale(const std::vector& gs); + /// 惯量缩放向量 (item 8, 7 值)。 + void set_inertia_scale(const std::vector& isc); + + /// 设末端载荷质量 (item 4) 与质心 (item 5, sub 0..2)。 + /// + /// ⚠ mass 为负 (或 >20) 不会被拒 —— 固件走 ff_clamp(v, 0, 20) 静默钳制后回 ACK。 + /// 所以 set_payload(-5) 会成功, 而质量被钳成 0 => 重力前馈随之改变。 + /// 别靠"传负值探边界"来判断参数有没有写进去: 写进去的是钳后的值, 要确认请用 + /// get_ff_scalar 读回 (读回的是固件里钳后的真值)。 + void set_payload(double mass, const std::array& com = {0.0, 0.0, 0.0}); + /// 重力向量 (item 6, 3 值)。 + void set_gravity_vector(const std::array& g); + + /// 持久化当前运行时参数到 Flash (固件异步, 约 0.5-1s)。 + /// ⚠ 写 flash, 不可逆; 须先 disable()。 + void save_params(); + + // ---------- 笛卡尔运动 (固件原生规划) ---------- + // + // 规划 (采样/逐点 IK/Hermite 播放) 全在固件里, PC 侧只发点、收 0x4E 结果帧。 + // 能力 (受固件 #if LITEARM_CART_PLAN 约束) 在 connect() 时探一次。 + // + // ⚠ 三条命令串行使用 (一条受理前不要发下一条): 固件的规划结果只有一个槽位, 连续流水线 + // 发多条会吞掉中段应答 (那时报 CartReplyLostError = 结局未知)。 + // ⚠ 被非笛卡尔命令 (movej/estop/disable/home/… 共 12 条 opcode) 作废时, 固件一条 + // 0x4E 都不发 —— 那些命令在发出前就会清掉本 SDK 的待配队列。 + + /// 笛卡尔直线 (CMD_MOVE_L 0x3A): 末端沿起点->目标直线走, 姿态 slerp。 + /// 起点由固件取当前实测 TCP (不是参数)。 + CartPlan move_l(const rot::PoseInput& pose, double speed = 1.0, bool wait = true); + /// 笛卡尔圆弧 (CMD_MOVE_C 0x3B): 起点 + pose_via + pose_goal 三点定圆。 + /// pose_via 的姿态被忽略 (固件只取它的位置三分量)。 + /// ⚠ pose_start 必须与调用时的实测 TCP 一致 (容差 6mm / 0.03rad, 含旋转等价判定)。 + CartPlan move_c(const rot::PoseInput& pose_start, const rot::PoseInput& pose_via, + const rot::PoseInput& pose_goal, double speed = 1.0, + bool wait = true); + /// 多路点 (0x3C BEGIN -> 0x3D ADD x n -> 0x3E RUN): 依次经过 poses, 尖角。 + /// poses 最多 32 个。协议里没有倒角字段, 所以拐角是尖的。 + /// ⚠ BEGIN/ADD 中途失败时不补发任何"清状态"命令: 固件侧 RECV 态自带 2s 倒计时。 + CartPlan move_path(const std::vector& poses, double speed = 1.0, + bool wait = true); + + /// 认领一条还没有被取走的笛卡尔规划结果。本方法不碰链路。 + std::optional poll_cart(); + + /// 固件是否支持笛卡尔 (connect() 时探得)。 + bool cart_supported() const { return cart_supported_; } + + // ---------- 逆运动学 ---------- + + /// 固件后台 IK: pose[6] + seed[7] -> q[7]。失败抛 IKError。 + /// + /// seed 恒为模型 7 轴 (KIN_N), 与关节数解耦。台架 1J 上当前反馈只有 1 个值, 故未显式给 + /// q_seed 时按 BENCH_MODEL_AXIS(=5) 把台架电机的实测值填进 7 轴种子的对应位, 其余为 0。 + std::vector ik(const std::array& pose, + const std::vector& q_seed = {}, double timeout = 3.0); + + // ---------- 授权/激活 (固件 1.8.0+) ---------- + + /// 读设备授权记录 (CMD_GET_LICENSE 0x2F -> RSP_LICENSE 0x4F, 26B)。 + /// + /// 未激活的臂除 ENABLE 外一切照常 (售后/产线要能诊断) => 本方法在未激活时正常返回 + /// (activated() 为 false), 不抛异常 —— "未激活"是一种状态, 不是错误。 + /// 刻意不返回 Msg: RSP_LICENSE 没有固件发起的流量 —— 它是请求/应答式的设备身份记录。 + LicenseInfo license(double timeout = 1.0); + + /// 提交厂商签发的授权凭据 (CMD_ACTIVATE 0x3F)。**须先 disable()**。 + /// + /// mac = 16 字节 (两个 SipHash-2-4 标签), 由厂商侧签发工具产出。 + /// 本包不产生也不需要密钥 —— 规格的硬要求: 客户侧只要有一份能算 MAC 的代码, 这套机制 + /// 就归零。 + /// + /// ERR{0x3F,0x02} 是聚合档, 光看码会把"已经激活过"误判成失败 => 本方法在这一档回读一次 + /// license(): 只有设备确实 state == 0 才抛。 + void activate(uint32_t cust_id, uint32_t issued, uint32_t flags, const uint8_t* mac, + size_t mac_len, double timeout = 2.0); + + // ---------- DFU (SDK 里唯一的终端态操作) ---------- + + /// 进 ROM 系统 bootloader (CMD_ENTER_DFU 0x15) —— 两段式, 会话终态。 + /// + /// 设备随后重新枚举成 0483:DF11 (与 CDC 端口无关), 本对象不再可用: 成功返回后任何走 + /// 写口/读口的调用 (含 connect/reconnect) 都抛 ArmIsInDfuError; 烧完固件请新建一个 Arm。 + /// close() 照旧可用 (幂等空操作) —— teardown 在任何状态下都不该抛。 + /// + /// 两段式的理由 (固件契约): 使能中不跳 (本地预检 st.enabled); 发空载荷等 ACK{0x15}; + /// ACK 只表示"已登记", 不表示"会跳" —— 登记的静默撤销点有三个 (都不回报而 ACK 是成功 + /// 的) => 还要等设备真的消失。消失则置终态; 窗口内没消失则抛"登记被撤销/未执行"且 Arm + /// 保持可用 (那是静默撤销路径的唯一出口)。 + /// + /// ⚠ 本方法不是线程安全的串行化点 (与 disable/emergency_stop 一样不取 _cart_serial)。 + void enter_dfu(double timeout = DFU_VANISH_TIMEOUT_S); + + // ---------- 子模块 ---------- + + /// 关节级参数读写入口。 + JointParams& params(); + /// 动力学模型读写入口。 + ModelParams& model(); + /// 300Hz 控制拍采集入口。 + ArmLog& log(); + /// 固件自检入口。 + Diagnostics& diag(); + + // ---------- 只读属性 ---------- + + /// 关节数 (connect 后由第一帧状态定出; 未连接时为 0)。 + int n() const { return n_; } + /// 固件版本字符串, 如 "Litearm1.8.0-7J"。 + const std::string& firmware() const { return firmware_; } + /// 固件版本三元组 (未连接时为空)。 + const std::optional& fw_version() const { return fw_version_; } + /// 上次启动原因: "normal" | "iwdg-rst" | "" (未收到开机签名)。 + /// iwdg-rst 表示固件侧独立看门狗复位过 —— 这是固件唯一会外显该信息的地方。 + std::string last_reset_reason(); + /// 台架电机对应的模型轴 (仅供非整臂 IK 种子映射; 整臂不受影响)。 + int bench_model_axis = proto::BENCH_MODEL_AXIS; + + // ---- 下列 7 个只读访问器是**与 Python 参照分叉**的加法 (参照侧一个都没有) ---- + // + // ⚠ 它们都是**纯本地快照**: 不发帧、不改状态、**终态 (DFU) 下也照常返回**。 + // 这是刻意的 —— 现场排查最需要它们的时候, 正是链路已经废掉的时候。 + + /// 链路还在不在 (读线程活着 **且** 状态帧不过期)。 + /// + /// ⚠ **两个条件都要**: 只看"对象已连接"会把"读线程死了"读成健康; 只看"读过帧" + /// 会把"连着但对方不再发字节"读成健康。判据与 A 侧一致。 + bool is_connected() const; + /// 是不是已经进了 DFU 终态 (终态下**任何**写都会抛 `ArmIsInDfuError`)。 + bool is_in_dfu() const { return dfu_entered_; } + /// 状态帧累计计数 (成功**解码**的那些; 回绕 u16)。 + uint64_t status_seq() const; + /// 某类上行帧本会话的平均到达频率 (Hz); 样本不足 2 条时返回 0。 + /// ⚠ 分母是**实际到达间隔** (不是"自 connect 起"), 故不会随链路空闲一起衰减。 + double msg_hz(uint8_t id) const; + /// 从开机横幅里解析出的固件版本串 (`"Litearm1.8.0-7J"`); 没抓到横幅返回 `nullopt`。 + /// + /// ⚠ 与 `firmware()` **不是**一回事: 后者来自握手 `0x41` 的应答, **权威**; 前者来自 + /// 开机噪声, 只在开机那一刻发一次 ⇒ 连上时板子早已启动的话多半是 `nullopt`。 + /// 它服务的是"现场想知道板子上跑的是哪版"这种诊断场景。 + std::optional banner_version() const; + /// 宿主机侧计数快照 (纯本地, 终态下也不抛)。 + HostStats host_stats() const; + /// 当前生效的选项 (构造期入参 + 运行期可调旋钮的**现值**)。 + /// + /// ⚠⚠ 与 A 侧**签名不同** (A 返回构造期那份 `const ArmOptions&`): 本仓的旋钮 + /// (`q_tol` / `dq_tol` / `arrive_frames` / `move_timeout`) 是**公开成员、运行期可改**的 + /// (见本节末), 返回一份构造期的死快照会让调用方读到**过期值** —— 那正是本仓刚修掉的 + /// `port_string()` 那类"注释与实现脱节"的坑。故这里逐次组装、返回现值。 + ArmOptions options() const; + + // ---------- 用户可调旋钮 (与 Python 侧的公开属性对应) ---------- + + /// 到位判据: 关节角容差 (rad)。 + double q_tol = 0.03; + /// 到位判据: 关节速度容差 (rad/s)。 + double dq_tol = 0.10; + /// 到位判据: 连续静止拍数 (防到位瞬间误判/抖动)。 + int arrive_frames = 3; + /// 动作等待窗口 (move_p 到位 / 笛卡尔等 0x4E 都用它)。 + /// + /// ⚠ CartPending 的吸收额度只在本属性被读到的那一刻取一次快照 (两个构造点: + /// 构造函数与 connect 里那处重建), 且有下限 —— 传下去的是 cart_absorb_ttl(move_timeout), + /// 不是裸的本属性。于是 connect 之后再改本属性会让"等待窗口"与"额度有效期"分叉, 且 + /// 两个方向都不安全。要改就 reconnect()。 + double move_timeout = 15.0; + + /// ⚠ 当前链路实际用的端口 (未连接时回退到构造入参, 都没有则为空)。 + /// + /// ⚠⚠ **2026-09-28 改**: 原来是 `return port_`, 而 `port_` 只是**构造入参** —— + /// 走自动发现 (`connect()` 不带端口) 时它恒为空, 于是"连上了却报空端口"。 + /// 头里的注释当年写的是「构造时给的, **或自动发现到的**」—— 那句承诺代码没做到。 + /// 真机实测复现 (`~/Desktop/litearm-cpp-对比测试报告.md` §5.1)。 + /// ⇒ 现在按**注释原本的承诺**返回实际链路端口。 + const std::string& port_string() const; private: - /// Send an RPC call and return the result (or throw on error). - LiteArmValue rpc(const std::string& method, - std::map kwargs = {}); - - std::shared_ptr tp_; - std::string endpoint_; - std::string arm_id_; - std::string rpc_topic_; - std::string estop_topic_; - std::string command_topic_; - std::string client_id_; - int seq_ = 0; - std::shared_ptr state_sub_; - std::optional last_state_; - std::shared_ptr devices_; // lazy (created on first use) + friend class Ack; + friend class CartPending; + friend struct CartImpl; + //: 测试专用的访问口 (定义在 tests/test_support.hpp)。 + //: + //: 它存在的理由是"帧归属"那组判据**必须**能摸到会话内部的应答队列 —— 上游 Python + //: 直接写 `arm._a._queues` (那边没有访问控制), C++ 里就得显式开一个口子。 + //: ⚠ 宁可开这一个**有文档、只有一处**的口子, 也不要把 `require()` / `raw_write()` + //: 这类收口公开出去 —— 那会让"唯一写口"这条安全设计在公开面上破功。 + friend struct TestAccess; + // 子模块 (params/model/log/diagnostics) 也是 Arm 的一部分 —— 它们只经这三处收口 + // (require / write_query / cmd_expect_ack) 碰链路, 故显式列进来, 而不是给一个更宽的 + // "谁都行"的口子。 + friend class JointParams; + friend class ModelParams; + friend class ArmLog; + friend class LogReader; + friend class Diagnostics; + friend bool probe(Arm*); + friend CartPlan move_l(Arm*, const rot::PoseInput&, double, bool); + friend CartPlan move_c(Arm*, const rot::PoseInput&, const rot::PoseInput&, + const rot::PoseInput&, double, bool); + friend CartPlan move_path(Arm*, const std::vector&, double, bool); + + /// 链路是否已死 (读线程退出) —— 给 CartPending::wait 判"该不该当场收场"。 + bool link_dead(std::string* why) const; + + Arm& connect_impl(const std::optional& port); + + // ---- 收口: 所有路径都经过这三个 ---- + Ack& require(); // 状态读取与查询类下行的收口 + /// 唯一写口 —— **发出去就一定会到 USB**(没有"静默丢弃"这条路径)。 + /// + /// ⚠⚠ **2026-09-28 变更**: 原来这里有一个 `droppable` 形参, 让调用方声明"这一帧丢了 + /// 也无所谓", 同帧节流只对那种写口生效。**同帧节流整套已删除**(见 README + /// 「与原版的差异」: 参照实现里那套 `_tx_allowed` / `set_tx_repeat_min_interval` + /// 是私有且默认全关的, 行为上等价于无 ⇒ 本仓**不做**)。形参随之消失。 + /// ⇒ 现在"一条等 ACK 的命令被静默丢掉"这条路径**不存在** —— 不是靠射程收窄挡住的, + /// 而是**根本没有那把刀**。 + void raw_write(uint8_t cmd, const uint8_t* payload, size_t len); + void raw_write(uint8_t cmd) { raw_write(cmd, nullptr, 0); } + void raw_write(uint8_t cmd, const std::vector& p) { + raw_write(cmd, p.data(), p.size()); + } + void write_query(uint8_t cmd, const uint8_t* payload, size_t len); // 查询类出口 + void write_query(uint8_t cmd) { write_query(cmd, nullptr, 0); } + void write_query(uint8_t cmd, const std::vector& p) { + write_query(cmd, p.data(), p.size()); + } + void write_cmd(uint8_t cmd, const uint8_t* payload, size_t len, bool guarded = true); + void write_cmd(uint8_t cmd, bool guarded = true) { + write_cmd(cmd, nullptr, 0, guarded); + } + void write_cmd(uint8_t cmd, const std::vector& p, bool guarded = true) { + write_cmd(cmd, p.data(), p.size(), guarded); + } + void cmd_expect_ack(uint8_t cmd, const uint8_t* payload, size_t len, + const std::string& label, double timeout = 1.2); + void cmd_expect_ack(uint8_t cmd, const std::vector& p, + const std::string& label, double timeout = 1.2) { + cmd_expect_ack(cmd, p.data(), p.size(), label, timeout); + } + + void reject_if_in_dfu() const; + void reject_if_wrong_process() const; + /// Ack::wait 的守卫收口 (终态 -> fork -> 未连接), 顺序是承重的。 + void guard_for_ack_wait(); + /// 读线程拿到 0x4E 时的收集器入口 (Ack 调)。 + void on_cart_plan_reply(const std::vector& payload) const; + + void clear_session_state(); + std::optional read_status(double timeout); + /// 驱动型读者: 等到 done(a) 为真、或链路没了、或窗口用尽。一条帧都不认领。 + bool pump_until(double timeout, const std::function& done, + const std::string& label); + RobotState arrive(const std::vector& target, double timeout = -1.0); + bool pose_near(const std::array& tcp, const std::array& goal, + double pos_tol, double rpy_tol) const; + Msg> msg_state(const std::optional& v); + template + Msg wrap(T value, uint8_t ch) { + Msg m; + m.value = std::move(value); + if (a_ != nullptr) { + const auto st = a_->recv_stats(ch); + m.hz = st.first; + m.timestamp = st.second; + } + return m; + } + /// 当前时间源 —— **空安全**: 没注入就回落到进程级的 `SteadyClock`。 + /// + /// ⚠ 空安全不是防御性编程: `Arm::Arm() = default` 与 `Arm(port)` 两条构造路径**都不设** + /// `clock_` (只有收 `ArmOptions` 那条设), 直接解引用就是空指针。 + /// ⚠ 给 `Ack` / `CartPending` / `LogReader` 这些"只拿得到 `Arm*`"的友元用。 + /// 它们**不许**各自缓存一份引用 —— `Arm` 一旦被移动, 缓存的引用就悬空。 + const Clock& clock() const { + return clock_ != nullptr ? *clock_ : steady_clock_instance(); + } + + void reject_if_cart_in_flight(); + void zg_keepalive(double period); + bool wait_until_link_lost(double timeout); + /// 建软限缓存 (`limits_`)。**只在 `connect_impl` 末尾调一次**。 + /// + /// ⚠ 全程 **fail-open**: 读不到就清空缓存(不预检、放行), 绝不让连接失败 —— + /// fail-closed 会让**所有**运动命令全废, 那是更坏的错误。 + void refresh_limits(); + + /// 注入的时间源。**空 = 用进程级的 `SteadyClock`** (见 `clock()`)。 + /// + /// ⚠ 用 `shared_ptr` 而不是裸指针/引用: 传输 (`SerialTransport`) 也要拿它, 而传输的 + /// 寿命**短于** `Arm` (`close()` 会把它 reset 掉) ⇒ 裸指针会在那条路径上悬空。 + std::shared_ptr clock_; + + std::string port_; ///< 构造入参 (可能为空 —— 走自动发现时) + std::string live_port_; ///< 当前链路实际用的端口; 未连接时为空 + std::function(const std::string&)> transport_factory_; + proto::FirmwareVersion min_firmware_ = MIN_FW; + std::unique_ptr tr_; + std::unique_ptr a_; + std::string firmware_; + std::optional fw_version_; + int n_ = 0; + + /// 软限缓存 —— 连接时由 `refresh_limits()` 填一次; 空 ⇒ 不做软限预检 (fail-open)。 + /// + /// ⚠ 不变式: **非空 ⇒ 每一条都有限、且 `q_min <= q_max`**。`refresh_limits()` 的合理性 + /// 校验就是为此: `std::clamp(v, lo, hi)` 要求 `lo <= hi`, 违反是 **UB**(实测 + /// libstdc++ 静默返回 `lo`), 而 `home` 的目标要靠它钳。坏值只能**整份弃用**, + /// 不能"跳过那一轴" —— 否则上面那条不变式就断了。 + std::vector limits_; + + // 零重力保活会话。 + // zg_lock_ 只串行化启停; 保活线程写错误时取的是 zg_err_mu_ —— 若它也去取 zg_lock_, + // 就会与"持着 zg_lock_ 等 join"的 zero_g_stop 死锁。 + std::atomic zg_active_{false}; + std::thread zg_thread_; + std::atomic zg_stop_{false}; + std::exception_ptr zg_error_; + std::string zg_error_text_; + std::mutex zg_err_mu_; + mutable std::recursive_mutex zg_lock_; + double zg_join_s_ = ZG_JOIN_S; + + std::unique_ptr params_; + std::unique_ptr model_; + std::unique_ptr log_; + std::unique_ptr diag_; + + std::unique_ptr cart_; + /// 笛卡尔入口的串行锁 —— 从登记 token 一直持到 0x4E 配对完成; wait=true 时再持到停稳。 + /// (理由: 0x3A/0x3B/0x3E 共用码空间, ERR 回显只能按码判, >=2 条在途时会互相认下对方的 + /// ERR/0x4E => 假成功。) + /// + /// 可重入 (recursive_mutex), 不是普通 mutex —— 嵌套点只有一个: move_path 把 + /// BEGIN + ADD x n + RUN 整段圈进本锁, 而 RUN 那一段本身也走 request_and_wait, 它也要 + /// 拿这把锁。换成普通 mutex 会在那个嵌套点把调用者自己锁死。 + /// + /// ⚠ emergency_stop/disable/zero_g*/get_tcp 自己不许获取本锁 —— 降能量方向的动作与只读 + /// 查询必须永远可达 (持锁者可能阻塞到 move_timeout)。 + mutable std::recursive_mutex cart_serial_; + + bool cart_supported_ = false; + bool cart_probe_silent_ = false; + bool dfu_entered_ = false; + int pid_ = -1; + }; -} // namespace litearm +// --------------------------------------------------------------------------- +// 简易 CLI (巡检/冒烟) —— 对应 python -m litearm / litearm-python +// --------------------------------------------------------------------------- +int main_impl(const std::vector& argv); + +} // namespace litearm diff --git a/include/litearm/cart.hpp b/include/litearm/cart.hpp new file mode 100644 index 0000000..0e8450c --- /dev/null +++ b/include/litearm/cart.hpp @@ -0,0 +1,318 @@ +// 笛卡尔固件规划的应答配对 —— 0x4E 与在途请求的 FIFO 配对。 +// +// 固件原生笛卡尔 (0x3A/0x3B/0x3E) 的应答载荷里没有命令 id, 只能按受理顺序 FIFO 配对; +// 而固件那份应答只有一个槽位 (plan_pending 单 bool + 单份载荷): 同一个 main 排空窗口内 +// 登记 >=3 条时, 中段请求零应答。于是两条守卫缺一不可: +// +// * 多了一条 分两支。清队销毁在途记账, 于是后来那条 0x4E 归属不明 —— 固件的应答是在 +// 规划完成时就推上 USB 的, 而主机清队发生在我们写那条清队 opcode 的那一刻; 两者之间 +// 隔着 USB 延迟。所以"清队那一刻固件那条规划已经跑完、应答早躺在主机 RX 缓冲里"是 +// 可达的一支, 它属于刚被我们清掉的那条请求。而 0x4E 载荷里没有命令 id, 任何 FIFO +// 方案必有一支判错, 只能选判错得安全的那一支: 宁可报"结局未知", 绝不报"成功": +// - 清队后的不可归属应答 (吸收额度 > 0) -> 计数 + 吸收, 不报错; +// - 真·脱同步 (额度耗尽且队列空) -> 计数 + 报错, 不静默丢弃。 +// * 少了一条 (token 超时没收尾) -> 摘除该 token 并抛 CartReplyLostError —— 缺了这条, +// 后续的 0x4E 会被错配给一条早已被吞掉的请求。 +// +// 串行是强制的 (不是建议), 由 Arm 里的 _cart_serial 锁实现 —— 上面两条守卫都只在串行下 +// 成立: _request_and_wait 是先登记、后写, 而配对按受理顺序 FIFO => 没有锁时"两条线程的 +// 登记顺序"与"固件的受理顺序"可以倒置 => 两条应答各自配给对方那条 token (0x4E 载荷里 +// 没有命令 id, 原理上不可区分) => 假成功。 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "litearm/clock.hpp" +#include "litearm/rot.hpp" + +namespace litearm { + +class Arm; +class Ack; + +/// 会静默作废在途笛卡尔规划的 opcode (固件侧走 cart_invalidate_before_motion -> +/// cart_abort, 一条 0x4E 都不发)。 +/// +/// 逐条依据 (固件运行路径的调用点): ctrl_accept_move_j (0x01, 且 0x2A home 复用同一处) +/// / kin_runner_request_move_p (0x02) / ctrl_accept_move_js (0x03) / +/// ctrl_accept_move_mit (0x04) / ctrl_accept_move_mit_all (0x05) / +/// ctrl_accept_zero_g (0x06) / ctrl_accept_move_j_sync (0x07) / ctrl_disable (0x11) / +/// ctrl_emergency_stop (0x12) / ctrl_clear_faults (0x13) / ctrl_reset (0x14)。 +/// +/// 0x3A/0x3B/0x3E 不在此列 —— 它们欠应答, 而且欠两条: 被取代的那条在 PLANNING 期由 +/// cart_begin_request 补一条 CANCELED, 新受理那条随后还有自己的结果。清队会让报告与 +/// 物理事实相反。 +/// +/// 这张表只解释"少发"的一半: 突发下 N>=3 的吞应答不经过 +/// cart_invalidate_before_motion (是 cart_begin_request 的 unreported 分支吞的), +/// 那半边由 CartPending::wait 的"少了一条"守卫兜住 —— 别以为"没在这张表里 => 必有应答"。 +const std::set& cart_clears_upon(); + +/// "已收到应答但没人认领"那一列的上界 —— 超出即丢最旧的。 +/// +/// 为什么必须有界: 调用方放弃 token 的那条路径 (ACK 超时) 每放弃一条就在那一列永久留下 +/// 一条 —— 固件其实已受理, 迟到的那条 0x4E 会把它配进去, 而它的调用方再也不会来 wait。 +/// 为什么这个界是安全的: 那一列里的结果只能由 Arm::poll_cart 认领, 而它一次只认领一条; +/// 被丢的只是那些请求的规划结果, 它们的调用方早已拿到异常放弃了; 丢弃不碰 wait 的路径 +/// => 不制造假成功。 +inline constexpr size_t kUnclaimedMax = 8; + +/// 吸收额度的存活下限 (秒) —— Arm 传进来的 absorb_ttl 取 max(move_timeout, 本值)。 +/// +/// 为什么必须有下限: 额度是"清队/放弃那一刻, 那条请求可能还欠一条应答"的通行证。 +/// 额度过期而那条应答还在路上时, 它一到就撞上 on_reply: 队列若非空 (清队后又登记了 +/// 新请求) 就被配给那条新 token => 调用方拿到别人那条的规划结果并报成功 (假成功); +/// 队列若空则报 LiteArmError "固件与主机已错配" (归因误导)。 +/// +/// 下限 = 12.0s 约 3 + 8 + 1, 三段各自有固件依据: +/// 1. 生成 (<= 写帧 + 3s): RSP_CART_PLAN 由 main 循环每圈调一次, +/// 规划期的硬上界是 CART_PLAN_MAX_TICKS = 3s; +/// 2. 发射时延 (<= 约 8s): 结果生成 != 已上 USB, 同一圈里排在它前面的 +/// usb_cmd_report() 可能正卡在参数保存的阻塞擦写上; 唯一的上界来自 +/// hw_watchdog.h —— 擦写前把 IWDG 重装成约 8s, 即"允许多长的停顿才不复位"; +/// 3. 链路 + 主机侧取帧余量: 约 1s 量级。 +/// +/// 两个方向都会出错: 取大 => 真·脱同步的应答被静默吸收 (硬错误降级成静默); +/// 取小/过期 => 假成功。两害相权必须偏向取大。 +inline constexpr double kAbsorbTtlFloor = 12.0; + +/// Arm 要传给 CartPending 的 absorb_ttl —— 两个构造点共用这一处, 免得两处各写一遍 max。 +double cart_absorb_ttl(double move_timeout); + +/// 固件 cart_err_t (cart_plan.h) 的取值 —— 0x4E 载荷第 2 字节。 +/// +/// 这是规划层的码, 与 RSP_ERR 第二字节的门禁原因码 (未使能 0x03 / 零重力 0x04 / +/// 掉线锁存 0x06) 共用 1~6 的数值区间却语义完全不同, 别互推。 +inline constexpr int CART_ERR_OK = 0; +inline constexpr int CART_ERR_IK = 1; //: 某路点 IK 无解, 或相邻解跳变超阈值 +inline constexpr int CART_ERR_COLLINEAR = 2; //: move_c 三点共线, 定不出圆 +inline constexpr int CART_ERR_TOO_LONG = 3; //: 路点超容量 —— 整条拒绝, 臂一步没动 +inline constexpr int CART_ERR_LIMIT = 4; //: 路点本身或段中插值越关节限位 +inline constexpr int CART_ERR_CANCELED = 5; //: 被新请求取代 (预期内的接管, 不是故障) +inline constexpr int CART_ERR_BADARG = 6; //: 入参非法 + +/// move_c 的起点校验容差 —— 与 Arm::move_p 的默认值同源 (6mm / 0.03rad), 不是抄来的巧合: +/// move_c 校验的就是"实际 TCP 是否在 pose_start 附近", 判据必须与 move_p 的到位判据一致 +/// (同一把尺子), 否则 move_p 收工的位置会被 move_c 判成"起点不一致"。 +/// 同源关系由 test_cart_protocol.cpp 钉住。 +inline constexpr double CART_START_POS_TOL = 0.006; +inline constexpr double CART_START_RPY_TOL = 0.03; + +/// 一条笛卡尔命令的规划结果 (固件 RSP_CART_PLAN 0x4E 的载荷)。 +/// +/// 三个入口 (Arm::move_l / move_c / move_path) 都返回它。ok=false 时入口不返回而是抛 +/// 对应异常 (见 raise_for_plan)。 +/// +/// **started_busy / settled / q_final / settle_err_rad 四个字段由 wait 决定**: +/// wait=true -> 填真值 (等到状态帧的 CART_BUSY(bit10) 落 0 且 q 静止, 再回读一次 TCP +/// 与目标比对才返回) +/// wait=false -> 一律"未等待"默认值, 只等到规划结果 +/// wait=false 时这四个字段不是"没到位", 而是"没等" —— 想知道臂现在什么样, 该 +/// get_state() 回读, 而不是拿这份陈旧的规划去推断。 +/// +/// settled=true 要两条一起成立 (缺一不可): 判到位收的尾, 且到位判据满足之后回读的实际 +/// TCP 与本次请求的目标位姿对得上。 +/// +/// **ok=true 而 settled=false 是一个必须存在的结局** —— 它的含义是"这条笛卡尔轨迹被别的 +/// 运动作废了" (锁外/进程外的 movej/home/zero_g: 固件一收到就把这条轨迹作废, 而收尾照样 +/// 是 bit10=0 + q 静止), 或者 get_tcp() 取不到。ok 只说明"固件受理并规划出来了", 不说明 +/// 臂停在目标上。现场必须回读 get_tcp() 看真实落点。 +/// +/// settle_err_rad 的名字沿用旧 PC 侧规划器的字段, 但语义已经变了: 从前是"收尾后与终点指令 +/// 的最大关节偏差", 而在固件原生路径下该量物理不可得。现在它是"结束时各轴 q 与 q_final +/// 的差", 即判到位那 arrive_frames 帧的抖动幅度 (与终点指令无关)。 +struct CartPlan { + bool ok = false; + int err = 0; + int n_wp = 0; + uint32_t plan_us = 0; + bool started_busy = false; + bool settled = false; + std::vector q_final; + double settle_err_rad = 0.0; + + /// SDK 自造的 err 档 (不是固件 cart_err_t 的取值): 这条请求的结局未知。 + /// 与 CartReplyLostError 说的是同一件事; 当前没有产出者 (那情形直接抛异常)。 + static constexpr int ERR_REPLY_LOST = -1; + + /// 0x4E 载荷 -> CartPlan。载荷 = ok u8 + err u8 + n_wp u16 LE + plan_us u32 LE (8B)。 + static CartPlan from_reply(const std::vector& payload); +}; + +/// ok=false 时按 err 抛对应异常 (ok=true 时什么都不做)。 +/// +/// err=1 IK -> IKError +/// err=2 COLLINEAR / 3 TOO_LONG / 4 LIMIT -> CartesianPlanError +/// err=5 CANCELED -> MotionSupersededError (不是失败) +/// err=6 BADARG -> InvalidCommandError +/// +/// CANCELED 单独成型是刻意的: 接管是正常用法, 混进"规划失败"会让调用方走故障恢复。 +/// 反过来 CartesianPlanError 的三档都是"这条轨迹本身不成立, 臂一步没动"。 +/// 未登记的 err 值也归到 CartesianPlanError (ok=0 就是"规划没成"), 但消息里带上原始码。 +void raise_for_plan(const CartPlan& plan); + +/// 一条在途笛卡尔请求的票据 (SDK 自造, 固件不知道它的存在)。 +struct CartToken { + std::mutex mu; + std::condition_variable cv; + bool done = false; + std::optional> reply; //: 原始 0x4E 载荷 + std::optional lost; //: 非空 = 结局未知, 值即给调用方看的原因 + /// 登记那一刻的 absorbed_replies 水位线 —— 用来判"本条请求的等待窗口里有没有吸收额度 + /// 被消费掉" (见 drop_and_absorb: 消费过就等于固件已经把它那条应答交出来了, 于是不许 + /// 再补额度, 否则额度永 >=1 = 自持)。 + uint64_t absorbed_at = 0; + + void resolve(const std::vector& payload); + void fail(const std::string& reason); +}; + +using CartTokenPtr = std::shared_ptr; + +/// 在途笛卡尔请求的 FIFO 队列 + 0x4E 收集器。 +/// +/// 三种角色落在同一个对象上 (它们必须共享同一份队列状态): +/// * register_/request/drop —— 记账 (发出命令的入口用); +/// * on_reply —— 收集器: 读线程认领到 0x4E 就交给它; +/// * clear_pending —— 清队: 被 cart_clears_upon() 里的命令作废时调用, 并把被清掉的 +/// 条数记成吸收额度。 +class CartPending { +public: + /// absorb_ttl 是吸收额度的存活时间, 由 Arm 传 max(move_timeout, kAbsorbTtlFloor)。 + /// absorb 是建对象时就带进来的额度 —— 只给 Arm::connect 那一处用。 + explicit CartPending(double absorb_ttl, int absorb = 0, Arm* arm = nullptr); + ~CartPending(); + + /// 登记一条在途请求。**必须在写之前调** —— 否则窗口内到来的 0x4E 会落空。 + CartTokenPtr register_token(); + + /// 登记 token 并立刻发帧 —— 登记与写在同一个 try 内。 + /// + /// **write 只许包含"写"** —— 校验必须由调用方放在 request() 之外: 校验失败根本不该 + /// 登记 token (登记了又要摘, 就多出一段"这个异常到底发生在写之前还是写之后"的推断)。 + /// + /// 写失败 (TransportError) 才摘除自己那个 token 并原样抛出。两个方向别记反: + /// * 滞留一条未送达的 token => 在途比应答多一条 => 应答顺次前移一格 => 安全; + /// * 丢弃一条其实已送达的 token => 在途比应答少一条 => 假成功, 危险。 + /// **其余任何异常一律 re-raise 且不动队列** —— 它们证明不了帧没送达 + /// (最现实的一支是 KeyboardInterrupt 落在 flush 里: 那时整帧已在驱动里会被送达)。 + CartTokenPtr request(const std::function& write); + + /// 摘除指定的那条 token (按对象身份, 不是队尾 —— 并发下队尾是别人的)。 + void drop(const CartTokenPtr& token); + + /// 摘除 token, 并留一格吸收额度 —— 摘 token 与给额度必须在同一把锁内。 + /// + /// timed_out 说明为什么要放弃这条请求: 超时支传 true、读链路炸支传 false。 + /// 它只决定下面那个"例外段"生不生效, 不影响"摘 token"这一半。 + /// + /// 例外 (唯一一条, 只对超时支生效): timed_out 且本条请求的窗口里已经消费过额度 => + /// 不补。吸收先于配对, 所以一条在途 token 的应答到了、而额度还有存量时, 被吃掉的 + /// 就是它自己那条 —— 那条应答已经交出来了, 固件不再欠它什么。此时再补一格, 补出来的 + /// 那格会去吃下一条命令的应答, 那条又超时、又补一格 ... 自持。 + /// + /// pump 支根本没等过窗口 (读链路当场就炸了), 这条前提在它身上没有依据 => 它照旧补一格。 + void drop_and_absorb(const CartTokenPtr& token, bool timed_out); + + /// 非阻塞认领: 队首 token 若已收尾就摘掉并返回它, 否则 nullopt。 + /// + /// FIFO 配对下队首就是"最早那条" —— 它被解答之前, 后面的 token 不可能被解答, + /// 所以只看队首是对的。用途只有 Arm::poll_cart()。 + /// 判据必须含 done, 不能只看"那一列非空": on_reply 是先登记进那一列、后 resolve, + /// 两者之间有一条缝 —— 并发读者落进缝里就会拿到一个还没收尾的 token。 + CartTokenPtr claim_resolved(); + + /// 收集器 —— 读线程见到 0x4E 时调它。 + /// + /// **先消费吸收额度, 再配对** —— 顺序是决定性的: 清队后立刻登记的新 token 会让队列 + /// 非空, 而那条清队前就已到达的迟到应答若被拿去配对, 就配错了 (报成功而实际没跑完, + /// 即假成功)。故额度那一步必须排在"队列空/非空"判断之前。 + /// + /// 队列空且额度为 0 才是真 "多了一条": 计数并报错 (抛基类 LiteArmError —— 那是 SDK + /// 的配对模型与固件脱同步, 内部不变量被破坏, 不该被专门 catch), 不静默丢弃。 + void on_reply(const std::vector& payload); + + /// 清队 —— 由 Arm::raw_write 在发出清队 opcode 之前调用。返回摘除条数。 + /// + /// 这几条请求在固件侧已经静默作废, 永远等不到 0x4E 了: 它们被标成"结局未知"并唤醒 + /// 等待者。**记账被销毁了, 但被清掉的条数留下一份额度**: 清队那一刻固件那条规划可能 + /// 已经跑完、应答早躺在主机 RX 缓冲里。 + /// n == 0 时两个字段都不动 —— 这不是优化, 是正确性: 零重力保活线程每 40ms 发一条清队 + /// opcode (0x06, 而它几乎永远是空清队), 若空清队也刷新截止, 额度就永远不过期, 此后 + /// 一条真脱同步的应答会被永久静默吞掉。额度跨多次清队累加。 + int clear_pending(const std::string& reason = ""); + + /// 等这条 token 收尾; 超时按"少了一条"处理。返回原始 0x4E 载荷。 + /// + /// 收尾不成功一律抛 CartReplyLostError (不是 MotionTimeoutError: 这里要表达的是 + /// "未知结局", 混进通用超时会让调用方按"没生效"去重发)。 + /// 两条放弃路径 (超时 / 读线程死) 摘 token 时都留一格吸收额度。 + /// 读线程死了要当场响亮: 等满 timeout 才报"结局未知"会把"链路断了"说成"固件没回"。 + std::vector wait(const CartTokenPtr& token, double timeout); + + /// 在途条数。 + int pending(); + + /// 因越界被丢弃的"待认领"条数 (累计) —— 丢弃不报错, 故至少要看得见。 + uint64_t evicted_unclaimed = 0; + /// 队列空却收到 0x4E 的累计条数 —— "多了一条"的可观测面。 + uint64_t extra_replies = 0; + /// 被吸收掉的不可归属应答的累计条数 —— 与 extra_replies 并列的另一个可观测面。 + uint64_t absorbed_replies = 0; + +private: + /// 时间源 —— 从会话 (`Arm`) 要, **不自己缓存一份** (理由与 `Ack::clk()` 逐条相同: + /// `Arm` 可移动、`arm_` 可为空、时钟是每会话注入的)。 + const Clock& clk() const; + + void unclaim_locked(const CartTokenPtr& token); + int absorbance_locked(double now); + void unclaim(const CartTokenPtr& token); + Arm* arm_of() const { return arm_; } + + std::mutex lock_; + Arm* arm_ = nullptr; + std::deque q_; + std::deque unclaimed_; + double absorb_ttl_; + int absorb_ = 0; + double absorb_deadline_ = 0.0; +}; + +// --------------------------------------------------------------------------- +// 三条入口的实现 (由 Arm 的同名方法转发) +// --------------------------------------------------------------------------- + +/// 能力探测: 固件是否支持笛卡尔 (受 #if LITEARM_CART_PLAN 编译开关约束)。 +/// +/// **只发空载荷的 0x3A**: 0x3A~0x3E 里没有只读命令 —— 发一条合法载荷就是"connect 之后臂 +/// 自己动一下"。空载荷撞的是固件的长度校验, 它排在 cart_gate_ok 与一切副作用之前, 于是: +/// 有笛卡尔 (开关 ON) -> ERR{0x3A,0x01} (长度不足) => 能力在 +/// 无笛卡尔 (开关 OFF) -> ERR{0x3A,0x00} (default 分支) => 能力不在 +/// +/// **"一片安静" != "不支持" —— 故本探测有界重试**: 固件的 default 分支保证任何固件都会 +/// 对 0x3A 回一条 ERR, 所以读超时只可能是这一帧丢了 (本硬件的 USB CDC 上行有已知丢帧), +/// 而不是"固件没有这条命令"; 而本函数的结果在 connect() 里缓存整个会话。 +/// 两种回应的处置不同: ERR{0x3A,0x00} 是确定结论, 立刻返回 false 不重试; 读超时则重试, +/// 仍安静才返回 false (fail-closed), 并把这一情形记进 Arm 的 _cart_probe_silent。 +bool probe(Arm* arm); + +CartPlan move_l(Arm* arm, const rot::PoseInput& pose, double speed = 1.0, + bool wait = true); +CartPlan move_c(Arm* arm, const rot::PoseInput& pose_start, + const rot::PoseInput& pose_via, const rot::PoseInput& pose_goal, + double speed = 1.0, bool wait = true); +CartPlan move_path(Arm* arm, const std::vector& poses, + double speed = 1.0, bool wait = true); + +} // namespace litearm diff --git a/include/litearm/clock.hpp b/include/litearm/clock.hpp new file mode 100644 index 0000000..82386a3 --- /dev/null +++ b/include/litearm/clock.hpp @@ -0,0 +1,51 @@ +#pragma once +// 可注入时钟 —— 让"超时 / 到期 / 时间戳"这类判据**可被测试控制**。 +// +// ⚠⚠ **本仓的时钟只管"现在几点", 不管"怎么等"** —— 这是与另一套实现 (Gitee +// `yudao_hz_1/litearm-cpp`) 的一处**刻意不同**, 理由写在这里, 因为它是承重的: +// +// 那套的 `Clock` 还有一个 `sleep_until(t)` (假钟 = 把虚拟时间**跳到** t 并立刻返回)。 +// 本仓**不提供**它, 因为本仓的等待**清一色**是 `condition_variable::wait_for` —— +// 那些等待靠的是**有帧到达时被 notify 立刻唤醒**, 而不是"睡够时间"。若把睡眠也搬进 +// 时钟, 就会丢掉"立刻唤醒"这条 (每次等待都要等满一整片), 得不偿失。 +// +// ⇒ 本仓的语义是: +// * **deadline / 到期比较 / 时间戳** 走注入的钟 (`Clock::now_s()`); +// * **等待** 走条件变量的**真实**时间 (带一个固定的真实分片)。 +// +// ⚠⚠ **由此带来一条必须知道的后果**: 注入一个**不会自己前进**的假钟时, +// `now + timeout` 这类 deadline **永远到不了** ⇒ 凡"等超时真的发生"的判据会**挂死** +// (不是失败, 是挂死)。所以: +// * 假钟只给"**纯比较**"类判据用 —— 到期比较、时间戳、TTL 水位, 那些**不等待**; +// * 凡"超时真的发生"的判据 (ACK 超时 / 写超时 / 到位超时) **必须用真钟** +// (`SteadyClock` 或默认构造), 或由用例主动 `FakeClock::advance()` 推时间。 +// 这条与另一套实现的结论**一致** (它的注释也写"凡'超时真的发生'的判据要用 SteadyClock"), +// 只是本仓因为不提供 `sleep_until`, 后果更硬: 那边是"退化成 skip", 这边是"挂死"。 +#include +#include + +namespace litearm { + +/// 时间源。单位是**秒 (double)** —— 与 `now_s()` 同口径, 于是仓里所有 +/// `end = now + timeout` 形式的表达式**一个都不用改**。 +class Clock { +public: + virtual ~Clock() = default; + /// 单调递增的"现在", 单位秒。⚠ 契约: **单调不减**。 + virtual double now_s() const = 0; +}; + +/// 生产用时钟 (steady_clock, 与从前的 `now_s()` 逐位一致)。 +class SteadyClock : public Clock { +public: + double now_s() const override; +}; + +/// 进程级默认时钟的**唯一**实例 —— 给那些拿不到会话对象的调用点兜底 +/// (例如不注入时钟时直接 `new SerialTransport(...)`)。 +/// +/// ⚠ 它是**共享且只读**的, 没有 setter: 要换时间源就**显式注入** (`ArmOptions::clock`), +/// 别做进程级可变全局 —— 一个进程里两个 `Arm` 想要两个不同时钟时, 全局会静默串味。 +const Clock& steady_clock_instance(); + +} // namespace litearm diff --git a/include/litearm/codec.hpp b/include/litearm/codec.hpp deleted file mode 100644 index 623d772..0000000 --- a/include/litearm/codec.hpp +++ /dev/null @@ -1,74 +0,0 @@ -#pragma once -/** - * @file codec.hpp - * @brief Protobuf serialization for litearm v4 wire protocol. - * - * All messages use protobuf for type-safe, multi-language serialization. - * Exception types are registered so that remote errors can be reconstructed - * on the client side. - */ - -#include -#include - -#include "litearm/types.hpp" - -// Forward-declare protobuf generated types -namespace litearm_proto { -class Value; -class RpcRequest; -class RpcReply; -class RobotState; -} - -namespace litearm { - -// ── Value conversion ──────────────────────────────────────────────────────── - -/// Convert a LiteArmValue to protobuf Value. -litearm_proto::Value to_proto(const LiteArmValue& val); - -/// Convert a protobuf Value to LiteArmValue. -LiteArmValue from_proto(const litearm_proto::Value& proto); - -// ── Request encoding/decoding ─────────────────────────────────────────────── - -/// Encode an RPC request to protobuf bytes. -std::string encode_request(const std::string& method, - const std::map& kwargs); - -/// Decode an RPC request from protobuf bytes. Returns (method, kwargs). -std::pair> -decode_request(const std::string& payload); - -// ── Reply encoding/decoding ───────────────────────────────────────────────── - -/// Encode a successful RPC reply to protobuf bytes. -std::string encode_reply_ok(const LiteArmValue& result); - -/// Encode an error RPC reply to protobuf bytes. -std::string encode_reply_error(const std::string& error_type, - const std::string& error_msg, - const std::map& details = {}); - -/// Decode an RPC reply from protobuf bytes. -/// Returns the result on success, throws the appropriate exception on error. -LiteArmValue decode_reply(const std::string& payload); - -// ── State encoding/decoding ───────────────────────────────────────────────── - -/// Encode a RobotState to protobuf bytes. -std::string encode_state(const RobotState& state); - -/// Decode a RobotState from protobuf bytes. -RobotState decode_state(const std::string& payload); - -// ── Estop encoding/decoding ───────────────────────────────────────────────── - -/// Encode an emergency stop signal to protobuf bytes. -std::string encode_estop(bool trigger = true); - -/// Decode an emergency stop signal from protobuf bytes. -bool decode_estop(const std::string& payload); - -} // namespace litearm diff --git a/include/litearm/device.hpp b/include/litearm/device.hpp deleted file mode 100644 index 60e5597..0000000 --- a/include/litearm/device.hpp +++ /dev/null @@ -1,133 +0,0 @@ -#pragma once -/** - * @file device.hpp - * @brief RemoteDevice — generic peripheral remote interface. - * - * Mirrors litearm-python's RemoteDevice/DeviceManager. Methods are proxied - * through "device.{device_id}.{method}" RPC calls to litearm-server. - * - * Usage: - * @code - * auto arm = Arm::create("tcp/192.168.1.100:7447"); - * auto hand = arm.device("hand_0"); - * hand.open(); - * hand.set_gesture("pinch"); - * - * auto gripper = arm.device("gripper_0"); - * gripper.set_width(0.5); - * @endcode - */ - -#include -#include -#include -#include - -#include "litearm/types.hpp" - -namespace litearm { - -/** - * Generic remote peripheral interface. - * - * Holds a device_id and an RPC function (injected by the Arm) that prefixes - * calls with "device.{device_id}.". Mirrors pylitearm RemoteDevice. - */ -class RemoteDevice { -public: - /// RPC function: method name → kwargs → result. - using RpcFn = std::function)>; - - RemoteDevice(std::string device_id, RpcFn rpc); - - /// Call an arbitrary device method (prefixes "device.{id}."). - LiteArmValue call(const std::string& method, - std::map kwargs = {}); - - /// Device identifier (e.g. "hand_0", "gripper_0", "teach_0"). - const std::string& device_id() const { return device_id_; } - - // ── Common methods ────────────────────────────────────────────────────── - - /// Get device status. - LiteArmValue get_status(); - /// Get device info. - LiteArmValue get_info(); - /// Connect the device. - bool connect(); - /// Disconnect the device. - void disconnect(); - /// Clear device faults. - bool clear_faults(); - - // ── End-effector methods (gripper / dexterous hand) ───────────────────── - - /// Open the end-effector. - bool open(); - /// Close the end-effector. - bool close(); - /// Set grip force. - bool set_force(double force); - - // ── Dexterous hand methods ────────────────────────────────────────────── - - /// Get device state (device.state_method, aligned with handGetState). - LiteArmValue get_state(); - /// Set a gesture (hand only). - bool set_gesture(const std::string& gesture); - /// List supported gestures (hand only). - LiteArmValue list_gestures(); - /// Per-finger motion (pose = per-finger angles). - bool finger_move(const std::vector& pose); - /// Set per-finger speeds. - bool set_speed(const std::vector& speed); - /// Set per-finger torques. - bool set_torque(const std::vector& torque); - - // ── Gripper methods ───────────────────────────────────────────────────── - - /// Set gripper width (gripper only). - bool set_width(double width); - /// Get gripper width (gripper only). - double get_width(); - - // ── Teach pendant methods ─────────────────────────────────────────────── - - /// Read joint angles (teach only). - LiteArmValue get_joints(); - /// Read button states (teach only). - LiteArmValue get_buttons(); - -private: - std::string device_id_; - RpcFn rpc_; -}; - -/** - * Device manager — lazily creates RemoteDevice handles. - */ -class DeviceManager { -public: - using RpcFn = RemoteDevice::RpcFn; - - explicit DeviceManager(RpcFn rpc); - - /// Get (and lazily create) a device interface. - RemoteDevice get(const std::string& device_id); - - /// Already-created device handle. - RemoteDevice operator[](const std::string& device_id); - - /// Whether a device handle has been created. - bool contains(const std::string& device_id) const; - - /// ids of all created handles. - std::vector device_ids() const; - -private: - RpcFn rpc_; - std::map devices_; -}; - -} // namespace litearm diff --git a/include/litearm/diagnostics.hpp b/include/litearm/diagnostics.hpp new file mode 100644 index 0000000..ebea94b --- /dev/null +++ b/include/litearm/diagnostics.hpp @@ -0,0 +1,86 @@ +// 固件自检 —— CMD_KIN_BENCH(0x49) -> RSP_KIN_BENCH(0x4A) 文本回执的解析。 +// +// 固件侧在后台用 DWT cycle 测各热点单拍开销 (禁中断测量, 最长窗口 5s), 结果拼成多行文本 +// 经可靠应答通道回。最有价值的产出是最后一行 LINK —— 它承载了固件里唯一的链路诊断计数 +// (CRC 坏帧 / RX 环溢出 / 应答 FIFO 丢弃 / CAN TX 失败分类桶 / 控制拍峰值与超预算拍数 / +// 锁存拍与原因), 这些在固件里原本全部静默。 +// +// 0x49 是双 ID: 下行 CMD_KIN_BENCH 与上行 RSP_JOINT_PARAM 同值, 靠收发方向区分。 +// 该命令不检查 payload 长度, 发出去必产生一次真实测量 —— 故仅用于显式调用。 +#pragma once + +#include +#include +#include +#include + +#include "litearm/msg.hpp" + +namespace litearm { + +class Arm; + +/// 固件 bench 最长窗口 KIN_BENCH_MAX_TICKS = 5s, 留 1.6 倍余量。 +inline constexpr double KIN_BENCH_TIMEOUT = 8.0; + +/// 固件把 KIN_BENCH 回执拆成连续两帧发 (第 1 帧 = 各项耗时, 第 2 帧 = LINK 诊断行)。 +/// +/// 拆帧的理由是单帧载荷上限 255B 而全文实测约 306B: 原单帧发出会在 253B 处硬截断, +/// 截断点恰好落在 LINK 行的 loop_k= 之后 => loop_k/ovr/flt/cause/st 一个都读不到。 +/// +/// **只收一帧必然丢掉全部链路计数, 而丢了它不会报错** —— 五个便捷出口全是 +/// link.get(key, 0) 语义, 拿不到就是静默的 0, 与"没有出错"分不开。 +inline constexpr int KIN_BENCH_FRAMES = 2; + +/// 第 2 帧的等待窗口。新固件它紧跟第 1 帧到; 旧固件根本不发 => 靠超时退出。 +/// 代价: 在只发一帧的旧固件上, 每次 kin_bench() 会多等这半个秒。 +inline constexpr double KIN_BENCH_MORE_S = 0.5; + +/// KIN_BENCH 回执。raw 是固件原文 (逐行), timings/link 是解析结果。 +/// +/// timings: 块名 -> 数字序列。FK/JAC/SC/G/RNEA/M/LAW 是 (n, avg_cyc, max_cyc), +/// IK 是 (n_try, min_cyc, max_cyc), LOOP1 是 (cycles,)。 +/// link: 链路诊断计数 (见模块顶部)。 +struct KinBenchResult { + std::string raw; + std::map> timings; + std::map link; + + std::string str() const { return raw; } + + // ---- 常用诊断的便捷出口 (拿不到就是 0, 与"没有出错"分不开, 见 KIN_BENCH_FRAMES) ---- + long long crc_errors() const; //: 固件累计 CRC 坏帧 (上位机发出的帧被固件丢弃) + long long reply_dropped() const; //: 固件应答 FIFO 队满丢弃数 + long long can_tx_fail() const; //: CAN 发送失败累计 + long long loop_max_kcycle() const; //: 控制拍峰值耗时 (千 cycle) + long long loop_overruns() const; //: 超出周期预算的控制拍数 + + //: 固件自己点名的"静默丢反馈"项。键名带数字 (rxl0/rxl1) —— 解析器必须吃得下, + //: 否则这三个出口恒返回 0, 而"读不到"与"没丢过"长得一模一样。 + long long rx_fifo_lost_motor() const; //: 电机域 CAN FIFO0 溢出计数 + long long rx_fifo_lost_bridge() const; //: gs_usb 桥 CAN FIFO1 溢出计数 + long long gsusb_ring_drops() const; //: gs_usb 桥环满丢帧数 +}; + +/// 解析固件 KIN_BENCH 文本; 空文本视为异常回执 (抛 TransportError)。 +KinBenchResult parse_kin_bench(const std::string& text); + +/// `arm.diag()` —— 固件自检入口。 +class Diagnostics { +public: + explicit Diagnostics(Arm* arm) : arm_(arm) {} + + /// 触发固件运动学/动力学单拍开销自测, 返回解析后的回执。 + /// + /// 固件侧最长窗口 5s (期间控制循环心跳监督豁免), 默认超时 8s。 + /// 该命令不检查载荷长度, 发出即产生一次真实测量。 + /// + /// **回执是连续两帧, 本方法两帧都收** —— 只收第 1 帧会静默丢掉全部链路计数。 + /// 旧固件只发一帧, 靠短窗口超时退出, 行为兼容。 + Msg kin_bench(double timeout = KIN_BENCH_TIMEOUT); + +private: + Arm* arm_; +}; + +} // namespace litearm diff --git a/include/litearm/errors.hpp b/include/litearm/errors.hpp new file mode 100644 index 0000000..bee7abc --- /dev/null +++ b/include/litearm/errors.hpp @@ -0,0 +1,225 @@ +// 错误层级 —— 镜像 litearm-python 的 errors.py (本包独立定义, 不依赖任何上游源码)。 +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "litearm/protocol.hpp" + +namespace litearm { + +/// 本包所有错误基类。 +class LiteArmError : public std::runtime_error { +public: + explicit LiteArmError(const std::string& what) : std::runtime_error(what) {} +}; + +/// 未连接/串口未开时调用。 +class NotConnectedError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// 在 fork 出的子进程里使用父进程建立的会话 —— 本包 fail-closed 拒绝。 +/// +/// 为什么必须拒绝 (不是保守, 是唯一正确的选择): +/// * 子进程继承 fd (os.write 照样把字节写到 CDC 上) 但不继承线程 —— 线程不被 fork +/// 复制 => 读线程在子进程里不存在; +/// * 于是命令会真的发出去, 而应答永远没人读 => 子进程等满超时报"无应答", +/// 用户以为命令失败、重试 => 重复下发; +/// * get_state() 那种读路径更隐蔽: 它不报错, 只是静默返回继承来的陈旧 state。 +/// +/// 所以口径是: 子进程里一个字节都不下发、也不认任何缓存, 立刻抛本异常。 +/// 子进程要用臂 => 新建一个 Arm。 +/// +/// 继承自 NotConnectedError: catch(NotConnectedError) 与 catch(LiteArmError) +/// 都抓得到它 (这个会话在本进程里确实不可用)。 +class ForkedSessionError : public NotConnectedError { +public: + using NotConnectedError::NotConnectedError; +}; + +/// 串口读写/帧 CRC 校验失败。 +class TransportError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// 固件版本不符合约定 (应 Litearm<主.次.修>-{7J|1J}) 或不满足最低版本。 +class FirmwareMismatchError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// 参数/命令非法 (长度、取值越界等)。 +class InvalidCommandError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// 状态帧 flags FAULT 或进入 EMERGENCY (含 G7 单关节故障降级)。 +class MotorFaultError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// move 超时未到位。 +class MotionTimeoutError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// IK 求解失败 / 目标不可达。 +class IKError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// 固件显式 ERR (payload 含 [cmd, code])。 +/// +/// 两个字段都是可编程判定用的, 消息里另有可读文本 (由 err_reason 拼出): +/// * cmd —— 固件回显的命令码。以它为准, 不要拿"我刚发了哪条"去推断: +/// 异步路径 (0x02 MOVE_P 的 IK 收尾 / 0x25 参数保存) 的 ERR 与它对应的那条命令 +/// 不在同一次收发里。 +/// * code —— 错误码。语义逐命令定义 (同一个 0x03 在 0x01 上是"未使能"、在 0x10 上 +/// 是"可重试"、在 0x23 上是"新限位关不住在途目标"); 0x00 恒为"固件没有这条命令"。 +/// +/// 带 drop_hold 门禁的那些运动命令 (0x01/0x03/0x04/0x05/0x07/0x2A 与笛卡尔 0x3A~0x3E) +/// 的 0x06 恒等于「掉线刚性持位锁存 (drop_hold)」—— 那是 SDK 唯一能确证该锁存存在的 +/// 信号; 状态帧里读不到它 (见 RobotState::drop_hold_inferred)。 +/// 两个例外: 0x02 MOVE_P 没有该门禁 (永不回 0x06); 0x10 ENABLE 的 0x06 判的是 +/// EMERGENCY / joint_fault, 不含 drop_hold。 +class CommandRejectedError : public LiteArmError { +public: + explicit CommandRejectedError(const std::string& what, uint8_t cmd = 0, + uint8_t code = 0) + : LiteArmError(what), cmd(cmd), code(code) {} + uint8_t cmd = 0; + uint8_t code = 0; +}; + +/// 固件没有实现这条命令 (ERR 码 == 0x00)。 +/// +/// 固件 usb_cmd.c 的 default 分支对未实现命令只回 ERR{cmd,0x00} 且无任何副作用; +/// 已实现命令的错误码一律落在 0x01..0x07。故 0x00 是「固件无此命令」的唯一稳定哨兵 +/// —— 比版本号可靠 (固件版本号不随命令增删而变)。 +class UnsupportedByFirmwareError : public CommandRejectedError { +public: + using CommandRejectedError::CommandRejectedError; +}; + +/// 固件规划被拒 (IK 无解 / 三点共线 / 超容量 / 越限位)。 +/// +/// 对应固件 RSP_CART_PLAN 的 err 字段 (cart_err_t) 里 2=COLLINEAR / 3=TOO_LONG / +/// 4=LIMIT 三种 —— 都是这条轨迹本身不成立, 臂一步没动。 +/// 刻意不继承 CommandRejectedError: 固件这里回的是规划结果, 不是 ERR{cmd,code} 形态的 +/// 「拒绝执行这条命令」, 硬套会凭空多出语义错误的 cmd/code 字段。 +class CartesianPlanError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// 这条笛卡尔请求被新请求取代 —— 预期内的接管, 不是失败。 +/// +/// 对应 err=5=CANCELED。抢占是正常用法 (新目标来了就接管旧的), 所以调用方必须能把它与 +/// 「规划失败」分开: 混进通用异常会让正常抢占走成故障分支。不得继承 CartesianPlanError。 +class MotionSupersededError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// 0x4E 应答丢失 (固件单槽 pending 在突发下会吞应答) —— 未知结局。 +/// +/// 固件侧那份应答只有一个槽位: 同一 main 排空窗口内登记 >=3 条笛卡尔请求时会吞掉中段 +/// 应答。这是本 SDK 自造的语义, 不是固件回码 —— 臂可能已经动了, 也可能没有, 只能靠 +/// 回读实际状态判定, 不可当失败重发。 +class CartReplyLostError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// 本 Arm 已把设备交棒进 ROM bootloader —— 会话的终态, 不可再用。 +/// +/// 由 Arm::enter_dfu 置位, 且只在确认设备真的从 CDC 上消失之后 (判据 = 读路径抛 +/// TransportError): 那一刻本对象关掉 transport, 此后任何走 require() / 读口 / 写口的 +/// 调用 —— 含 connect()/reconnect() —— 都抛本异常。烧完固件要接着用臂请新建一个 Arm +/// (本对象刻意不提供"复活"入口)。 +/// +/// 刻意不继承 NotConnectedError: 那个的语义是"还没连 / 链路断了, 连上即可", 而这里不是 +/// 一次可恢复的掉线 —— 是本会话按调用方要求主动把设备交了出去。 +class ArmIsInDfuError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +// ---- 跨线错误 (原住在 litearm-server) ---- + +/// 该入口不可跨线: 未注册 / 返回活对象 / 返回不可序列化类型。 +/// 与"固件拒绝"刻意分开: 这不是设备出错, 不要重试。 +class NotRemoteable : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// 该能力在当前后端上没有实现 (不是因为调用方式不对)。 +class NotSupportedOnThisBackend : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// 遥操态下拒绝手动控制。 +class TeleopLockedError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +/// 遥操正在切换中 (enter/exit 未完成)。 +class TeleopBusyError : public LiteArmError { +public: + using LiteArmError::LiteArmError; +}; + +// =========================================================================== +// (cmd, code) 语义表 —— 固件 ERR 应答的唯一解读处。 +// +// 真源 = 固件仓 User/litearm/hal/usb_cmd.c 的 usb_cmd_dispatch() 与 +// User/litearm/kinematics/kin_runner.c (后台 IK 的收尾)。逐条取自固件原注释。 +// code == 0x00 一律不在此表 —— 它恒等于"固件没有这条命令"(唯一真源是 usb_cmd.c 的 +// default 分支), 由通用档承担。 +// =========================================================================== + +const std::map, std::string>& err_text(); +const std::map& err_code_text(); + +/// (cmd, code) -> 可读语义。未登记的码不静默 (三级查找, 认不出也把原始码带出来)。 +std::string err_reason(uint8_t cmd, uint8_t code); + +/// CMD_ENABLE (0x10) 的返回码里值得重试的那些 —— Arm::enable 的重试白名单。 +/// +/// 判据是白名单 ("只有它说可重试才重试") 而不是"黑名单": 固件对这几个码的注释是并列的: +/// 0x03 "可重试 (反馈未齐 / CMODE 首写…重发即可)" -> 重试 +/// 0x06 "锁存须先 reset (EMERGENCY/joint_fault)" -> 否 +/// 0x07 "CMODE 补写预算耗尽, 重发无用, 须现场排查" -> 否 +/// 0x00 固件没有这条命令 (default 分支) -> 否 +/// 黑名单写法 ("除 0x06 外都重试") 会让 0x07 与 0x00 各自白耗 3.3 秒。 +const std::set& enable_retryable_codes(); + +/// 把固件 ERR 应答 ([cmd, code]) 映射成异常并抛出。 +/// +/// `code == 0x00` <=> 固件 usb_cmd.c 的 default 分支 —— 即未实现该命令, 且固件侧无任何 +/// 副作用 (唯一真源)。已实现命令的错误码一律落在 0x01..0x07。 +/// 抛出的具体类型按 code 定 (0x00 -> UnsupportedByFirmwareError), 故返回 void 而不是 +/// 异常对象: 返回值会按基类切片, catch (UnsupportedByFirmwareError&) 就抓不到了。 +/// +/// 消息里的语义文本不在这里写死: 走 err_reason, 它的第 2/3 档会带上原始码 —— 固件新增一档 +/// 时这里是唯一会如实说出"我没见过这个码"的地方。 +/// .cmd / .code 两个字段照旧供调用方编程判定 (它们是本异常存在的理由)。 +[[noreturn]] void raise_firmware_error(const std::vector& payload, + const std::string& prefix); + +} // namespace litearm diff --git a/include/litearm/exceptions.hpp b/include/litearm/exceptions.hpp deleted file mode 100644 index b22f4f4..0000000 --- a/include/litearm/exceptions.hpp +++ /dev/null @@ -1,181 +0,0 @@ -#pragma once -/** - * @file exceptions.hpp - * @brief Structured exceptions for the litearm C++ client SDK. - * - * Mirrors pylitearm.errors and litearm-python exceptions — no cross-imports. - */ - -#include -#include -#include -#include -#include -#include - -namespace litearm { - -// ── Base exception ────────────────────────────────────────────────────────── - -/// Base class for all litearm SDK runtime errors. -class LiteArmError : public std::runtime_error { -public: - explicit LiteArmError(const std::string& msg) : std::runtime_error(msg) {} -}; - -// ── Configuration ─────────────────────────────────────────────────────────── - -/// Robot configuration is missing, inconsistent, or fails integrity checks. -class ConfigurationError : public LiteArmError { -public: - explicit ConfigurationError(const std::string& msg) : LiteArmError(msg) {} -}; - -// ── State transitions ─────────────────────────────────────────────────────── - -/// An API operation is not valid in the current arm state. -class StateTransitionError : public LiteArmError { -public: - explicit StateTransitionError(const std::string& msg) : LiteArmError(msg) {} -}; - -/// The requested operation requires a live hardware connection. -class NotConnectedError : public StateTransitionError { -public: - explicit NotConnectedError(const std::string& msg) : StateTransitionError(msg) {} -}; - -// ── Invalid commands ──────────────────────────────────────────────────────── - -/// A target or control parameter is malformed or outside its safe range. -class InvalidCommandError : public LiteArmError { -public: - explicit InvalidCommandError(const std::string& msg) : LiteArmError(msg) {} -}; - -/// Cartesian planning failed (e.g. IK singularity or path infeasible). -class CartesianPlanError : public InvalidCommandError { -public: - explicit CartesianPlanError(const std::string& msg, - int index = -1) - : InvalidCommandError(msg), index(index) {} - - int index; ///< Index of the failing waypoint (-1 = unknown) -}; - -// ── Safety violations ─────────────────────────────────────────────────────── - -/// A planned or executing motion violated a configured safety rule. -class SafetyViolationError : public LiteArmError { -public: - explicit SafetyViolationError( - const std::string& msg, - std::unordered_map details = {}) - : LiteArmError(msg), details(std::move(details)) {} - - std::unordered_map details; -}; - -/// One or more joints did not provide sufficiently fresh feedback. -class FeedbackTimeoutError : public SafetyViolationError { -public: - explicit FeedbackTimeoutError( - const std::string& msg, - std::unordered_map details = {}) - : SafetyViolationError(msg, std::move(details)) {} -}; - -/// Measured joint position departed too far from the commanded trajectory. -class FollowingError : public SafetyViolationError { -public: - explicit FollowingError( - const std::string& msg, - std::unordered_map details = {}) - : SafetyViolationError(msg, std::move(details)) {} -}; - -/// The robot did not physically settle at the requested target in time. -class MotionTimeoutError : public SafetyViolationError { -public: - explicit MotionTimeoutError( - const std::string& msg, - std::unordered_map details = {}) - : SafetyViolationError(msg, std::move(details)) {} -}; - -/// One or more drives reported a motor fault. -class MotorFaultError : public SafetyViolationError { -public: - explicit MotorFaultError( - const std::string& msg, - std::unordered_map details = {}) - : SafetyViolationError(msg, std::move(details)) {} -}; - -/// A specific arm-level fault condition (e.g. undervoltage, overtemp). -class ArmFault : public MotorFaultError { -public: - explicit ArmFault( - const std::string& msg, - std::unordered_map details = {}) - : MotorFaultError(msg, std::move(details)) {} -}; - -/// The host-side command watchdog had to take over control. -class WatchdogError : public SafetyViolationError { -public: - explicit WatchdogError( - const std::string& msg, - std::unordered_map details = {}) - : SafetyViolationError(msg, std::move(details)) {} -}; - -// ── Transport errors ──────────────────────────────────────────────────────── - -/// CAN/serial transport failed or could not accept a command. -class TransportError : public LiteArmError { -public: - explicit TransportError(const std::string& msg) : LiteArmError(msg) {} -}; - -/// CAN bus mode (classic/FD, baud rate) does not match expected configuration. -class CanModeMismatch : public TransportError { -public: - explicit CanModeMismatch(const std::string& msg) : TransportError(msg) {} -}; - -/// A motor ID is not registered on the CAN bus. -class CanMotorNotRegistered : public TransportError { -public: - explicit CanMotorNotRegistered(const std::string& msg) : TransportError(msg) {} -}; - -/// CAN frame write timed out (bus full or device not responding). -class CanWriteTimeout : public TransportError { -public: - explicit CanWriteTimeout(const std::string& msg) : TransportError(msg) {} -}; - -// ── Cancellation ──────────────────────────────────────────────────────────── - -/// Motion was cancelled by a cancellation token or stop request. -class MotionCancelled : public LiteArmError { -public: - explicit MotionCancelled(const std::string& msg) : LiteArmError(msg) {} -}; - -// ── Exception factory ─────────────────────────────────────────────────────── - -/// Throw the appropriate exception by type name (from wire protocol). -/// Falls back to LiteArmError for unknown types. -/// If the type is a SafetyViolationError subclass, details are attached. -/// This function always throws — it never returns normally. -[[noreturn]] void throw_exception( - const std::string& type_name, - const std::string& message, - std::unordered_map details = {}); - -/// Check if a type name corresponds to a SafetyViolationError subclass. -bool is_safety_violation_type(const std::string& type_name); - -} // namespace litearm diff --git a/include/litearm/framing.hpp b/include/litearm/framing.hpp new file mode 100644 index 0000000..7175f8f --- /dev/null +++ b/include/litearm/framing.hpp @@ -0,0 +1,85 @@ +#pragma once +// 分帧器 (L0) —— 把字节流切成帧。**与平台无关**。 +// +// ⚠⚠ **它存在的理由只有一个: 消灭重复。** 从前本仓的 POSIX 与 Win32 两条传输后端 +// **各写过一份**分帧状态机 (SOF 扫描 / 半帧超时 / 噪声捕获), 代码里自己写着 +// "与 POSIX 侧同一段逻辑" —— 那是一份**会漂的拷贝**: 改了一侧忘另一侧, 而 Windows +// 那条分支在 Linux 上**连编都编不了**, 漂了也不会有人当场发现。 +// +// ⇒ 抽成这一个类之后: 新增/修改的都是**被编译、被测试**的代码, 两条后端只剩 I/O。 +// +// ⚠ 本文件**不许**出现任何平台相关的头 (`windows.h` / `termios.h` / ``) —— +// 一旦出现, 这个类就再也不能在"另一侧"编译, 抽出来的意义当场归零。 +#include +#include +#include +#include +#include + +#include "litearm/protocol.hpp" + +namespace litearm { + +class FrameReader { +public: + /// 喂入字节, 返回缓冲里的**第一条**完整帧 (没有则 `nullopt`)。 + /// + /// ⚠⚠ **返回值必须接住 —— 它不只是"试试看", 它把那条帧取走了。** + /// 丢掉返回值 = **丢掉那一帧**, 而且症状很隐蔽: 数据明明在流, 却一帧都读不出来。 + /// 实测**在同一个下午踩了两次** (一次在 `SerialTransport` 的读循环里, 一次在用例里) + /// ⇒ 这是本 API 最容易踩的坑, 故标准用法是**取空**: + /// + /// ```cpp + /// if (auto f = r.feed(chunk.data(), chunk.size())) return *f; // 这一批里可能就有整帧 + /// while (auto f = r.feed(nullptr, 0)) { /* 处理 f */ } // 再取空缓冲 + /// ``` + /// + /// ⚠ 一次 `read()` 常常带回来 20+ 条 153B 的状态帧 (100Hz 流 + 一读 4096B) —— + /// 剩下的**留在缓冲里**, 靠后续 `feed(nullptr, 0)` 取出。**是留着不是丢掉**。 + /// + /// ⚠⚠ **没有时间参数, 而且不该有**: 装配是纯粹的字节操作, 不需要知道"现在几点"。 + /// 半帧窗口那个**唯一**需要时间的判据在 `tick(now)` 里。签名里留一个用不到的 + /// `now` 会在 `--werror` 下变成编译失败, 而更坏的是它让人以为"喂字节时也在计时"。 + std::optional feed(const uint8_t* data, size_t len); + + /// 没有新字节时也要过一遍**半帧窗口**。 + /// + /// ⚠ 一个"看起来像帧头、却永远收不齐"的残片必须在超时后被丢掉, 否则它会**永远堵在 + /// 缓冲头部**, 后面所有真帧一条都出不来 —— 症状是"链路活着但一帧都收不到"。 + /// ⚠ 读循环**每一拍**都要调它, 包括 `read_frame` 返回空的那几拍。 + /// + /// 返回**缓冲有没有被改动** (即: 丢掉了假帧头)。改动过 ⇒ 调用方应当**立刻重试装配** + /// —— 刚被丢掉的那个假帧头后面很可能就跟着一条真帧。 + /// ⚠ 这个返回值不是可选装饰: 少了它, 调用方要么漏掉那一帧 (要等到下一次读字节), + /// 要么在"tick 什么都没干"时空转。两种都不对。 + bool tick(double now); + + /// 被当作非帧字节跳过的**噪声文本** (固件开机横幅就在里面)。 + /// + /// ⚠ 与另一套实现的 `take_noise()` 不同, 本函数**不清空**: 本仓的 `text_log()` 是 + /// 只读且被**多次**调用 (`last_reset_reason()` / `banner_version()` 都要读它)。 + std::string noise() const { return std::string(noise_.begin(), noise_.end()); } + + void reset(); + + /// 缓冲里还有多少未消化的字节 (诊断用: "帧体未齐"时它就是那个半帧的长度)。 + size_t buffered() const { return buf_.size(); } + +private: + /// 装配循环: 尽量从缓冲头部取出一条完整帧; 取不出返回 `nullopt`。 + std::optional try_assemble(); + void note(uint8_t b); + + std::vector buf_; + std::deque noise_; + /// "在途残帧"的起始时刻; `< 0` 表示没有。 + double partial_since_ = -1.0; + + /// 一个收不齐的假帧头最多留多久 (秒)。 + /// ⚠ 它**不是**"帧间隔上限" —— 它只管**假帧头**的清理, 与固件多久发一帧无关。 + static constexpr double kPartialMaxS = 0.25; + /// 噪声留痕上限 —— 与 `SerialTransport::kTextLogMax` 同口径 (2048)。 + static constexpr size_t kNoiseMax = 2048; +}; + +} // namespace litearm diff --git a/include/litearm/litearm.hpp b/include/litearm/litearm.hpp new file mode 100644 index 0000000..9540d13 --- /dev/null +++ b/include/litearm/litearm.hpp @@ -0,0 +1,58 @@ +// litearm-cpp —— LiteArm STM32 直连后端 (薄协议 SDK)。 +// +// 定位: 固件 (litearm-stm32, 版本约定 Litearm1.5.0+) 已内置 S 曲线/IK/动力学/控制律, +// 本库直连 USB CDC 用高层子集镜像 litearm-python 的常用用法, PC 端不做轨迹/运动学。 +// +// 典型用法: +// +// #include +// using namespace litearm; +// +// Arm arm; // 不指定端口则自动发现 (VID:PID 1d50:606f) +// arm.connect(); // 校验固件版本约定 +// arm.enable(); +// arm.movej({0.1, 0, 0, 0, 0, 0, 0}, 0.3); +// auto tcp = arm.get_tcp().value; // 读值要 .value (返回信封 Msg) +// auto q = arm.ik({0.30, 0.0, 0.35, 3.14, 0.0, 0.0}); +// arm.move_l(std::array{0.30, 0.0, 0.40, 3.14, 0.0, 0.0}, 0.5); +// arm.disable(); +// arm.close(); +// +// **返回信封** Msg { value, hz, timestamp }: 11 个"读一帧"型 getter +// (get_state / get_status_now / get_tcp / get_ff_vec / get_ff_scalar / +// params().get_joint_param / model().get_body / model().get_jm / model().status / +// model().get_gravity / diag().kin_bench) 返回它 —— value 是原返回值, hz 是该类帧在本 +// 会话里的平均到达频率, timestamp 是最近一帧的本地时刻。move_* / home (动作结果) 与纯本地量 +// 不包。 +// +// ⚠ 与本库的 Python 版一致: 每条会话都带一条读线程, 所以**必须 close()**。 +// C++ 里 Arm 的析构会兜底调用 close(), 但显式 close() 仍是推荐做法 (析构只在对象真的被 +// 析构时才跑, 而"还想再用一会儿"的语义会让对象活到作用域结束)。 +#pragma once + +#include "litearm/ack.hpp" +#include "litearm/arm.hpp" +#include "litearm/cart.hpp" +#include "litearm/clock.hpp" +#include "litearm/diagnostics.hpp" +#include "litearm/errors.hpp" +#include "litearm/log.hpp" +#include "litearm/model.hpp" +#include "litearm/msg.hpp" +#include "litearm/params.hpp" +#include "litearm/protocol.hpp" +#include "litearm/rot.hpp" +#include "litearm/state.hpp" +#include "litearm/transport.hpp" + +#define LITEARM_VERSION_MAJOR 2 +#define LITEARM_VERSION_MINOR 1 +#define LITEARM_VERSION_PATCH 0 +#define LITEARM_VERSION_STRING "2.1.0" +#define LITEARM_VERSION_CSTR "2.1.0" + +namespace litearm { + +inline const char* version() { return LITEARM_VERSION_STRING; } + +} // namespace litearm diff --git a/include/litearm/log.hpp b/include/litearm/log.hpp new file mode 100644 index 0000000..f5ace28 --- /dev/null +++ b/include/litearm/log.hpp @@ -0,0 +1,139 @@ +// 300Hz 控制拍采集 —— 固件 CMD_LOG_CTRL(0x2D) / CMD_LOG_READ(0x2E) 的封装。 +// +// 固件侧 (hal/log_capture.h): 跑激励轨迹时把每控制拍 (tick / 执行参考 q_ref / +// 执行速度 dq / 电机实测力矩 tau) 追加进 RAM 缓冲, LOG_MAX_SAMPLES=2400 (约 8s@300Hz) +// 记满自停; 上位机用 LOG_READ 按字节游标分块读回 —— 这条"绕开连续 USB 流、按游标续读" +// 的设计正是为规避 CDC 间歇掉帧而做的, 所以本模块的重试/续读语义是必须的, 不是可选优化。 +// +// 样本布局 (固件 log_sample_t): u32 tick + q_ref[N] + dq[N] + tau[N], 即 4 + 12N 字节。 +#pragma once + +#include +#include +#include +#include + +#include "litearm/clock.hpp" +#include "litearm/transport.hpp" + +namespace litearm { + +class Arm; + +/// 固件 LOG_MAX_SAMPLES (hal/log_capture.h) —— 超过会回 ERR{0x2D,0x02} +inline constexpr int LOG_MAX_SAMPLES = 2400; +/// 固件单块读回上限 LOG_READ_CHUNK (usb_cmd.c) —— 9B 头 + n <= 245 +inline constexpr int LOG_READ_CHUNK = 245; +/// 固件控制循环频率 (litearm.h 的 LITEARM_CTRL_HZ) —— 采集是逐拍记录的, +/// 故 n 拍需要 n/HZ 秒才录满。用于推算默认等待预算。 +inline constexpr int CTRL_HZ = 300; + +/// 单拍字节数 = sizeof(u32) + 3 * N * sizeof(f32)。 +int sample_size(int n_joints); + +/// 一拍采样 (固件 log_sample_t)。 +struct LogSample { + uint32_t tick = 0; + std::vector q_ref; + std::vector dq; + std::vector tau; +}; + +/// 把原始字节流解析成样本列表; 不是样本整数倍则抛 TransportError。 +std::vector parse_samples(const std::vector& blob, int n_joints); + +/// 按固件游标 (next_byte) 分块读回采集缓冲。 +/// +/// next_byte == 0 表示读完; 掉帧 (应答整块丢失) 时按当前游标重试, 不会丢一段数据。 +class LogReader { +public: + LogReader(Arm* arm, double timeout = 1.0, int retries = 3) + : arm_(arm), timeout_(timeout), retries_(retries) {} + + /// 之前一次读回的固件总字节数 (读完后可查)。 + uint32_t total_bytes() const { return total_bytes_; } + + /// 查询固件当前已记录的字节数 (一次 LOG_READ 往返, 不保存数据)。 + /// + /// 采集是 300Hz 逐拍进行的, start(n) 返回时缓冲里还没有数据 —— 等待录满必须靠轮询 + /// 本方法, 不能靠 sleep 猜时间。 + uint32_t total(); + + /// 等固件记满 n_ticks 拍; 返回实际总字节数。 + /// + /// timeout<0 时按 n_ticks/CTRL_HZ*1.5 + 3s 推算。超时抛 MotionTimeoutError 并报出 + /// 实际录到多少拍 —— 绝不返回半截数据冒充成功。 + uint32_t wait_for(int n_ticks, double timeout = -1.0, double poll = 0.05); + + /// 逐块产出原始字节 (读到 `next_byte == 0` 结束), **保留块边界**。 + /// + /// 必须自检游标是否前进: 固件游标若因异常/干扰回了一个不大于当前 offset 的非零值, + /// 天真的实现会在同一位置无限次重读 (死循环)。这里直接报错。 + /// + /// C++ 没有生成器, 故用"块向量"表达上游那个 `Iterator[bytes]` —— 语义相同 + /// (逐块、保边界), 只是**一次性取完**而不是惰性产出。要拼接成一整块用 `read_all()`。 + /// ⚠ 满量程 2400 拍 x 88B ≈ 211 kB, 一次取完完全放得下; 真要流式处理请自己循环 + /// `read_chunk` 那一层 (它是私有的, 故本方法就是公开面里的流式等价物)。 + std::vector> iter_chunks(); + + /// 读回整块原始字节 (读到 `next_byte == 0` 结束)。 + std::vector read_all(); + + /// 读回并解析为样本列表。 + std::vector samples(); + +private: + std::vector read_chunk(uint32_t offset); + + /// 时间源 —— 从会话 (`Arm`) 要, 空安全 (理由同 `Ack::clk()`)。 + /// ⚠ 定义在 `log.cpp`, **不能内联在这里**: 本头里 `Arm` 只是前向声明, + /// `arm_->clock()` 需要完整类型 (实测: 内联会 `invalid use of incomplete type`)。 + const Clock& clk() const; + + Arm* arm_; + double timeout_; + int retries_; + uint32_t total_bytes_ = 0; + uint32_t next_ = 0; +}; + +/// `arm.log()` —— 采集启停 + 读回入口。 +class ArmLog { +public: + explicit ArmLog(Arm* arm) : arm_(arm) {} + + /// 开始记录 n_ticks 拍后自停 (n=0 等效 stop())。 + /// + /// 固件是逐拍记录的: 本调用返回时缓冲里还没有数据 (300Hz, n 拍要 n/300 秒)。 + /// 要拿完整数据须用 capture() (自动等) 或 dump() (默认等)。 + /// 固件侧 n > LOG_MAX_SAMPLES(2400) 回 ERR{0x2D,0x02}。 + void start(int n_ticks); + + /// 停止/清空记录 (停止后仍可用 reader() 读回已记录部分)。 + void stop(); + + LogReader reader(double timeout = 1.0, int retries = 3); + + /// 便捷: 采 n_ticks 拍 -> 等录满 -> 读回 -> 解析成样本列表。 + /// + /// 固件是 300Hz 逐拍记录的 —— start(n) 返回时缓冲里还没有数据, n_ticks=600 要约 2 秒 + /// 才录满, 故本方法会轮询等待。等不满则抛错, 不返回半截数据。 + std::vector capture(int n_ticks, double timeout = 1.0, int retries = 3, + double record_timeout = -1.0); + + /// 读回原始字节流落盘 (解析交给调用方 / parse_samples)。返回字节数。 + /// + /// wait=true (默认) 会先等最近一次 start(n) 的 n 拍录满 —— 否则 start() 之后立刻 + /// dump 会落盘一个空/半截文件。要读"此刻已录到多少"就传 wait=false。 + size_t dump(const std::string& path, bool wait = true, double timeout = 1.0, + int retries = 3, double record_timeout = -1.0); + + /// 最近一次 start(n) 的 n。 + int last_target() const { return last_target_; } + +private: + Arm* arm_; + int last_target_ = 0; +}; + +} // namespace litearm diff --git a/include/litearm/model.hpp b/include/litearm/model.hpp new file mode 100644 index 0000000..e592eb8 --- /dev/null +++ b/include/litearm/model.hpp @@ -0,0 +1,121 @@ +// 动力学模型在线导入 —— 固件 0x30/0x32/0x33/0x34/0x35/0x37/0x38/0x39 的封装。 +// +// stm32 固件里, 连杆动力学 (m / com / I) 与电机转子惯量 jm 原先都是编译期常量。产线要求 +// 「每台臂出厂前上 HYY 控制器辨识一次动力学 -> 把参数写进固件」, 逐台不同 => 必须能在线 +// 写入, 否则每台都得重编译烧录。 +// +// 固件的模型分三层: +// bank 模型唯一权威, 永不含 payload; 来源 = 编译期常量 或 导入/flash +// staging 0x30 / 0x33 写这里, 不参与递推 +// 生效层 bank + payload 复合, RNEA 读这一层 +// 0x32 commit 把 staging 整组原子拷进 bank => 不存在"半截模型生效"。 +// +// 正确用法: +// arm.model().set_body(1, {...}); // J1 ... J7 +// arm.model().set_jm({...}); // 7 个 +// arm.model().commit(MODEL_MASK_WRITTEN); // 0x2FE +#pragma once + +#include +#include +#include + +#include "litearm/msg.hpp" + +namespace litearm { + +class Arm; + +/// 模型刚体数 —— 与固件 dyn_model_data.h 的 DYN_MODEL_NBODY 同值。 +/// 与 arm.n() (关节数, 台架 1J 时为 1) 无关: 模型恒 9 刚体 (0=base, 1..7=关节, +/// 8=ee 固定体)。照抄 joint param 用 arm.n() 做上界会让台架完全无法验证模型。 +inline constexpr int MODEL_NBODY = 9; + +/// 单 body 的字段数: m, cmx, cmy, cmz, ixx, ixy, ixz, iyy, iyz, izz +inline constexpr int MODEL_BODY_PARAMS = 10; + +/// jm 长度 (关节数) +inline constexpr int MODEL_JM_N = 7; + +/// body i 的 expected_mask 位 (i = 0..8)。 +inline constexpr uint16_t MODEL_BIT_BODY(int i) { return uint16_t(1u << i); } +inline constexpr uint16_t MODEL_BIT_JM = uint16_t(1u << 9); + +/// 工具/产线实际写入的集合: body1..7 + jm (body0=base 与 body8=ee 不写) = 0x2FE。 +inline constexpr uint16_t MODEL_MASK_WRITTEN = + uint16_t((MODEL_BIT_BODY(1) | MODEL_BIT_BODY(2) | MODEL_BIT_BODY(3) | + MODEL_BIT_BODY(4) | MODEL_BIT_BODY(5) | MODEL_BIT_BODY(6) | + MODEL_BIT_BODY(7)) | + MODEL_BIT_JM); +static_assert(MODEL_MASK_WRITTEN == 0x2FE, "MODEL_MASK_WRITTEN 常量漂移"); + +/// 固件 RSP_MODEL_STATUS(0x56) 的 5B 应答。 +struct ModelStatus { + int override_level = 0; //: 0 = 编译期常量; 1 = 导入/flash 模型 (不区分二者) + uint16_t staged_mask = 0; //: bit0..8 = body0..8, bit9 = jm (0..0x3FF) + int dirty = 0; //: 1 = RAM 有未固化改动 (定义 = RAM != flash) + + static ModelStatus decode(const std::vector& payload); +}; + +/// `arm.model()` —— 动力学模型的读/写/提交/回退入口。 +/// +/// 无状态: 每次调用直发一帧, 不做任何本地缓存 —— 固件才是唯一权威, 缓存会与"另一端 +/// 可能改了"打架。 +class ModelParams { +public: + explicit ModelParams(Arm* arm) : arm_(arm) {} + + /// 固件是否支持模型在线导入。 + /// + /// 用 0x34 探测: 旧固件没有这条命令的 case => 落 default => ERR{0x34,0x00} => 捕获 + /// UnsupportedByFirmwareError 并回 false。 + /// 不能用 0x30/0x33 探测 —— 旧固件对它们有显式 case 回 ERR{cmd,0x02} (与"数值非法" + /// 同码), 无法区分「无此命令」与「参数非法」。 + bool probe(); + + /// 读生效 bank 的第 idx 个刚体 (0..8) 的 10 个参数。读的是 bank (无 payload 复合), + /// 故与 set_body 写入值可直接逐项比对。 + Msg> get_body(int idx, double timeout = 1.0); + + /// 写 staging 的第 idx 个刚体。**不生效**, 须 commit()。 + /// 固件拒绝 (ERR{0x30,0x02}) 的情形: 索引越界 / 任一分量非有限或 |v|>1e6 / + /// idx == 8 (ee 固定体) 而质量非 0。 + void set_body(int idx, const std::vector& vals); + + /// 读生效 bank 的 jm (7 个关节转子惯量)。 + Msg> get_jm(double timeout = 1.0); + + /// 写 staging 的 jm[7]。**不生效**, 须 commit()。 + /// ⚠ set_jm 会改写关节映射, 用错会让臂乱摆且没有可靠的回退路径 —— 谨慎使用。 + void set_jm(const std::vector& vals); + + /// staging 整组原子生效。 + /// + /// expected_mask 必须与固件侧"本次会话实际写过的项"逐位相等, 否则回 + /// ERR{0x32,0x07} (掩码不符 —— 与"数值非法"分开的码, 便于产线定位)。 + /// 零模型哨兵不过回 ERR{0x32,0x02}; 已武装回 ERR{0x32,0x04}。 + void commit(uint16_t expected_mask); + + /// 只回退模型: bank 回编译期常量 (含 jm) + 清 staging。须失能。 + /// + /// 三条后果 (工具/产线必须知道): + /// 1. 不动 flash => flash 里的导入模型仍在, 重新上电会复活; + /// 2. 此后任何一次 save_params() 会把 flash 里的导入模型一并抹掉 + /// (整扇区擦除, 旧记录不可恢复); + /// 3. 回退后 status().dirty == 1 (RAM != flash), 但不要据此提示"补固化"。 + void revert(); + + /// 读模型状态 (override / staged_mask / dirty)。 + Msg status(double timeout = 1.0); + + /// 给定关节角算重力项 G(q) (纯读, 无门控, 不改任何状态)。 + Msg> get_gravity(const std::vector& q, + double timeout = 1.0); + +private: + int check_body_idx(int idx) const; + Arm* arm_; +}; + +} // namespace litearm diff --git a/include/litearm/msg.hpp b/include/litearm/msg.hpp new file mode 100644 index 0000000..5863b88 --- /dev/null +++ b/include/litearm/msg.hpp @@ -0,0 +1,33 @@ +// Msg —— 一帧的值 + 它到达的统计 (11 个"读一帧"型 getter 的返回信封)。 +#pragma once + +#include + +namespace litearm { + +/// **一帧的值 + 它到达的统计** —— 读一帧型 getter 的返回信封。 +/// +/// 形态借自松灵的同名结构 ({msg, hz, timestamp}), 语义由本包定: +/// * value —— 原返回值。取不到帧的入口 (get_state/get_tcp) 用 std::nullopt 表达, +/// 即"没取到"这件事从"返回空"变成"Msg.value 为空"; +/// * hz —— 该类帧在本会话里的平均到达频率, 见 Ack::recv_stats; +/// * timestamp —— 该类帧最近一帧的本地单调时刻 (本会话从没收到过该类帧时为 0.0)。 +/// +/// 被包的入口: get_state / get_status_now / get_tcp / get_ff_vec / get_ff_scalar / +/// params.get_joint_param / model.get_body / model.get_jm / model.status / +/// model.get_gravity / diag.kin_bench。 +/// +/// 刻意不包的: move_* 与 home (它们返回动作结果, 不是"读一帧"); 纯本地量 +/// (n / firmware / last_reset_reason); license (RSP_LICENSE 没有固件发起的流量); +/// 以及两个派生 getter (get_ff_mask / all_joint_params)。 +template +struct Msg { + T value{}; + double hz = 0.0; + double timestamp = 0.0; + + const T& operator*() const { return value; } + const T* operator->() const { return &value; } +}; + +} // namespace litearm diff --git a/include/litearm/params.hpp b/include/litearm/params.hpp new file mode 100644 index 0000000..a01c17b --- /dev/null +++ b/include/litearm/params.hpp @@ -0,0 +1,66 @@ +// 关节级运行时参数 —— 固件 CMD_SET_JOINT_PARAM(0x22) / SET_JOINT_LIMITS(0x23) / +// GET_JOINT_PARAM(0x24) / PARAM_RESET(0x36) 的面向对象封装。 +// +// 与 FF/动力学调参族 (留在 Arm 上) 的分工: 那一族是前馈/控制律参数 +// (0x26/0x27/0x28/0x31 + 读回 0x2B/0x2C); 本模块是关节级 MIT 刚度/阻尼/力矩钳幅与 +// 软限位。两者都是 RAM 生效, 持久化统一走 Arm::save_params() (0x25)。 +#pragma once + +#include +#include +#include +#include + +#include "litearm/msg.hpp" +#include "litearm/transport.hpp" + +namespace litearm { + +class Arm; + +/// 固件 RSP_JOINT_PARAM(0x49) 的 21B 应答: idx + kp,kd,tau_max,q_min,q_max。 +struct JointParam { + int idx = 0; + double kp = 0.0; + double kd = 0.0; + double tau_max = 0.0; + double q_min = 0.0; + double q_max = 0.0; + + static JointParam decode(const std::vector& payload); +}; + +/// `arm.params()` —— 关节级参数的读写入口 (RAM 生效)。 +class JointParams { +public: + explicit JointParams(Arm* arm) : arm_(arm) {} + + /// 改写单关节 MIT 刚度/阻尼/力矩钳幅 (RAM; 须 save_params() 才持久化)。 + /// 固件侧还有一道值合法性校验, 非法时回 ERR{0x22,0x02}。 + void set_joint_param(int idx, double kp, double kd, double tau_max); + + /// 改写单关节软限位 (RAM)。固件要求 q_min < q_max。 + void set_joint_limits(int idx, double q_min, double q_max); + + /// 读回单关节参数 (与上面两个构成写->读回闭环)。 + /// 返回 Msg 信封 (帧 id RSP_JOINT_PARAM)。单发请求/应答式 => 第一次调用 hz == 0.0。 + Msg get_joint_param(int idx, double timeout = 1.0); + + /// 逐关节读回全部参数 (N 次往返)。 + /// + /// 返回 vector —— 不是 vector: 它是 N 次往返的聚合 (N 帧、N 个 + /// 到达时刻), 一个 hz/timestamp 描述不了它; 要单帧的信封请调 get_joint_param(i)。 + std::vector all_joint_params(); + + /// 恢复出厂默认 + 失效 flash (固件 0x36)。 + /// + /// 固件要求失能态 (擦写窗口 CPU 停顿, 电机不能在无监督下保持使能), 已武装时回 + /// ERR{0x36,0x04}; 本方法不代劳 disable(), 以免替调用方做安全决策。 + void reset_factory(); + +private: + int check_idx(int idx) const; + Arm* arm_; +}; + +} // namespace litearm diff --git a/include/litearm/protocol.hpp b/include/litearm/protocol.hpp index edb7305..fe1f727 100644 --- a/include/litearm/protocol.hpp +++ b/include/litearm/protocol.hpp @@ -1,33 +1,324 @@ +// 帧协议层 —— 与 litearm-stm32 固件 / tools/arm_console.py 逐字节一致。 +// +// 帧: SOF(0xA5) CMD(1B) LEN(1B) PAYLOAD(0..255) CRC16_LO CRC16_HI +// CRC16-CCITT-FALSE, 覆盖 [SOF..PAYLOAD]。 +// 上行 100Hz 状态帧: 6+21N (固件 >=1.5.0, 尾部 joint_fault u16) 或 4+21N +// (<=1.4.x, 仅兼容解析); 应答/异步返回穿插。 #pragma once -/** - * @file protocol.hpp - * @brief Zenoh topic naming conventions for litearm v4 protocol. - */ +#include +#include +#include +#include +#include #include +#include +#include namespace litearm { +namespace proto { -inline constexpr int PROTOCOL_VERSION = 1; +// ---------------------------------------------------------------- 帧 +inline constexpr uint8_t SOF = 0xA5; -/// RPC request/reply topic for the given arm. -inline std::string rpc_topic(const std::string& arm_id) { - return "litearm/v4/" + arm_id + "/rpc"; +// ---- 下行 CMD (固件 hal/usb_cmd.h) ---- +inline constexpr uint8_t CMD_MOVE_J = 0x01; +inline constexpr uint8_t CMD_MOVE_P = 0x02; +inline constexpr uint8_t CMD_MOVE_JS = 0x03; +inline constexpr uint8_t CMD_MOVE_MIT = 0x04; +inline constexpr uint8_t CMD_MOVE_MIT_ALL = 0x05; +// 零重力(拖动示教) 进入/退出: payload [u8] 1=进 0=退 -> ACK +// 固件侧自带 watchdog_kick, 重发即保活 —— 上位机需周期重发(建议 <=50ms), +// 否则 0.1s 后看门狗把模式掐回 fail-soft 持位。 +inline constexpr uint8_t CMD_ZERO_G = 0x06; +// [SYNC] 同步 PTP movej: 全关节同时到达, 末端沿关节空间直线 q(s) = q0 + s*dq。 +// 载荷与 CMD_MOVE_J 逐字节相同; 语义差异只在轨迹形状。 +inline constexpr uint8_t CMD_MOVE_J_SYNC = 0x07; +inline constexpr uint8_t CMD_ENABLE = 0x10; +inline constexpr uint8_t CMD_DISABLE = 0x11; +inline constexpr uint8_t CMD_EMERGENCY_STOP = 0x12; +inline constexpr uint8_t CMD_CLEAR_FAULTS = 0x13; +inline constexpr uint8_t CMD_RESET = 0x14; +// [DFU] 免探针进 ROM 系统 bootloader (终端态操作): 空载荷 -> ACK 表示"已登记"。 +inline constexpr uint8_t CMD_ENTER_DFU = 0x15; +inline constexpr uint8_t CMD_SET_MOTION_MODE = 0x20; +inline constexpr uint8_t CMD_SET_SPEED_PERCENT = 0x21; +inline constexpr uint8_t CMD_SET_JOINT_PARAM = 0x22; +inline constexpr uint8_t CMD_SET_JOINT_LIMITS = 0x23; +inline constexpr uint8_t CMD_GET_JOINT_PARAM = 0x24; +inline constexpr uint8_t CMD_PARAM_SAVE = 0x25; +// [P1-C] 恢复出厂默认 + 失效 flash (空载荷; 须失能, 否则 ERR{0x36,0x04}) +inline constexpr uint8_t CMD_PARAM_RESET = 0x36; +// [HOME] 回零位舒展姿 (空载荷): 目标=URDF 零位 0, 固件侧速度写死 0.10。 +inline constexpr uint8_t CMD_HOME = 0x2A; +// item(1B)+f32[7]: 1 friction/2 ki/3 i_max/4-6 wall/7 gs/8 is +// 9-11 摩擦 v2 (fv/fc0/fc1) / 12-14 零重力 (zg_kp/zg_kd/zg_damping) / 15 kd_extra +// 12 zg_kp 松手保持的虚拟弹簧刚度 Nm/rad 钳 [0,500] +// 13 zg_kd MIT 速度阻尼 (固件会抬到下限以上) 钳 [0,5] +// 14 zg_damping 附加速度阻尼 Nm.s/rad, 出厂 0 钳 [0,50] +// 15 kd_extra tau 域软件微分阻尼, 出厂 [6,6,6,6,0,0,0], 腕部 J5-J7 必须留 0。 +// 任一分量 NaN 整组拒绝 (ERR{0x26,0x02}), 幅值超限静默钳制。 +inline constexpr uint8_t CMD_SET_FF_VEC = 0x26; +inline constexpr uint8_t CMD_SET_FF_FLAGS = 0x27; // u32 ff_mask LE +// item+sub+f32: 1 fric_db/2 margin/3 slew/4 payload_mass/5 payload_com/6 gravity +// 7 friction_model (0/1/2) / 8 fric_v2_eps +// 10-17 零重力 (drag_gain/drag_db/drag_kd_margin/vel_thr/ +// engage_sec/engage_kp/engage_kd/wall_fw_kd) +// 18 hold_kp_gain. item 9 保留给 0x2C 的 ff_mask 读回 +inline constexpr uint8_t CMD_SET_FF_SCALAR = 0x28; +inline constexpr uint8_t CMD_FF_PRESET = 0x31; // u8: 0 全关 / 1 出厂 / 2 全开 +// [readback 0x2B/0x2C] 参数读回 (镜像写口, 纯读) +inline constexpr uint8_t CMD_GET_FF_VEC = 0x2B; // item(1B) -> RSP_FF_VEC +inline constexpr uint8_t CMD_GET_FF_SCALAR = 0x2C; // item+sub -> RSP_FF_SCALAR +// [P1 傅里叶辨识采集] 300Hz 控制拍记录/读回 (固件 hal/log_capture.*) +inline constexpr uint8_t CMD_LOG_CTRL = 0x2D; // u32 n_ticks LE (0=停/清) +inline constexpr uint8_t CMD_SET_MODEL_PARAM = 0x30; // body_idx(1B)+f32[10] -> staging +inline constexpr uint8_t CMD_MODEL_COMMIT = 0x32; // u16 expected_mask LE +inline constexpr uint8_t CMD_SET_MODEL_JM = 0x33; // f32[7] -> staging 的 jm +inline constexpr uint8_t CMD_GET_MODEL_PARAM = 0x34; // body_idx(1B) -> RSP_MODEL_PARAM +inline constexpr uint8_t CMD_GET_MODEL_JM = 0x35; // (空) -> RSP_MODEL_JM +inline constexpr uint8_t CMD_REVERT_MODEL = 0x37; // (空) -> ACK +inline constexpr uint8_t CMD_GET_MODEL_STATUS = 0x38; // (空) -> RSP_MODEL_STATUS +inline constexpr uint8_t CMD_GET_GRAVITY = 0x39; // q[7]f32 -> RSP_GRAVITY +inline constexpr uint8_t CMD_LOG_READ = 0x2E; // u32 offset_byte LE -> RSP_LOG_DATA +// 双 ID: 下行 CMD_KIN_BENCH 与上行 RSP_JOINT_PARAM 同为 0x49 (固件 usb_cmd.h)。 +// 收发方向不同, 故靠方向区分, 不可用单张 ID 表查。 +inline constexpr uint8_t CMD_KIN_BENCH = 0x49; +inline constexpr uint8_t CMD_GET_STATUS = 0x40; +inline constexpr uint8_t CMD_GET_FIRMWARE = 0x41; +inline constexpr uint8_t CMD_GET_IK = 0x42; +inline constexpr uint8_t CMD_GET_TCP = 0x43; + +// ---- [授权/激活] 固件 license.c + usb_cmd.c 的 license 分支 (固件 1.8.0+) ---- +// 本层只做"查询 + 提交凭据", 不含任何算 MAC 的代码 —— 这是规格的硬要求。 +inline constexpr uint8_t CMD_GET_LICENSE = 0x2F; // 无载荷 -> RSP_LICENSE(0x4F) 26B +inline constexpr uint8_t CMD_ACTIVATE = 0x3F; // 28B -> ACK{0x3F} / ERR{0x3F,code} +// 28B = cust_id u32 LE + issued u32 LE + flags u32 LE + mac[16] +// 长度判据是 len < 28 => 更长的载荷会被接受, 尾部多余字节不进 MAC。 + +// ---- [笛卡尔运控] 固件原生规划 (受固件 #if LITEARM_CART_PLAN 编译开关约束) ---- +// 关掉开关时这 5 条整段不在固件里, 会落到 usb_cmd.c 的 default 分支 -> +// ERR{cmd,0x00}。故 SDK 必须探测而不是假定 (见 cart::probe)。 +// 0x3A/0x3B/0x3C/0x3D 的长度校验排在门禁与一切副作用之前, 所以"发空载荷"不会让臂动 +// —— 这四条可以当能力探针。0x3E (CART_RUN) 是例外: 空载荷就是它的合法载荷, 且 +// cart_gate_ok 的门禁有实打实的副作用, 别拿它当探针。 +inline constexpr uint8_t CMD_MOVE_L = 0x3A; // pose[6]f32 + sp f32 (28B) +inline constexpr uint8_t CMD_MOVE_C = 0x3B; // via[6] + end[6] + sp (52B) +inline constexpr uint8_t CMD_CART_BEGIN = 0x3C; // n u8 + sp f32 (5B, n in [1,32]) +inline constexpr uint8_t CMD_CART_ADD = 0x3D; // idx u8 + pose[6] (25B) +inline constexpr uint8_t CMD_CART_RUN = 0x3E; // 空载荷: 提交并开始规划 + +// ---- 上行 ---- +inline constexpr uint8_t RSP_STATUS = 0x40; +// 死登记 (固件仍定义 RSP_DETAIL 0x41 但发送方已删; 且与下行 CMD_GET_FIRMWARE 撞号)。 +// 保留定义是为了让协议同步测试的按名比对自然通过 —— 不要给它造生产者。 +inline constexpr uint8_t RSP_DETAIL = 0x41; +inline constexpr uint8_t RSP_FIRMWARE = 0x44; +inline constexpr uint8_t RSP_ACK = 0x45; +inline constexpr uint8_t RSP_ERR = 0x46; +inline constexpr uint8_t RSP_IK = 0x47; +inline constexpr uint8_t RSP_TCP = 0x48; +inline constexpr uint8_t RSP_JOINT_PARAM = 0x49; +inline constexpr uint8_t RSP_KIN_BENCH = 0x4A; // CMD_KIN_BENCH 应答: 自检文本 +inline constexpr uint8_t RSP_FF_VEC = 0x4B; // 载荷首字节冗余回填本 id +inline constexpr uint8_t RSP_FF_SCALAR = 0x4C; // 同上 +inline constexpr uint8_t RSP_LOG_DATA = 0x4D; // [u32 total][u32 next][u8 n][n bytes] +// [笛卡尔] 规划结果: ok u8 + err u8 + n_wp u16 LE + plan_us u32 LE = 8B。 +// 载荷里没有命令 id —— 只能按受理顺序 FIFO 配对。规划失败也必须发这一条。 +inline constexpr uint8_t RSP_CART_PLAN = 0x4E; +// [授权] CMD_GET_LICENSE(0x2F) 的应答 —— 26B: +// [state u8][ver u8][uid 12B][cust_id u32 LE][issued u32 LE][flags u32 LE] +// 载荷首字节是 state(0/1/2), 不是命令号 => 它不在"回显归属"那一类。 +// 未激活时 cust_id/issued/flags 全 0, 但 UID 照回。 +inline constexpr uint8_t RSP_LICENSE = 0x4F; +// [授权] 本版无生产者 —— 激活成功回的是 ACK{0x3F}, 故固件里没有任何代码会发出 0x50。 +inline constexpr uint8_t RSP_ACTIVATE = 0x50; +inline constexpr uint8_t RSP_MODEL_PARAM = 0x54; // [0x54, body_idx, f32[10]] = 42B +inline constexpr uint8_t RSP_MODEL_JM = 0x55; // [0x55, f32[7]] = 29B +inline constexpr uint8_t RSP_MODEL_STATUS = 0x56; // [0x56, override, mask u16 LE, dirty] +inline constexpr uint8_t RSP_GRAVITY = 0x57; // [0x57, G(q)[7]] = 29B + +// ---- ff_mask 位 (固件 litearm.h) ---- +inline constexpr uint32_t FF_MASTER = 0x0001; +inline constexpr uint32_t FF_G = 0x0002; +inline constexpr uint32_t FF_INERTIA = 0x0004; +inline constexpr uint32_t FF_CORIOLIS = 0x0008; +inline constexpr uint32_t FF_FRICTION = 0x0010; +inline constexpr uint32_t FF_INTEGRAL = 0x0020; +inline constexpr uint32_t FF_WALL = 0x0040; +inline constexpr uint32_t FF_QUANT = 0x0080; +inline constexpr uint32_t FF_VELREF = 0x0100; +inline constexpr uint32_t FF_ALL = 0x01FF; + +// 固件 IK 的 seed 恒为模型 7 轴 (KIN_N), 与 LITEARM_NUM_JOINTS 解耦。 +inline constexpr int KIN_N = 7; +// 台架电机对应的整臂模型轴 (joint_cfg.h 的 LITEARM_BENCH_MODEL_AXIS, 台架=5)。 +inline constexpr int BENCH_MODEL_AXIS = 5; + +inline constexpr int MODE_ZERO_G = 7; +// flags bit9 = 使能位 (固件 1.5.0 起; 低 6 位才是安全 flag, 6..8 是 mode) +inline constexpr int FLAG_ENABLED_BIT = 9; +// flags bit10 = 笛卡尔规划/播放进行中 (刻意不放进 FLAG_NAMES, 见 state.hpp) +inline constexpr int FLAG_CART_BUSY_BIT = 10; +// joint_fault 位图 (u16) 能表达的关节数上限 +inline constexpr int MAX_JOINTS = 16; + +const char* mode_name(int mode); +const char* flag_name(int bit); + +// ---------------------------------------------------------------- 帧编解码 + +/// **CRC-16/CCITT-FALSE** (poly 0x1021 / init 0xFFFF / 无末异或)。 +/// 覆盖 [SOF..PAYLOAD], 两字节低字节在前 (见 pack_frame)。 +uint16_t crc16_ccitt_false(const uint8_t* data, size_t len); +inline uint16_t crc16_ccitt_false(const std::vector& v) { + return crc16_ccitt_false(v.data(), v.size()); +} + +/// 组一帧: SOF CMD LEN PAYLOAD CRC_LO CRC_HI。 +std::vector pack_frame(uint8_t cmd, const uint8_t* payload, size_t len); +inline std::vector pack_frame(uint8_t cmd) { return pack_frame(cmd, nullptr, 0); } +inline std::vector pack_frame(uint8_t cmd, const std::vector& p) { + return pack_frame(cmd, p.data(), p.size()); +} + +/// 一条解出来的帧。 +struct Frame { + uint8_t cmd = 0; + std::vector payload; +}; + +/// 校验完整帧; 合法返回帧, 否则 nullopt。 +std::optional unpack_frame(const uint8_t* frame, size_t len); +inline std::optional unpack_frame(const std::vector& f) { + return unpack_frame(f.data(), f.size()); } -/// State broadcast topic for the given arm. -inline std::string state_topic(const std::string& arm_id) { - return "litearm/v4/" + arm_id + "/state"; +// ---------------------------------------------------------------- f32 载荷 + +std::vector pack_f32s(const double* values, size_t count); +inline std::vector pack_f32s(const std::vector& v) { + return pack_f32s(v.data(), v.size()); +} +inline std::vector pack_f32s(const std::array& v) { + return pack_f32s(v.data(), v.size()); } +inline std::vector pack_f32s(const std::array& v) { + return pack_f32s(v.data(), v.size()); +} + +std::vector unpack_f32s(const uint8_t* b, size_t off, int count); -/// Command channel topic for the given arm (e.g. servo targets). -inline std::string command_topic(const std::string& arm_id) { - return "litearm/v4/" + arm_id + "/command"; +inline std::vector pack_u32le(uint32_t v) { + return {uint8_t(v & 0xFF), uint8_t((v >> 8) & 0xFF), + uint8_t((v >> 16) & 0xFF), uint8_t((v >> 24) & 0xFF)}; +} +inline std::vector pack_u16le(uint16_t v) { + return {uint8_t(v & 0xFF), uint8_t((v >> 8) & 0xFF)}; +} +inline std::vector pack_f32le(double v) { + return pack_f32s(&v, 1); } -/// High-priority emergency stop topic for the given arm. -inline std::string estop_topic(const std::string& arm_id) { - return "litearm/v4/" + arm_id + "/estop"; +uint32_t read_u32le(const uint8_t* b); +uint16_t read_u16le(const uint8_t* b); +uint32_t read_u32le(const std::vector& b, size_t off); +uint16_t read_u16le(const std::vector& b, size_t off); + +// ---------------------------------------------------------------- 固件版本 + +struct FirmwareVersion { + int major = 0; + int minor = 0; + int patch = 0; + std::string variant; // "7J" / "1J" / ... + bool operator<(const FirmwareVersion& o) const { + if (major != o.major) return major < o.major; + if (minor != o.minor) return minor < o.minor; + return patch < o.patch; + } + bool operator<=(const FirmwareVersion& o) const { return !(o < *this); } + bool operator==(const FirmwareVersion& o) const { + return major == o.major && minor == o.minor && patch == o.patch; + } +}; + +/// 'Litearm1.5.2-7J' -> {1,5,2,"7J"}; 旧 'A1.x-...-USB' 返回 nullopt(不符合新约定)。 +std::optional parse_firmware_version(const std::string& ver); + +/// 从串口噪声文本里解析开机签名 -> "normal" | "iwdg-rst"; 没有则 nullopt。 +/// 固件唯一能区分「独立看门狗复位过」的地方。重连/多次枚举会重复出现, 取最后一次。 +std::optional parse_boot_banner(const std::string& text); + +// ---------------------------------------------------------------- 状态帧 + +/// 一个关节的一拍。err 是 payload 尾部那个字节。 +struct JointRaw { + double q = 0.0, dq = 0.0, tau = 0.0, t_mos = 0.0, t_coil = 0.0; + uint8_t err = 0; +}; + +/// 状态帧解析结果。 +struct StatusDecoded { + uint16_t flags = 0; + uint16_t seq = 0; + int mode = 0; + std::vector flag_names; + std::vector joints; + uint16_t joint_fault = 0; +}; + +/// 状态帧 -> StatusDecoded; 非法返回 nullopt。 +/// 兼容两种布局 (尾部 u16 joint_fault 为固件 1.5.0 起新增): +/// 4+21N 固件 <=1.4.x (无 joint_fault, 视为 0) +/// 6+21N 固件 >=1.5.0 (7J=153B / 1J=27B) +std::optional decode_status(const uint8_t* payload, size_t len); +inline std::optional decode_status(const std::vector& p) { + return decode_status(p.data(), p.size()); } -} // namespace litearm +// ---------------------------------------------------------------- 命令覆盖契约 +// +// 固件每条已实现的下行命令 -> SDK 上的一等方法入口。由测试强制 (见 +// tests/test_protocol_sync.cpp 对 _protocol.py 的 COMMAND_COVERAGE 逐条断言)。 +struct CommandCoverage { + uint8_t cmd; + const char* entry; +}; +const std::vector& command_coverage(); + +/// 请求 -> 它的那条 RSP_* 应答 (只列"应答不是 ACK/ERR"的命令)。 +/// 唯一用途是 Arm::raw_write 在发出前清掉本命令的应答队列。 +/// 0x40 (GET_STATUS) 不在: 它的应答走单槽不是队列。 +const std::vector>& rsp_of_cmd(); +std::optional rsp_of(uint8_t cmd); + +/// 回显命令码当归属判据的上行 id —— 只有这两类帧的 payload[0] 是"原命令码"。 +inline bool is_echoed(uint8_t c) { return c == RSP_ACK || c == RSP_ERR; } + +// ---------------------------------------------------------------- 同步哨兵表 +// +// 上游 (`_protocol.py`) 用四张表把"SDK 与固件的覆盖关系"变成可断言的东西 —— +// `tests/test_protocol_sync.py` 直接解析**固件头文件** (hal/usb_cmd.h), 双向比对名字与 +// ID, 并逐条核对这四张表**仍然成立**。它们的语义各不相同, 别混: +// +// * `unimplemented_cmds()` —— 固件注释即「未实现」, SDK 有意不提供入口。 +// 不是永久豁免: 一旦固件实现了它们, 那条测试立刻红, 提醒补入口。 +// * `firmware_only_cmds()` / `firmware_only_rsps()` —— 用户裁决 SDK **有意不暴露** +// ("不做", 不是"还没做")。往这里加条目等于宣布"SDK 有意不做这条命令"。 +// * `preexisting_gaps()` —— **已知缺口** (本应同步而未同步), 与"有意不做"不同。 +// +// 四张表**当前都是空的**。留空是**有意的**: 它们现在是"当前没有未实现命令 / 没有固件单边 +// 命令 / 没有已知缺口"这句话的**断言载体**, 而不是可以顺手塞东西的地方。 +// +// ⚠ **本仓跑不了那个比对**: `test_protocol_sync.py` 要读固件仓 (`LITEARM_FW_DIR`) 的 +// `hal/usb_cmd.h`, 而 C++ 仓里没有固件树。故这里保留的是**表本身与它们的语义** +// (由 `tests/test_protocol.cpp` 断言为空), 固件侧那一半需要把固件仓放到旁边才能跑。 +// 别把"表是空的"读成"已经跟固件对过账"。 +const std::map& unimplemented_cmds(); +const std::map& firmware_only_cmds(); +const std::map& firmware_only_rsps(); +const std::map& preexisting_gaps(); + +} // namespace proto +} // namespace litearm diff --git a/include/litearm/rot.hpp b/include/litearm/rot.hpp new file mode 100644 index 0000000..a8956ca --- /dev/null +++ b/include/litearm/rot.hpp @@ -0,0 +1,158 @@ +// 旋转/位姿小工具 —— 纯数学, 无外部依赖。 +// +// 约定与固件 kin.c 完全一致 (ZYX 内旋)。三条互不相同的表示在本包来回转: +// +// 6 向量 (x, y, z, roll, pitch, yaw) 固件 / 本包 get_tcp / move_p +// 位姿对 (position[3], rotation[3x3]) pylitearm 的 movel/movec/movep +// 旋转矩阵 R[3][3] 行主序 内部统一用这个算 +// +// as_pose() 是唯一的入口, 上面前两种都吃。 +#pragma once + +#include +#include +#include +#include +#include + +namespace litearm { +namespace rot { + +using Vec3 = std::array; +using Mat3 = std::array, 3>; +using Mat4 = std::array, 4>; +/// 归一化后的位姿: (位置[3], 旋转矩阵[3x3]) +using Pose = std::pair; + +inline constexpr double kEps = 1e-12; + +Mat3 eye3(); +Mat3 mat_mul(const Mat3& A, const Mat3& B); +Mat3 mat_T(const Mat3& A); +Vec3 mat_vec(const Mat3& A, const Vec3& v); +double dot(const Vec3& a, const Vec3& b); +Vec3 cross(const Vec3& a, const Vec3& b); +double norm(const Vec3& v); +Vec3 sub(const Vec3& a, const Vec3& b); +/// 零向量抛 InvalidCommandError。 +Vec3 unit(const Vec3& v); +double det3(const Mat3& R); + +// RPY <-> 旋转矩阵 (ZYX 内旋, 与固件 kin_rpy_to_rot / kin_rot_to_rpy 同约定)。 +// +// rpy_to_mat: (roll, pitch, yaw) -> R, 即 R = Rz(yaw) * Ry(pitch) * Rx(roll)。 +// 与固件逐元素同式 —— 别再自己推一遍, 推错方向不会报错, 只会让所有姿态静默反着转。 +Mat3 rpy_to_mat(const Vec3& rpy); +/// R -> (roll, pitch, yaw)。万向锁 (|pitch| ~= pi/2) 时强制 yaw=0 —— 与固件 +/// kin_rot_to_rpy 的锁分支同约定。只保证"锁上强制 yaw=0"这条约定一致 —— 两侧的锁带 +/// 宽度不同: 本函数用 |pitch| 距 pi/2 小于 1e-6, 固件用 |cos(pitch)| <= 1e-4。 +Vec3 mat_to_rpy(const Mat3& R); + +// SO(3) 上的指数/对数 —— slerp 与姿态误差的基础。 +/// R -> 旋转向量 (轴 x 角)。acos 的入参必须夹住: 数值噪声会让它越界到 +-(1+eps)。 +Vec3 rot_log(const Mat3& R); +/// 旋转向量 -> R (Rodrigues)。 +Mat3 rot_exp(const Vec3& w); +/// 姿态球面插值 R0 * exp(t * log(R0^T R1)) —— 走最短弧。 +Mat3 rot_slerp(const Mat3& R0, const Mat3& R1, double t); +/// 旋转矩阵对应的转角 (rad)。 +double rot_angle(const Mat3& R); + +/// (位置误差 m, 姿态误差 rad)。 +/// +/// 姿态差必须是 rot_log(R_d * R^T), 不能写成 rot_log(R * R_d^T) —— 后者是复合不是差, +/// 符号相反; 曾因此把 0.59mm 位置误差的位姿报成 153 度姿态误差。 +std::pair pose_err(const Vec3& pos, const Mat3& R, const Vec3& pos_d, + const Mat3& R_d); + +/// 是不是合法的 SO(3) 成员 (正交 + det ~= +1)。 +bool is_rotation(const Mat3& R, double atol = 1e-5); + +// ---------------------------------------------------------------- 位姿归一化 + +/// 位姿入参的三种可接受写法 —— 这是 C++ 侧对 Python 动态形态判定的等价表达。 +struct PoseInput { + enum class Kind { Pose6, PosRot, Homogeneous }; + Kind kind = Kind::Pose6; + Vec3 pos{}; + Mat3 R{}; // PosRot + Vec3 rpy{}; // Pose6 + + PoseInput() = default; + + //: 隐式转换 —— 让 `arm.move_p({0.3, 0, 0.4, 3.14, 0, 0})` 这类最常见写法直接可用。 + //: (pos[3], R[3x3]) 与 4x4 两种写法用下面那两个具名工厂 —— 它们**不**隐式转换是 + //: 刻意的: 一个 3 元素 vector 到底是位置还是别的, 靠隐式转换猜不出来。 + PoseInput(const std::array& v) { *this = from_vec6(v); } // NOLINT + PoseInput(const std::vector& v) { *this = from_vec6(v); } // NOLINT + /// 花括号字面量 —— 让 `arm.move_p({0.3, 0, 0.4, 3.14, 0, 0})` 直接可用 + /// (有它时, 花括号列表会优先选这一条, 于是不会与上面两条撞成二义)。 + PoseInput(std::initializer_list v) { // NOLINT + *this = from_vec6(std::vector(v)); + } + PoseInput(const Mat4& M) { *this = from_homogeneous(M); } // NOLINT + PoseInput(const Vec3& pos_in, const Mat3& R_in) { // NOLINT + *this = from_pos_rot(pos_in, R_in); + } + + // 形式 1: [x, y, z, roll, pitch, yaw] + static PoseInput from_vec6(const Vec3& xyz, const Vec3& rpy); + static PoseInput from_vec6(const std::array& v); + static PoseInput from_vec6(const std::vector& v); + // 形式 2: (position[3], R[3x3]) + static PoseInput from_pos_rot(const Vec3& pos, const Mat3& R); + // 形式 3: 4x4 齐次矩阵 + static PoseInput from_homogeneous(const Mat4& M); +}; + +/// 把调用方给的东西归一化成 (position[3], R[3x3])。 +/// 不合法的形态抛 InvalidCommandError, 文案里给出实际收到的形状。 +Pose as_pose(const PoseInput& in); + +/// 便捷: 任何写法 -> 归一化位姿。 +inline Pose as_pose(const std::array& v) { + return as_pose(PoseInput::from_vec6(v)); +} +/// std::vector 写法 —— 长度必须恰好 6, 否则抛 InvalidCommandError。 +/// (C++ 里 6 向量最常见的载体就是 vector, 收它免得调用方自己转 array。) +inline Pose as_pose(const std::vector& v) { + return as_pose(PoseInput::from_vec6(v)); +} +inline Pose as_pose(const Vec3& xyz, const Vec3& rpy) { + return as_pose(PoseInput::from_vec6(xyz, rpy)); +} +inline Pose as_pose(const Vec3& pos, const Mat3& R) { + return as_pose(PoseInput::from_pos_rot(pos, R)); +} +inline Pose as_pose(const Mat4& M) { return as_pose(PoseInput::from_homogeneous(M)); } + +/// 任意写法 -> 固件要的 [x,y,z,roll,pitch,yaw]。 +std::array as_pose6(const PoseInput& in); +inline std::array as_pose6(const std::array& v) { + return as_pose6(PoseInput::from_vec6(v)); +} +inline std::array as_pose6(const std::vector& v) { + return as_pose6(PoseInput::from_vec6(v)); +} +inline std::array as_pose6(const Vec3& pos, const Mat3& R) { + return as_pose6(PoseInput::from_pos_rot(pos, R)); +} +inline std::array as_pose6(const Vec3& xyz, const Vec3& rpy) { + return as_pose6(PoseInput::from_vec6(xyz, rpy)); +} +inline std::array as_pose6(const Mat4& M) { + return as_pose6(PoseInput::from_homogeneous(M)); +} + +/// 便捷: 6 向量 -> 9 元组行主序旋转矩阵 (与固件同式)。 +inline std::array rpy_to_rowmajor(const Vec3& rpy) { + const Mat3 m = rpy_to_mat(rpy); + return {m[0][0], m[0][1], m[0][2], m[1][0], m[1][1], + m[1][2], m[2][0], m[2][1], m[2][2]}; +} + +/// 两组 rpy 所代表旋转之间的测地夹角 (rad)。trace(Ra^T * Rb) 求夹角。 +double orient_angle(const Vec3& a, const Vec3& b); + +} // namespace rot +} // namespace litearm diff --git a/include/litearm/state.hpp b/include/litearm/state.hpp new file mode 100644 index 0000000..7df5334 --- /dev/null +++ b/include/litearm/state.hpp @@ -0,0 +1,88 @@ +// 运行状态 —— 状态帧 (G8) 的面向对象视图。 +#pragma once + +#include +#include +#include + +#include "litearm/protocol.hpp" + +namespace litearm { + +/// 一个关节的一拍。 +struct JointState { + double q = 0.0; + double dq = 0.0; + double tau = 0.0; + double t_mos = 0.0; + double t_coil = 0.0; + uint8_t err = 0; +}; + +/// 一帧完整的状态。 +struct RobotState { + int mode = 0; + std::string mode_name = "INIT"; + uint16_t flags = 0; + std::vector flag_names; + uint16_t seq = 0; + std::vector joints; + uint16_t joint_fault = 0; // 固件 G7 断轴位图 (1.5.0 起上报; 旧布局恒 0) + + int n() const { return int(joints.size()); } + + /// flags bit9 (固件 1.5.0 起上报; 旧固件恒 false, 不可据此判失能)。 + bool enabled() const { return (flags & (1u << proto::FLAG_ENABLED_BIT)) != 0; } + + /// 固件 flags bit10 = 笛卡尔规划/播放进行中。 + /// + /// 刻意不放进 flag_names —— 真正的护栏是 decode_status 里那个隐式的 range(6): + /// flag_names 只会去看 bit0..bit5, 于是"往 FLAG_NAMES 加 CART_BUSY 什么都不会发生"。 + /// 但"现在无事发生" = 一条防线写在别人的隐式常量里 —— 一旦有人把那个 6 改大, + /// bit6..bit10 (mode 三位 + enabled + cart_busy) 会一起变成"故障位"。 + bool cart_busy() const { return (flags & (1u << proto::FLAG_CART_BUSY_BIT)) != 0; } + + /// 固件已断轴(失能)的关节号, 0 基 (如 {1, 3} = J2/J4)。 + std::vector fault_axes() const; + + std::vector q() const; + std::vector dq() const; + std::vector tau() const; + + /// 推断值, 不是读到的位 —— 掉线刚性持位锁存 (固件 g_arm.drop_hold) 是否可能在生效。 + /// + /// 为什么只能是推断: 固件不上报这个量 —— 状态帧里没有它的位, 它也没有自己的 + /// ARM_FLAG_*, 固件里 drop_hold 只出现在门禁处, 没有任何上报路径。SDK 侧唯一能拿到的 + /// 是固件自己写下的一条单向蕴含: + /// "ENABLE 门禁无需另设 —— drop_hold 为真时 joint_fault 必非 0" + /// => 逆否: joint_fault == 0 蕴含 drop_hold == false。反向不成立。所以: + /// * 本属性为 false => 确定没有该锁存; + /// * 本属性为 true => 可能在锁存, 也可能只是一次"只降级单轴"的故障 —— + /// 温度 / 跟随误差 / 限位 / 超速那几类按用户裁决只锁该轴、不置 drop_hold。 + /// + /// 要确证 drop_hold, 用 0x06 错误码 —— 会带 drop_hold 门禁的那些命令 + /// (0x01/0x03/0x04/0x05/0x07/0x2A 与笛卡尔 5 条 0x3A~0x3E) 抛出的 + /// CommandRejectedError::code == 0x06 就是当拍 drop_hold 为真。 + /// + /// 名字带 _inferred 是刻意的: 一个叫 drop_hold 的布尔会让人以为它来自状态帧, + /// 从而写出"它一变就能自恢复"的代码 —— 而固件那边的真实语义恰恰是"必须锁存, + /// 不能随标志位自恢复"。清它的唯一途径是 reset() / clear_faults()。 + bool drop_hold_inferred() const { return joint_fault != 0; } + + /// FAULT 位 / EMERGENCY / 任一轴被固件断轴 (G7)。 + /// + /// 固件单轴锁存 (断轴/越限/超速/温度) 不一定置全局 FAULT 位, 只置对应的 flags 位并 + /// 把该轴记进 joint_fault —— 若不看 joint_fault, movej 不会早失败, 只会耗满超时后 + /// 报"未到位"。 + bool faulted() const { + return (flags & 1u) != 0 || mode == 6 || joint_fault != 0; + } + + /// 人类可读的故障描述 (安全 flags + 断轴号)。 + std::string fault_detail() const; +}; + +/// 状态帧 -> RobotState。非法帧抛 TransportError。 +RobotState decode_state(const std::vector& payload); + +} // namespace litearm diff --git a/include/litearm/testing.hpp b/include/litearm/testing.hpp new file mode 100644 index 0000000..a763b7c --- /dev/null +++ b/include/litearm/testing.hpp @@ -0,0 +1,308 @@ +// 脚本化应答的假传输 —— 让 Arm 全流程可离线测试 (镜像 testing.py 的桩硬件风格)。 +// +// 按收到的下行命令推送预设帧 (ACK/状态/RSP_TCP/RSP_IK), 实现 Transport 接口, 供测试注入 +// ArmOptions::transport_factory 用。 +// +// ⚠ 桩的每个字段都是"为了复现固件的某个具体形状"而存在的, 别按"看起来用不到"删掉 —— +// 每一条都至少钉着一个判据 (见 src/testing.cpp 里逐条的注释)。 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "litearm/clock.hpp" +#include "litearm/protocol.hpp" +#include "litearm/transport.hpp" + +namespace litearm { +namespace testing { + +/// 可**手工推进**的假钟。 +/// +/// ⚠⚠ 用法有一条硬前提, 先读 `clock.hpp` 开头那段: 本仓的时钟只管"现在几点", +/// **不管怎么等** ⇒ 假钟**不会自己前进**, 于是凡"等超时真的发生"的判据会**挂死** +/// (不是失败)。它只该用在**纯比较**类判据上: 到期比较、时间戳、TTL 水位。 +/// +/// 能用的典型场合 —— 而且这是它最大的价值: **"多久没收到帧算失联"** 这类判据 +/// 本来要真睡满 `STATUS_STALE_MAX_S` (2.0s) 才验得到, 用本类 `advance()` 是**瞬间**的。 +class FakeClock : public Clock { +public: + /// 起始值**刻意非 0**。 + /// + /// ⚠ 本仓多处用 `timestamp <= 0.0` 表示"**从没收到过**"(如 `Arm::is_connected` 里 + /// `recv_stats(...).second <= 0.0` 那一句)。假钟若从 0 起, 那类判据会在 + /// "明明收到过"时判假 —— 于是用例测的是一个**现实中不存在**的状态。 + static constexpr double kStartS = 1000.0; + + static std::shared_ptr make(); + + double now_s() const override; + /// 手工推进 `s` 秒。⚠ 契约只要求"单调不减", **推进是用例的责任** —— + /// 本类不会因为有人等它就自己走。 + void advance(double s); + +private: + mutable std::mutex mu_; + double now_ = kStartS; +}; + +} // namespace testing +} // namespace litearm + +namespace litearm { +namespace testing { + +/// 固件 usb_cmd.c 对 5 条笛卡尔命令的最小载荷长度校验 (0x3A 28 / 0x3B 52 / 0x3C 5 / +/// 0x3D 25 / 0x3E 0)。固件把长度校验排在门禁与一切副作用之前, 桩照同一顺序 —— +/// 于是空载荷只会得到 ERR{cmd,0x01} 而不会让臂动, SDK 的能力探测正是靠这一点。 +extern const std::map CART_MIN_LEN; + +/// KIN_BENCH 回文本 —— 逐字节镜像固件 kin_runner.c 的组装结果, 不是"看着像"。 +/// 关键在 u32_cat() 的语义: 数字先写 digits 再补一个尾随空格, 而 txt() 原样拼接 (不加 +/// 分隔符)。于是: +/// FK + 200 + 108 + 240 -> "FK200 108 240 " +/// LOOP1 + 4000 -> "LOOP14000 " <- 名字与数字相连! +/// 最后一行 LINK 是拿到 CRC 坏帧 / 应答 FIFO 丢弃 / CAN TX 失败分类桶等诊断计数的唯一通道。 +extern const char* const KIN_BENCH_TEXT; + +/// 把 KIN_BENCH 全文按真机那样拆帧 (第 1 帧耗时 / 第 2 帧 LINK 行)。 +/// 不含 LINK 的文本仍回一帧 —— 空文本要走 parse_kin_bench 的"空回执 = 异常"判据。 +std::vector split_kin_bench(const std::string& text, bool one_frame = false); + +/// 造 n_ticks 拍固件 log_sample_t 的字节流: u32 tick + q_ref[N] + dq[N] + tau[N]。 +std::vector make_log(int n_ticks, int n); + +/// 造一帧**固件 1.5.x 真实布局** (6+21N) 的状态帧 —— 尾部带 joint_fault u16。 +std::vector make_status(const std::vector& q, + const std::vector& dq, int mode, uint16_t flags, + uint16_t seq, uint16_t joint_fault, int n); + +class FakeTransport : public Transport { +public: + explicit FakeTransport(const std::string& port = "fake", double timeout = 0.2, + const std::string& fw = "Litearm1.7.0-7J", int n = 7); + + // ---- Transport 接口 ---- + using Transport::write_frame; // 别让下面那条 override 把基类的便捷重载藏掉 + void write_frame(uint8_t cmd, const uint8_t* payload, size_t len) override; + std::optional read_frame(double timeout) override; + void close() override; + bool is_open() const override; + std::string port_name() const override { return port; } + std::string text_log() const override; + + /// 注入"噪声文本" —— 模拟真传输把**非帧字节**留下的痕迹 (固件开机横幅就在里面)。 + /// ⚠ 走本口注入 (持 `state_lock`), 别直接写 `noise_text`: 它被读线程经 `text_log()` 读。 + void set_text_log(std::string s); + /// 注入 flush (tcdrain) 失败计数 —— 模拟"写出去了但没保证出得去"。 + void set_flush_failures(uint64_t n); + + /// **注帧口**: 往响应队列里塞一帧 (不需要先有一条下行命令)。 + void push_frame(uint8_t cmd, const std::vector& payload = {}); + + /// 某命令各次下行的时刻 (与 tx_log 同下标)。 + std::vector stamps_of(uint8_t cmd) const; + + /// 下行流水账的**一致快照** (持 `tx_lock`) —— 读 `tx_log` 一律走这里。 + /// + /// ⚠⚠ 别裸遍历 `tx_log`: 零重力保活线程会**并发** `emplace_back`, 而 `std::vector` + /// 扩容会把正在被遍历的缓冲区**释放掉** —— 那是 use-after-free, 不是"读到旧值" + /// 那种无害竞争。实测: TSan 报 `data race ... tests/test_zero_g.cpp:18 in + /// count_zero_g_on` (2026-09-28), 而这类读者曾有 **10 个测试文件 / 36 处**。 + /// `stamps_of()` 一直是持锁的, 只有裸读 `tx_log` 的那些漏了 —— 照它写。 + std::vector>> tx_snapshot() const; + + /// 当前下行条数 (持 `tx_lock`)。只关心"有没有/多了几条"时用它, 比快照便宜。 + size_t tx_count() const; + + /// 固件 ctrl_is_armed() 的桩侧等价物 (= enabled || enable_pending)。 + /// 五处门禁 (0x25/0x36/0x32/0x37/0x23) 与 0x15 共用这一个判据 —— 判据只有这一份, + /// 别在任何一处退回 self.enabled。 + bool armed() const { return enabled || dfu_armed_pending; } + + // ---- 固件状态 (可注入) ---- + std::string port; + double timeout; + std::string fw; + int n; + std::map> ff_vec; // item -> 7 值 (供 0x2B 读回) + std::map, double> ff_scalar; // (item, sub) -> 值 + uint32_t ff_mask = 0; // item 9 只读回填 + /// 当前(假)关节位形 / TCP。 + /// ⚠ 这两个**只由写线程改、由读线程读** (读线程要用它们合成状态帧与 `0x48` 应答), + /// 裸写就是数据竞争 ⇒ 用 `set_q()` / `set_pose()` (持 `state_lock`)。 + std::vector q; + std::vector pose; + void set_q(std::vector v); + void set_pose(std::vector v); + /// 把**全部轴**的软限位设成 `[q_min, q_max]` (持 `state_lock`)。 + /// + /// ⚠ 必须走本口, 别直接改 `joint_params`: 那个表是**构造期**由 `jp_*` 建好的, + /// 构造之后再改 `jp_q_min` / `jp_q_max` **没有任何效果**(实测: 预检用例里 + /// 改完仍报 "越软限 [-3, 3]"); 而直接改 `joint_params` 又会与读线程的 + /// `write_frame` 抢 (它整段持 `state_lock` 读这张表)。 + /// ⚠ 也就**只能**在 `connect()`(会读一次 `0x24`) **之前**调 —— 软限缓存是连接期 + /// 建一次的, 连上之后再改桩, 宿主机那份缓存不会跟着变。 + void set_joint_limits(double q_min, double q_max); + /// 缓冲空时是否补一拍 idle 状态帧。 + /// + /// ⚠ **原子**, 理由与 `status_seq` 同款: 读线程在 `read_frame` 里读它, 而用例要从 + /// **主线程**关掉它 (用例想先把链路静下来时) —— 裸 `bool` 就是数据竞争。 + /// ⚠ 用例**别直接**用它, 用 `lt::quiesce()` (tests/test_support.hpp): "关掉被动流" + /// 只是静链路的**一半**, 另一半是"等在途帧落定", 只做一半的用例仍然是偶发红。 + std::atomic auto_status{false}; + /// 合成状态帧的最小间隔 (秒)。 + /// ⚠ 没有它, read_frame 会无限快地造状态帧 —— SDK 有读线程, 它会以桩能供上的最高速率 + /// 空转, 整套用例被拖垮。取 1ms (桩可以比真机快, 但不能无界)。 + /// ⚠ 同 `auto_status`: 读线程读、用例可能写 ⇒ 要改它之前先把它也改成原子。 + double auto_status_period = 0.001; + bool closed = false; + std::vector>> tx_log; + std::vector tx_stamps; + + // 注入: 第 N 次 CMD_ZERO_G 之后抛 TransportError (模拟 CDC 掉线) + int zg_fail_after = -1; + /// ⚠ 原子: 保活线程自己加, 用例从主线程读。 + std::atomic zg_writes{0}; + /// >0 时每次保活写 (0x06 on=1) 卡这么久 —— 模拟 CDC 写阻塞 + double zg_slow_write_s = 0.0; + /// 固件"没有实现"的命令 —— 按固件 default 分支回 ERR{cmd,0x00} + std::set unknown_cmds; + /// 强制某命令回指定错误码 (模拟 ERR{cmd,code}) + std::map err_override; + + // [2026-09-14] 动力学模型在线导入。 + // 模型恒 9 刚体 —— 与 self.n (关节数) 无关, 台架 1J 下也是 9。 + int model_nbody = 9; + std::vector> model_body; + std::vector model_jm; + std::map> model_stage_body; + std::optional> model_stage_jm; + bool model_override = false; + bool model_dirty = false; + + // 关节级参数表 (0x22/0x23 写, 0x24 读, 0x36 回出厂默认)。 + // ⚠ 原子: 读线程在 `stamp_status` 里把它盖成状态帧的 bit9, 而用例从主线程改它 + // (`off.t().enabled = true` 这种写法保持可用)。 + std::atomic enabled{false}; + + // [授权] 是否已激活。默认 true —— 否则每一条既有用例的 enable() 都会撞 + // ERR{0x10,0x08} (固件 ctrl_enable 的第一条判据), 而那些用例与授权无关。 + bool activated = true; + int license_ver = 1; + std::vector license_uid; + uint32_t license_cust_id = 0; + uint32_t license_issued = 0; + uint32_t license_flags = 0; + /// 注入: 下一次 0x3F 回 ERR{0x3F,0x02} (模拟 MAC 不符 / 密钥非法 / 写失败 —— + /// 它们全折成这一档)。桩不可能真的验 MAC —— 它没有也不需要密钥 (规格硬要求)。 + bool license_fail_next = false; + + double jp_kp = 50.0, jp_kd = 2.0, jp_tau_max = 10.0, jp_q_min = -3.0, jp_q_max = 3.0; + std::vector> joint_params; + + // 300Hz 采集 + int log_max = 2400; + std::vector log_bytes; + bool log_active = false; + /// 模拟 USB 掉帧: 下一次 CMD_LOG_READ 不回 (读者须按游标续读/重试) + bool log_read_fail_once = false; + /// CMD_LOG_READ 回一段**过短的载荷** —— 用于造"应答帧短"那条分支。 + /// ⚠ 确定注入点, 别在用例里手工 push_frame (会与读线程抢时序)。 + bool log_reply_short = false; + /// >0 时模拟固件的逐拍记录 (300Hz 语义): 启动后按"每拍 1/log_hz 秒"推进, + /// 与读回次数无关 —— 这是必须的真实模型: start() 之后立刻读回只能拿到一个前缀, + /// 等待必须靠轮询已记录量。 + double log_hz = 0.0; + double log_t0 = -1.0; + int log_target = 0; + int log_recorded_ticks = -1; + /// 模拟固件回一个不前进的游标 (next_byte == offset) —— 读者必须自己判定"无进展"并报错。 + bool log_cursor_stuck = false; + int log_read_calls = 0; + + // KIN_BENCH + std::string kin_bench_text; + /// 模拟旧固件: 只发第 1 帧 (耗时帧), 不发 LINK 诊断帧。 + bool kin_bench_one_frame = false; + + // 覆盖 GET_TCP 回读的位置/朝向 (模拟固件的 rpy 规范化 —— 含万向锁强制 yaw=0) + std::optional> pos_override; + std::optional> rpy_override; + /// 非空时 `0x43` 原样回这段载荷 (而不是打包 6 个 f32) —— 用于造"帧长不足"那条分支。 + /// ⚠ 用**桩的确定注入点**, 不要在用例里手工 push_frame: 那样造出来的帧会与读线程抢 + /// 时序 (它可能先被读进队列, 再由 `drain_for` 清掉), 用例变成偶发红。 + std::optional> tcp_payload_override; + /// `0x42` 应答里那个 ok 字节 (0 = 目标不可达/IK 失败)。 + int ik_ok = 1; + /// >= 0 时 `0x34` 回显这个 body_idx 而不是被请求的那个 —— 用于造"帧布局漂移"。 + int model_body_echo_idx = -1; + + // ---- [笛卡尔] 0x3A~0x3E + RSP_CART_PLAN ---- + /// 固件的 #if LITEARM_CART_PLAN 编译开关。false = 这 5 条整段不在固件里。 + bool cart_supported = true; + int cart_n_wp = 12; + uint32_t cart_plan_us = 4700; + /// 非空时 0x4E 回 ok=0 + err=该值 —— 造规划失败 (cart_err_t 1..6)。 + std::optional cart_err_override; + /// 交付给 SDK 的状态帧计数 (自最后一条 0x4E 起) —— 驱动 CART_BUSY (bit10)。 + /// ⚠ 读线程在 `stamp_status` 里读它 ⇒ 同样只能经下面这两个口改。 + std::optional cart_busy_seq; + void set_cart_busy(int seq); + void clear_cart_busy(); + /// **交付给 SDK 的状态帧序号** —— 每交付一帧 +1 (u16 回绕)。 + /// 到位判据的新鲜度闸靠它判"这一帧是不是命令之后生成的" => 桩必须逐帧递增。 + /// 原子: 读线程在 `stamp_status` 里递增它, 而用例可能从主线程读它对账。 + std::atomic status_seq{0}; + + // [DFU] 0x15: 只登记, 不在命令处理里跳 + bool dfu_armed_pending = false; + bool dfu_rom_table_invalid = false; + /// 登记成功后设备是否真的离开 CDC。true = 真跳转 (ACK 之后读路径抛 TransportError); + /// false = 两条静默撤销路径的形状 (设备还在 CDC 上, 而 ACK 是成功的)。 + bool dfu_vanishes = true; + bool dfu_gone = false; + /// 登记 ACK 之后、设备离开之前还会继续投递的帧。 + std::vector>> dfu_post_ack_frames; + + /// 当前(假)路径的最后一个路点 —— 0x3E (RUN) 的目标就是它。 + std::vector path_last_pose; + + /// 下行流水账的锁 (`tx_log` / `tx_stamps`)。 + mutable std::mutex tx_lock; + + /// **桩的状态锁。** + /// + /// ⚠ 这不是可选的: SDK 的读线程在**并发**调 `read_frame`, 而测试线程在调 `write_frame` + /// —— 两者都要动 `resp_` (读线程 `pop_front`、写线程 `push_back`) 与 `q`/`pose` 等字段。 + /// Python 版的桩不需要这把锁 (GIL 让单个容器操作原子), C++ 里没有它就是数据竞争: + /// 实测 (ASan) 会以 **SEGV on `resp_.pop_front()`** 的形态偶发崩在 + /// `FakeTransport::read_frame` 里。 + /// 锁的取用点: `write_frame` / `read_frame` / `push_frame`, 三者互不嵌套。 + mutable std::mutex state_lock; + +private: + int log_recorded() const; + bool axis_outside(int idx, double qmin, double qmax) const; + void cart_cmd(uint8_t cmd, const std::vector& payload); + std::vector cart_goal(uint8_t cmd, const std::vector& payload) const; + std::vector cart_plan_frame() const; + std::optional stamp_status(std::optional fr); + void push(const std::vector& frame); + + std::deque> resp_; + double auto_status_last_ = -1.0; + /// 噪声文本 (见 `set_text_log`)。读线程经 `text_log()` 读, 故写入持 `state_lock`。 + std::string noise_text; +}; + +} // namespace testing +} // namespace litearm diff --git a/include/litearm/transport.hpp b/include/litearm/transport.hpp index ed187ed..b4c1d46 100644 --- a/include/litearm/transport.hpp +++ b/include/litearm/transport.hpp @@ -1,99 +1,147 @@ +// 串口传输 —— USB CDC 帧读写 (含自动发现/自愈)。 +// +// 端点独占是两层的, 缺一层都不够 —— Linux 下 CDC 口不独占, 第二个进程 open 成功且分吃 +// 同一字节流 (双方拿残帧、静默失败); Windows 反而由 OS 强制独占 => 独占性因平台而异, +// 不能当常量依赖。固件那侧无从提供, 只能主机侧收口: +// +// * 跨进程: flock(LOCK_EX|LOCK_NB) (POSIX) / 文件句柄本身 (Windows), 失败抛 +// TransportError; +// * 进程内: claim_port() 的 port -> 持有者 登记表。它与 flock 不是重复的: 它排在 +// open 之前 (无副作用、fail-fast), 且那一刻分得清是"自己人重复开"还是"别的进程 +// 占着" —— 等 flock 失败时只剩一句 "Resource temporarily unavailable", 两者无从分辨。 +// +// 两层挡的都是端口级的互斥, 与上层开了几个 Arm 无关: 两个 Arm 各指一个口是合法的, +// 要挡的是同一个口被两条链路同时开 —— 不论那两条来自两个 Arm、同一个 Arm 的两次构造, +// 还是绕过 Arm 直接 new 出来的传输。故这道门挂在传输这一层。 #pragma once -/** - * @file transport.hpp - * @brief Pluggable transport layer for litearm v4. - * - * Provides pub/sub and query/reply abstractions with two backends: - * - InProcTransport: in-process queues (zero-dependency, for testing) - * - ZenohTransport: zenoh-c networking (production use, optional) - */ +#include #include #include -#include -#include #include #include #include -#include #include -namespace litearm { - -// state 广播 50Hz × 最长 query timeout 300s = 15000 条消息。设 20000 留余量。 -// 不足时旧消息被环形丢弃。InProcTransport 与未来 ZenohTransport 共用此常量。 -inline constexpr size_t kDefaultFifoDepth = 20000; - -// RPC 默认超时(秒)= 5 分钟。zenoh 默认仅约 10s,运动方法(movej/movel/...) -// 可能跑几十秒甚至更久。InProcTransport 的 query() 是同步直接调用(不需 timeout), -// 此常量预留给未来的 ZenohTransport。 -inline constexpr double kDefaultQueryTimeoutS = 300.0; - -// ── Abstract base classes ─────────────────────────────────────────────────── +#include "litearm/framing.hpp" +#include "litearm/protocol.hpp" -/// Subscription handle: FIFO, try_recv() non-blocking, drain_latest(). -class Sub { -public: - virtual ~Sub() = default; - - /// Non-blocking receive. Returns bytes or empty optional if queue is empty. - virtual std::optional try_recv() = 0; - - /// Drain to the last message, discarding older ones. - /// Returns the latest message or empty optional if queue was empty. - std::optional drain_latest(); -}; +namespace litearm { -/// Abstract transport: pub/sub + query/reply. +/// 传输接口 —— 真串口与测试桩都实现它。 +/// +/// 它不是"内部细节": Arm 只通过这个接口碰链路, 注入点见 ArmOptions::transport_factory。 class Transport { public: virtual ~Transport() = default; - /// Publish payload to topic. - virtual void pub(const std::string& topic, const std::string& payload) = 0; - - /// Subscribe to topic. Returns a Sub handle. - virtual std::shared_ptr sub(const std::string& topic) = 0; - - /// Send a query and wait for a single reply. Returns reply bytes. - virtual std::string query(const std::string& topic, const std::string& payload) = 0; + /// 写一帧。失败抛 TransportError。 + /// + /// **"抛 TransportError 蕴含整帧未送达"是一条载重不变量**: CartPending::request 靠 + /// "写失败就摘掉自己那个 token"成立, 而那条只在"帧没出去"时才安全 —— 帧其实送到了却 + /// 摘掉 token, 固件随后那条 0x4E 会配给队列里后面那条活 token, 于是报"成功"而实际 + /// 没跑 (假成功)。 + /// + /// 而两半的物理事实不同: write 抛 => 帧没送达 => 抛 TransportError; + /// drain (tcdrain/flush) 抛 => 整帧已经交进驱动、会被送达, 失败收不回来 + /// => 不算发送失败, 只计进 flush_failures (静默是另一条底线)。 + virtual void write_frame(uint8_t cmd, const uint8_t* payload, size_t len) = 0; + + /// 便捷重载 —— 载荷直接给 vector 或省略 (空载荷)。它们转发到上面那条虚函数, + /// 故子类只需实现一个。 + void write_frame(uint8_t cmd) { write_frame(cmd, nullptr, 0); } + void write_frame(uint8_t cmd, const std::vector& p) { + write_frame(cmd, p.data(), p.size()); + } + + /// 读一帧; 超时返回 nullopt。跳过噪声字节, 丢弃 CRC 坏帧。 + /// + /// 缓冲自持完整流 (含候选帧头 SOF): 读超时时已到达的半帧留在缓冲里, 下次调用续读即可 + /// —— 旧实现超时时已把 SOF 丢掉, 余下字节会被当新流扫描, 导致整帧丢失。 + virtual std::optional read_frame(double timeout) = 0; + + virtual void close() = 0; + virtual bool is_open() const = 0; + + /// 最近被跳过的非帧字节 (解码后), 用于解析固件开机签名。默认空。 + virtual std::string text_log() const { return {}; } + + /// 端口名 (用于错误消息与进程内登记)。默认空。 + virtual std::string port_name() const { return {}; } + + /// flush (tcdrain) 失败的累计次数 —— 只做可观测性, 不改变任何行为。 + /// + /// ⚠ **虚函数**, 不是可有可无: 测试夹具 (`tests/test_support.hpp` 的 `SharedFake`) + /// 把真传输**包一层**再交给 `Arm`, 非虚的话 `host_stats().flush_failures` 读到的 + /// 是**包装层自己**那个恒 0 的计数器, 假件注入的值永远传不上来 —— 而"假件与真件 + /// 行为不一致"正是本仓最忌讳的假绿(见 `stamps_of` / `tx_snapshot` 的同款说明)。 + virtual uint64_t flush_failures() const { return flush_failures_.load(); } + +protected: + std::atomic flush_failures_{0}; +}; - /// Register a queryable on topic. handler(payload) -> reply_bytes. - using Handler = std::function; - virtual void declare_queryable(const std::string& topic, Handler handler) = 0; +using Frame = proto::Frame; - /// Close the transport and release resources. - virtual void close() {} -}; +/// 按 VID:PID 1d50:606f 自动发现 CDC 端口; 找不到返回 nullopt。 +/// +/// 不读任何环境变量 —— 脚本层用 LITEARM_PORT 覆盖, 那是脚本的事。 +std::optional find_cdc_port(); -// ── InProc backend (testing / single-process mode) ────────────────────────── +// ---------------------------------------------------------------- 真串口 -/// In-process pub/sub + query/reply. Zero-dependency, for testing. -class InProcTransport : public Transport { +class SerialTransport : public Transport { public: - explicit InProcTransport(size_t fifo_depth = kDefaultFifoDepth); - ~InProcTransport() override; + /// 打开并占用 port。失败 (含被占用) 抛 TransportError。 + explicit SerialTransport(const std::string& port, double timeout = 0.2); + ~SerialTransport() override; + + SerialTransport(const SerialTransport&) = delete; + SerialTransport& operator=(const SerialTransport&) = delete; - void pub(const std::string& topic, const std::string& payload) override; - std::shared_ptr sub(const std::string& topic) override; - std::string query(const std::string& topic, const std::string& payload) override; - void declare_queryable(const std::string& topic, Handler handler) override; + using Transport::write_frame; // 别让下面那条 override 把基类的便捷重载藏掉 + void write_frame(uint8_t cmd, const uint8_t* payload, size_t len) override; + std::optional read_frame(double timeout) override; void close() override; + bool is_open() const override; + std::string text_log() const override; + std::string port_name() const override { return port_; } -private: - class InProcSub; + /// 噪声留痕上限。固件开机签名不是帧, 会被当噪声丢弃; 留痕是为了解析出 + /// 「本次是否 IWDG 复位」。噪声可能无限多, 故必须封顶。 + /// ⚠ 上限的**实现**在 `FrameReader` (`kNoiseMax`, 同值) —— 本常量保留是为了 + /// 兼容"从外面读它"的调用方; 改一处要改两处, 故这里注明。 + static constexpr size_t kTextLogMax = 2048; - size_t depth_; - std::mutex lock_; - std::unordered_map>> subs_; - std::unordered_map queryables_; +private: + std::optional read_frame_locked(double end_steady); + std::optional> read_chunk(double end_steady); + size_t want_bytes() const; + + std::string port_; + double timeout_; + int fd_ = -1; + std::mutex rlock_; // 读者互斥 + std::mutex wlock_; // 写者互斥 + /// **分帧状态机** —— 与平台无关, 抽在 `litearm/framing.hpp` 里。 + /// + /// ⚠⚠ 从前 POSIX 与 Win32 两条后端**各写了一份**同样的状态机 (SOF 扫描 / 半帧超时 / + /// 噪声捕获), 代码里自称"与 POSIX 侧同一段逻辑"。那是**会漂的拷贝**, 而 Windows + /// 那条分支在 Linux 上编不了 ⇒ 漂了也不会当场发现。抽出之后两条后端只剩 I/O。 + FrameReader reader_; bool closed_ = false; + // 唤醒读线程用 (close 时置位, 让阻塞在 poll 上的读者及时退出) + std::atomic stop_flag_{false}; }; -// ── Factory ───────────────────────────────────────────────────────────────── +/// 单调时钟秒 (对应 Python 的 time.monotonic)。 +double now_s(); -/// Create a transport by kind: "inproc" (testing) or "zenoh" (production). -/// Zenoh transport requires zenoh-c to be linked. -std::unique_ptr make_transport(const std::string& kind = "inproc"); +/// 把 `port` 记到当前传输名下; 已被另一个活着的持有者占着时响亮失败。 +/// 排在 open 之前 (那一刻还没有任何副作用, 且错里能直接写清"是进程内自己人占用")。 +void claim_port(const std::string& port, const Transport* owner); +void release_port(const std::string& port, const Transport* owner); +/// 进程内登记表快照 (端口名, 持有者) —— 供测试断言不泄漏。 +std::vector> port_owners_snapshot(); -} // namespace litearm +} // namespace litearm diff --git a/include/litearm/types.hpp b/include/litearm/types.hpp deleted file mode 100644 index 603553e..0000000 --- a/include/litearm/types.hpp +++ /dev/null @@ -1,207 +0,0 @@ -#pragma once -/** - * @file types.hpp - * @brief Core types for the litearm C++ SDK. - * - * Provides LiteArmValue (dynamic value type), ArmState enum, - * RobotState, JointTrajectory, and related types. - */ - -#include -#include -#include -#include -#include -#include - -namespace litearm { - -// ── Convenience aliases ───────────────────────────────────────────────────── - -using Vec7 = std::vector; -using Mat3x3 = std::vector>; - -// ── Dynamic value type ────────────────────────────────────────────────────── - -/// Recursive dynamic value type (like Python's Any or JSON value). -/// Used for RPC arguments/results that vary by method. -class LiteArmValue { -public: - enum class Kind { - Null, Bool, Int, Double, String, Bytes, List, Map - }; - - using ListType = std::vector; - using MapType = std::map; - using BytesType = std::vector; - - // Constructors - LiteArmValue() : kind_(Kind::Null) {} - LiteArmValue(std::nullptr_t) : kind_(Kind::Null) {} - LiteArmValue(bool v) : kind_(Kind::Bool), bool_val_(v) {} - LiteArmValue(int v) : kind_(Kind::Int), int_val_(v) {} - LiteArmValue(int64_t v) : kind_(Kind::Int), int_val_(v) {} - LiteArmValue(double v) : kind_(Kind::Double), double_val_(v) {} - LiteArmValue(const char* v) : kind_(Kind::String), str_val_(v) {} - LiteArmValue(std::string v) : kind_(Kind::String), str_val_(std::move(v)) {} - LiteArmValue(BytesType v) : kind_(Kind::Bytes), bytes_val_(std::move(v)) {} - LiteArmValue(ListType v); - LiteArmValue(MapType v); - - // Kind queries - Kind kind() const { return kind_; } - bool is_null() const { return kind_ == Kind::Null; } - bool is_bool() const { return kind_ == Kind::Bool; } - bool is_int() const { return kind_ == Kind::Int; } - bool is_double() const { return kind_ == Kind::Double; } - bool is_string() const { return kind_ == Kind::String; } - bool is_bytes() const { return kind_ == Kind::Bytes; } - bool is_list() const { return kind_ == Kind::List; } - bool is_map() const { return kind_ == Kind::Map; } - - // Value accessors (throw std::bad_cast on type mismatch) - bool as_bool() const; - int64_t as_int() const; - double as_double() const; - const std::string& as_string() const; - const BytesType& as_bytes() const; - const ListType& as_list() const; - const MapType& as_map() const; - - // Mutable accessors - ListType& as_list(); - MapType& as_map(); - - // Convenience conversions - /// Convert a vector to a List of Double values. - static LiteArmValue from_vec(const std::vector& v); - /// Convert a nested vector to a List of Lists. - static LiteArmValue from_mat(const std::vector>& m); - /// Extract as vector (each element must be numeric). - std::vector to_vec() const; - /// Extract as nested vector>. - std::vector> to_mat() const; - - // Map helpers - /// Shorthand: build a map from initializer list. - static LiteArmValue make_map(std::initializer_list> items); - -private: - Kind kind_; - bool bool_val_ = false; - int64_t int_val_ = 0; - double double_val_ = 0.0; - std::string str_val_; - BytesType bytes_val_; - std::shared_ptr list_val_; - std::shared_ptr map_val_; -}; - -// ── Arm state enum ────────────────────────────────────────────────────────── - -enum class ArmState { - DISCONNECTED, - CONNECTING, - READY, - MOVING, - HOLDING, - ZERO_GRAVITY, - IMPEDANCE, - FOLLOWING, - STOPPING, - FAULT, - UNKNOWN, -}; - -/// Convert ArmState to its string representation. -const char* to_string(ArmState state); - -/// Parse ArmState from string. Returns UNKNOWN for unrecognized values. -ArmState arm_state_from_string(const std::string& s); - -// ── Robot state types ─────────────────────────────────────────────────────── - -struct JointFeedbackState { - int joint = 0; - int received = 0; - double age_s = 0.0; - bool fresh = false; -}; - -struct FeedbackState { - double max_age_s = 0.0; - std::vector joints; - std::vector stale_joints; -}; - -struct WatchdogState { - bool enabled = false; - double timeout_s = 0.0; - std::string mode; - bool tripped = false; - double last_kick_age_s = 0.0; -}; - -struct FaultInfo { - int joint = 0; - int err_code = 0; -}; - -struct TemperatureInfo { - int mos_temp = 0; - int coil_temp = 0; -}; - -struct RobotState { - std::vector q; - std::vector dq; - std::vector tau; - std::vector faults; - std::vector errs; - std::vector temps; - std::string state; - FeedbackState feedback; - WatchdogState watchdog; - std::string robot_serial; - std::string config_checksum_sha256; -}; - -// ── Trajectory types ──────────────────────────────────────────────────────── - -struct TrajectoryFrame { - double t = 0.0; - std::vector q; - std::optional> dq; - std::optional> tau; -}; - -/// Validated, portable recording of a seven-axis joint trajectory. -struct JointTrajectory { - static constexpr const char* SCHEMA = "pylitearm.joint_trajectory.v1"; - - std::vector frames; - std::string name = "trajectory"; - std::string created_at; - std::optional sample_rate_hz; - std::optional filter_alpha; - std::optional robot_serial; - std::optional config_checksum_sha256; - std::optional source_path; - - /// Total duration in seconds (last frame timestamp). - double duration_s() const; - - /// Extract all position vectors. - std::vector> q() const; - - /// Extract all timestamps. - std::vector t() const; - - /// Serialize to LiteArmValue (map). - LiteArmValue to_value() const; - - /// Deserialize from LiteArmValue (map). - static JointTrajectory from_value(const LiteArmValue& v); -}; - -} // namespace litearm diff --git a/proto/litearm.proto b/proto/litearm.proto deleted file mode 100644 index 4231b4e..0000000 --- a/proto/litearm.proto +++ /dev/null @@ -1,142 +0,0 @@ -syntax = "proto3"; - -package litearm_proto; - -// 协议版本:通过字段增减实现兼容性 -// 所有消息隐式携带版本信息(通过字段编号) - -// ============================================================================ -// 通用类型 -// ============================================================================ - -// 通用值类型(支持任意参数) -message Value { - oneof value { - double double_val = 1; - int64 int_val = 2; - string string_val = 3; - bool bool_val = 4; - bytes bytes_val = 5; - ListValue list_val = 6; - MapValue map_val = 7; - NoneValue none_val = 8; - } -} - -message NoneValue {} - -message ListValue { - repeated Value values = 1; -} - -message MapValue { - map values = 1; -} - -// ============================================================================ -// RPC 消息 -// ============================================================================ - -// RPC 请求 -message RpcRequest { - string method = 1; // 方法名(如 "movej", "fk") - map kwargs = 2; // 参数(方法特定的键值对) -} - -// RPC 响应 -message RpcReply { - bool ok = 1; // 是否成功 - Value result = 2; // 成功时的返回值 - Error error = 3; // 失败时的错误信息 -} - -// 错误信息 -message Error { - string type = 1; // 异常类型名(如 "SafetyViolationError") - string message = 2; // 异常消息 - map details = 3; // 额外详情(如 SafetyViolationError.details) -} - -// ============================================================================ -// 状态广播 -// ============================================================================ - -// 机器人状态(~50Hz 广播) -message RobotState { - repeated double q = 1; // 关节角 rad [7] - repeated double dq = 2; // 关节速度 rad/s [7] - repeated double tau = 3; // 关节力矩 Nm [7] - repeated Fault fault = 4; // 故障列表 - repeated int32 errs = 5; // 原始状态码 [7] - repeated Temperature temps = 6; // 温度 [7] - string state = 7; // ArmState 枚举值 - FeedbackState feedback = 8; // 反馈状态 - WatchdogState watchdog = 9; // 看门狗状态 - string robot_serial = 10; // 机器人序列号 - string config_checksum_sha256 = 11; // 配置校验和 -} - -// 故障信息 -message Fault { - int32 joint = 1; // 关节编号 - int32 err_code = 2; // 错误码 -} - -// 温度信息 -message Temperature { - int32 mos_temp = 1; // MOS 温度 °C - int32 coil_temp = 2; // 线圈温度 °C -} - -// 反馈状态 -message FeedbackState { - double max_age_s = 1; // 最大反馈年龄 s - repeated JointFeedbackState joints = 2; // 各关节反馈 - repeated int32 stale_joints = 3; // 过时关节列表 -} - -// 单关节反馈状态 -message JointFeedbackState { - int32 joint = 1; // 关节编号 - int32 received = 2; // 接收计数 - double age_s = 3; // 反馈年龄 s - bool fresh = 4; // 是否新鲜 -} - -// 看门狗状态 -message WatchdogState { - bool enabled = 1; // 是否启用 - double timeout_s = 2; // 超时时间 s - string mode = 3; // 模式 - bool tripped = 4; // 是否触发 - double last_kick_age_s = 5; // 上次重置年龄 s -} - -// ============================================================================ -// 控制命令 -// ============================================================================ - -// 急停 -message Estop { - bool trigger = 1; // 触发急停 -} - -// 遥操作命令(用于 servo/joint_follow) -message Command { - oneof command { - JointCommand joint = 1; // 关节命令 - PoseCommand pose = 2; // 位姿命令 - } -} - -// 关节命令 -message JointCommand { - repeated double q = 1; // 目标关节角 [7] - repeated double dq = 2; // 目标关节速度 [7](可选) -} - -// 位姿命令 -message PoseCommand { - repeated double pos = 1; // 目标位置 [3] - repeated double R = 2; // 目标旋转矩阵 [9](3x3 展平,行优先) -} diff --git a/run_example.ps1 b/run_example.ps1 new file mode 100644 index 0000000..f3a27ef --- /dev/null +++ b/run_example.ps1 @@ -0,0 +1,28 @@ +# ============================================================================ +# 运行单个 example (Windows / PowerShell), 用法: +# +# .\run_example.ps1 01_hello +# .\run_example.ps1 02_movej --go --speed 0.2 +# $env:LITEARM_PORT="COM7"; .\run_example.ps1 01_hello +# +# 等价于: . .\env.ps1 然后跑 build\.exe [args...] +# ============================================================================ +param( + [Parameter(Mandatory = $true, Position = 0)][string]$Name, + [Parameter(ValueFromRemainingArguments = $true)][string[]]$Rest +) +$ErrorActionPreference = "Stop" + +$root = $PSScriptRoot +. (Join-Path $root "env.ps1") + +$exe = Join-Path $LiteArmBuild "$Name.exe" +if (-not (Test-Path $exe)) { $exe = Join-Path $LiteArmBuild "Release\$Name.exe" } +if (-not (Test-Path $exe)) { + Write-Host "找不到样例可执行文件: $Name (先跑 .\env.ps1 或 cmake --build build)" + Get-ChildItem (Join-Path $root "examples\*.cpp") | + Where-Object { $_.BaseName -notlike "_*" } | + ForEach-Object { Write-Host " $($_.BaseName)" } + exit 1 +} +& $exe @Rest diff --git a/run_example.sh b/run_example.sh new file mode 100755 index 0000000..a08f55d --- /dev/null +++ b/run_example.sh @@ -0,0 +1,28 @@ +#! /usr/bin/env bash +# ============================================================================ +# 运行单个 example (设置好环境再跑), 用法: +# +# ./run_example.sh 01_hello +# ./run_example.sh 02_movej --go --speed 0.2 +# LITEARM_PORT=/dev/ttyACM0 ./run_example.sh 01_hello +# +# 等价于: source env.sh && "$LITEARM_BIN"/ [args...] +# ============================================================================ +set -euo pipefail +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ $# -lt 1 ]; then + echo "用法: $0 [args...]" + echo "样例:" + ls "$DIR/examples/"*.cpp 2>/dev/null | sed 's#.*/##; s#\.cpp$##' | grep -v '^_' || true + exit 1 +fi + +name="$1"; shift || true +source "$DIR/env.sh" + +exe="$LITEARM_BIN/$name" +if [ ! -x "$exe" ]; then + echo "找不到样例可执行文件: $exe"; exit 1 +fi +exec "$exe" "$@" diff --git a/src/ack.cpp b/src/ack.cpp new file mode 100644 index 0000000..1d2b4aa --- /dev/null +++ b/src/ack.cpp @@ -0,0 +1,371 @@ +#include "litearm/ack.hpp" + +#include +#include + +#include "litearm/arm.hpp" + +#ifdef _WIN32 +# include +# define LITEARM_ACK_GETPID() ::_getpid() +#else +# include +# define LITEARM_ACK_GETPID() ::getpid() +#endif + +namespace litearm { + +QueueKey read_key(uint8_t c, const std::vector& payload) { + // 只有 RSP_ACK / RSP_ERR 的 payload[0] 是"原命令码"。 + if (proto::is_echoed(c) && !payload.empty()) return {c, int(payload[0])}; + return {c, kNoEcho}; +} + +std::vector wait_keys(uint8_t want, std::optional echo_cmd) { + if (!proto::is_echoed(want)) { + // 给了 echo_cmd 就还要等"回显了本命令码的 ERR" —— 那是本命令被拒的唯一载体 + // (expect 的 raise_on_err 靠它)。少了这一条, 一次被拒会退化成"无应答"超时。 + if (!echo_cmd) return {{want, kNoEcho}}; + return {{want, kNoEcho}, {proto::RSP_ERR, int(*echo_cmd)}}; + } + if (!echo_cmd) { + throw InvalidCommandError( + "等 RSP_ACK/RSP_ERR 必须给 echo_cmd —— 它的归属判据是回显的命令码; " + "不给就分不清'我的 ACK'与'别人的 ACK' (并发下会互吃)"); + } + if (want == proto::RSP_ACK) { + // 受理回 ACK{cmd}、拒绝回 ERR{cmd,code} (绝不补 ACK) => 两条队列都得等 + // (err_waits_for_ack 与 raise_on_err 都靠这一条)。 + return {{proto::RSP_ACK, int(*echo_cmd)}, {proto::RSP_ERR, int(*echo_cmd)}}; + } + return {{want, int(*echo_cmd)}}; +} + +Ack::Ack(Arm* arm) : arm_(arm) {} + +const Clock& Ack::clk() const { + // ⚠ 空安全: `arm_` 允许为空 (可以直接 `Ack a(nullptr)`)。 + return arm_ != nullptr ? arm_->clock() : steady_clock_instance(); +} + +Ack::~Ack() { + if (owner_pid_ != -1 && LITEARM_ACK_GETPID() != owner_pid_) { + // **fork 出来的子进程**: 这条读线程在子进程里不存在。 + // * `stop_reader()` 会 `join()` —— 对一个不存在的线程 join 会**永久阻塞**; + // * 直接放着不管则 `~std::thread` 会因"joinable 却没 join"而 `std::terminate`。 + // 故把句柄交出去**故意泄漏**: 子进程要么马上退出 (内核回收), 要么会新建自己的 + // `Arm`。比"为了体面地回收一个对象而挂死/中止"划算得多。 + // + // ⚠⚠ **这个守卫是必要的, 但远不是充分的。** 实测 (2026-09-24, glibc 2.35) 定位到 + // 真正会挂死的那一步**不是 join, 而是本类成员的析构**: + // + // ~condition_variable(stop_cv_) ← 子进程里**永久阻塞** + // + // 最小复现: 一个线程 `cv.wait_for(...)` 停在一个 condvar 上, `fork()` 之后在 + // 子进程里析构那个 condvar —— glibc 的 `pthread_cond_destroy` 会去取 condvar + // 自己的**内部锁** (组切换 `__condvar_quiesce_and_switch_g1`), 而那把锁与已经 + // 不存在的等待者绑在一起 ⇒ 死锁。(同一个实验里: 析构"没人在等"的 condvar 与 + // mutex **都没事**; detach 继承来的线程句柄也不抛; malloc/free 也安全。 + // 问题**只**出在"析构一个有人停在上面的 condvar"。) + // + // 而 `stop_cv_` / `cv` 是**成员** ⇒ 只要 `~Ack` 跑起来, 这一步就躲不掉 —— + // 本守卫拦不住它。**所以调用方必须根本不析构本对象**: 真正救命的是 + // `Arm::close()` fork 分支里的 `a_.release()` (见那里的注释), 本守卫只是 + // 在"有人绕过那条路径直接 delete 一个继承来的 Ack"时把伤害从 + // `std::terminate` 降级为泄漏 —— 那一步之后仍会在成员析构上挂住。 + // ⚠ 别把上面这段读成"本守卫能兜住": 它兜不住。 + // ⚠ 走到这里说明**有人直接析构了一个继承来的 `Ack`** —— 支持的路径 + // (`Arm::close()` / `~Arm`) 不会这么做 (它们 `release()` 这个对象)。 + // 按本仓"丢帧不静默"的一贯口径, 这里也要出声: 这条路**下一句就会挂死** + // (成员析构里那个 condvar), 静默地挂比报一句错难查得多。 + std::fprintf(stderr, + "[litearm] 警告: 在 fork 出来的子进程 (pid=%d) 里析构了一个属于 " + "pid=%d 的会话对象。这不是支持的用法 —— 紧接着的成员析构会**永久" + "阻塞** (详见 ack.cpp 里 ~Ack 的注释)。请改用 arm.close(), 或在子进程" + "里新建一个 Arm。\n", + int(LITEARM_ACK_GETPID()), int(owner_pid_)); + std::fflush(stderr); + try { + new std::thread(std::move(reader_)); + } catch (...) { + // 分配失败时无路可走 —— 但这已经不可能是"子进程收尾"这条路径上的常态。 + } + return; + } + stop_reader(); +} + +void Ack::start_reader(Transport* tr) { + tr_ = tr; + owner_pid_ = LITEARM_ACK_GETPID(); + stop_.store(false); + reader_ = std::thread(&Ack::reader_loop, this, tr); +} + +void Ack::stop_reader() { + if (owner_pid_ != -1 && LITEARM_ACK_GETPID() != owner_pid_) { + // 非属主进程 (fork 的子进程): 那条线程不在这里, join 会永久挂死。见析构函数。 + stop_.store(true); + if (reader_.joinable()) { + try { + new std::thread(std::move(reader_)); + } catch (...) { + } + } + return; + } + // 必须排在 transport->close() 之前 (否则一次正常关闭会被记成"链路丢失")。 + // 也必须 join: 线程的 target 是成员函数 => 线程引用本对象。 + stop_.store(true); + { + std::lock_guard lk(stop_mu_); + // 取一下锁再 notify: 保证退避中的读线程已经进了 wait (否则这次 notify 会丢, + // 那一拍要多等 kIdleSleepS —— 无害但没必要)。 + } + stop_cv_.notify_all(); + if (reader_.joinable()) reader_.join(); +} + +void Ack::reader_loop(Transport* tr) { + // 唯一的读者 —— 全包只有这一个地方碰 transport->read_frame。 + // + // 本设计的全部代价就是下面两条纪律: + // 1. 只投递, 不判定 —— "这帧是谁的"由队列决定, 不由线程决定; + // 2. 死了要响亮 —— 死因进 reader_error 并唤醒所有等待者, 绝不静默退出。 + while (!stop_.load()) { + std::optional fr; + try { + fr = tr->read_frame(kReadSliceS); + } catch (...) { + die(std::current_exception()); + return; + } + if (!fr) { + // 退避: SerialTransport 空闲时会阻塞满 kReadSliceS, 但传输接口没有这个契约 + // —— 测试桩就完全忽略 timeout。不退避的空循环会跑出接近 100% 的 CPU。 + // 用带超时的 wait 而不是纯 sleep: 让 stop_reader 不必等满这一拍。 + std::unique_lock lk(stop_mu_); + stop_cv_.wait_for(lk, std::chrono::duration(kIdleSleepS)); + continue; + } + try { + deliver(fr->cmd, std::move(fr->payload), clk().now_s()); + } catch (...) { + // 协议层异常 (典型: 笛卡尔收集器的"多了一条"): 不当场抛、也不死, 存起来交给 + // 下一个等待者。 + MuLock lk(mu, dbg_mu_owner); + errors.push_back(std::current_exception()); + cv.notify_all(); + } + } +} + +void Ack::die(std::exception_ptr e) { + // 链路没了 —— 记死因并唤醒所有等待者 (绝不静默退出)。 + std::string text = "未知传输错误"; + try { + std::rethrow_exception(e); + } catch (const std::exception& ex) { + text = ex.what(); + } catch (...) { + } + { + std::lock_guard lk(mu); + reader_error = e; + reader_error_text = text; + cv.notify_all(); + } +} + +void Ack::deliver(uint8_t c, std::vector payload, double now) { + // 把一帧投递到它该去的地方 —— 本包唯一的分发点。 + Arm* owner = nullptr; + bool is_cart = false; + { + MuLock lk(mu, dbg_mu_owner); + dbg_check("deliver"); + note_recv(c, now); + if (c == proto::RSP_STATUS) { + on_status(payload); + status_seq += 1; + } else if (c == proto::RSP_CART_PLAN) { + owner = arm_; // 收集器在锁外调 (见下) + is_cart = true; + } else { + const QueueKey k = read_key(c, payload); + Queue& q = queues[k]; + if (q.size() >= kQueueMax) { + q.pop_front(); // 孤儿: 丢最旧, 不静默 + dropped += 1; + } + QueueEntry e; + e.id = c; + e.payload = std::move(payload); + e.seq = next_seq_++; + q.push_back(std::move(e)); + } + cv.notify_all(); + } + // on_reply 必须在 mu 之外: 它要去取 CartPending 的锁, 在全局锁里调另一个子系统的锁, + // 那边一慢就会把所有等待者一起卡住。 + if (is_cart && owner != nullptr) { + owner->on_cart_plan_reply(payload); + } +} + +void Ack::on_status(const std::vector& payload) { + try { + state = decode_state(payload); + } catch (const std::exception&) { + // 非法状态帧: 忽略 (不改变已有缓存), 但**要计数**。 + // ⚠ 计数不是装饰: "每帧都解不出来"与"根本没有帧"在等待者眼里是同一种静默, + // 而两者要查的方向相反。见 `bad_status_frames` 的说明。 + bad_status_frames += 1; + } +} + +void Ack::note_recv(uint8_t c, double now) { + auto it = recv_.find(c); + if (it == recv_.end()) { + RecvEnt e; + e.count = 1; + e.first = now; + e.last = now; + recv_[c] = e; + } else { + it->second.count += 1; + it->second.last = now; + } +} + +std::pair Ack::recv_stats(uint8_t c) { + MuLock lk(mu, dbg_mu_owner); + dbg_check("recv_stats"); + const auto it = recv_.find(c); + if (it == recv_.end()) return {0.0, 0.0}; + const RecvEnt& e = it->second; + const double span = e.last - e.first; + if (e.count < 2 || span <= 0.0) { + // span <= 0 是防御性的: 两帧撞进同一个读数到不了 —— 留着是为了让本函数对任何 + // 输入都有定义。 + return {0.0, e.last}; + } + return {double(e.count - 1) / span, e.last}; +} + +std::optional Ack::state_copy() { + MuLock lk(mu, dbg_mu_owner); + dbg_check("state_copy"); + return state; +} + +void Ack::drain_for(uint8_t cmd) { + // 由 Arm::raw_write 在发帧之前调 —— 位置是载重的 (见头文件)。 + std::vector keys = {{proto::RSP_ACK, int(cmd)}, {proto::RSP_ERR, int(cmd)}}; + const auto rsp = proto::rsp_of(cmd); + if (rsp) keys.emplace_back(*rsp, kNoEcho); + MuLock lk(mu, dbg_mu_owner); + dbg_check("drain_for"); + for (const auto& k : keys) { + const auto it = queues.find(k); + if (it != queues.end()) { + dropped += it->second.size(); + queues.erase(it); + } + } +} + +std::optional Ack::wait(const std::vector& keys, double timeout) { + if (arm_ == nullptr) { + throw NotConnectedError("链路已关闭 (Arm::close() 之后) —— 请先 connect()"); + } + // 三个守卫排最前, 一个都不能少 —— 漏哪条都是从"响亮地报错"静默退化成"等满超时"。 + arm_->guard_for_ack_wait(); + if (timeout < 0.0) { + throw InvalidCommandError( + "timeout 不能为负 —— 负值没有等待语义; 要'不等、只探一次'请传 0.0"); + } + const double end = clk().now_s() + timeout; + MuLock lk(mu, dbg_mu_owner); + dbg_check("wait"); + while (true) { + QueueEntry best; + bool have_best = false; + QueueKey best_key{}; + for (const auto& k : keys) { + const auto it = queues.find(k); + if (it == queues.end() || it->second.empty()) continue; + if (!have_best || it->second.front().seq < best.seq) { + best = it->second.front(); + best_key = k; + have_best = true; + } + } + if (have_best) { + queues[best_key].pop_front(); + return best; + } + if (!errors.empty()) { + std::exception_ptr e = errors.front(); + errors.pop_front(); + std::rethrow_exception(e); + } + if (reader_error) { + throw TransportError("读线程已退出: " + reader_error_text); + } + const double left = end - clk().now_s(); + if (left <= 0.0) return std::nullopt; + cv.wait_for(lk.ul(), std::chrono::duration(left)); + } +} + +ExpectResult Ack::expect(uint8_t want, double timeout, const std::string& cmdlabel, + bool raise_on_err, std::optional echo_cmd, + bool err_waits_for_ack) { + const std::vector keys = wait_keys(want, echo_cmd); + std::optional> err_seen; + // 这里不清队: 清队由 Arm::raw_write 在发帧之前做 (drain_for) —— 放在这里会 100% + // 吃掉自己的应答。 + const double end = clk().now_s() + timeout; + while (true) { + const double left = end - clk().now_s(); + if (left <= 0.0) break; + const auto fr = wait(keys, left); + if (!fr) break; + const uint8_t c = fr->id; + const std::vector& pl = fr->payload; + if (c == proto::RSP_ERR) { + if (err_waits_for_ack && !err_seen) { + // 先记下, 等窗口给结论。第二条起的 ERR 不再记 —— 一条命令至多被拒一次。 + err_seen = pl; + continue; + } + if (raise_on_err) raise_firmware_error(pl, cmdlabel + " 被固件拒绝: "); + ExpectResult r; + r.kind = ExpectResult::Kind::Err; + r.payload = pl; + return r; + } + ExpectResult r; + r.kind = (want == proto::RSP_ACK) ? ExpectResult::Kind::Ack + : ExpectResult::Kind::Rsp; + r.payload = pl; + return r; + } + if (err_seen) { + // 窗口里只见过 ERR、没见到 ACK{echo_cmd} => 它就是本命令的拒绝。 + if (raise_on_err) raise_firmware_error(*err_seen, cmdlabel + " 被固件拒绝: "); + // 本行今天没有调用方走到 (err_waits_for_ack 且 raise_on_err=false 才可达)。 + // 保留它是刻意的: 它两个兄弟分支各覆盖一种开关组合, 三者共同定义 expect 的返回 + // 契约 —— 删掉它, 那条"我不想抛、但请把拒绝告诉我的"组合会掉到末尾的超时异常, + // 而那时明明收到了应答 (一条 ERR), 归因直接反了。 + ExpectResult r; + r.kind = ExpectResult::Kind::Err; + r.payload = *err_seen; + return r; + } + throw MotionTimeoutError(cmdlabel + " 无应答(超时 " + + std::to_string(timeout) + "s)"); +} + +} // namespace litearm diff --git a/src/arm.cpp b/src/arm.cpp index f9a36d8..b2824bd 100644 --- a/src/arm.cpp +++ b/src/arm.cpp @@ -1,792 +1,1625 @@ #include "litearm/arm.hpp" -#include "litearm/codec.hpp" -#include "litearm/exceptions.hpp" -#include "litearm/protocol.hpp" +#include #include -#include +#include +#include +#include +#include +#include + +#include "litearm/cart.hpp" + +#ifdef _WIN32 +# include +# define LITEARM_GETPID() ::_getpid() +#else +# include +# define LITEARM_GETPID() ::getpid() +#endif namespace litearm { -// ── Constructor / Destructor ──────────────────────────────────────────────── - -Arm::Arm(const std::string& endpoint, - const std::string& arm_id, - std::shared_ptr transport) - : arm_id_(arm_id) - , rpc_topic_(rpc_topic(arm_id)) - , estop_topic_(estop_topic(arm_id)) - , command_topic_(command_topic(arm_id)) - , client_id_("sdk-cpp-" + std::to_string(std::hash{}(arm_id) & 0xFFFF)) -{ - // Resolve endpoint: explicit arg > LITEARM_ENDPOINT env > default - std::string resolved_endpoint = endpoint; - if (resolved_endpoint.empty()) { - const char* env = std::getenv("LITEARM_ENDPOINT"); - resolved_endpoint = env ? env : "tcp/127.0.0.1:7447"; - } - endpoint_ = resolved_endpoint; - - if (transport) { - tp_ = std::move(transport); - } else { - // Default: create an InProcTransport (zenoh not linked by default) - // In production, users should pass a ZenohTransport - tp_ = std::make_shared(); +const char* const ZERO_G_GUARD_MESSAGE = + "零重力保活正在进行, 拒绝其它下行命令 (会改写模式/看门狗, 与保活互相打架); " + "先 zero_g_stop() 退出"; + +const char* const CART_IN_FLIGHT_GUARD_MESSAGE = + "笛卡尔运动在途, 拒绝进入零重力 (中途进场会丢掉位置环、只剩重力前馈, " + "臂会靠摩擦滑停): 先 movej() 受控接管收口, 或 emergency_stop() 急停, " + "或等它结束再进入"; + +const char* const CART_IN_FLIGHT_UNCONFIRMED_MESSAGE = + "取不到状态帧, 无法确认笛卡尔是否在途 —— 保守拒绝进入零重力 (未确认就放行, " + "漏的正是'轨迹中途进场靠摩擦滑停'那一侧): 先 get_state(refresh=true) " + "确认臂已停稳, 再重试"; + +const char* const DFU_ENABLED_MESSAGE = + "使能中拒绝进入 DFU —— 跳转停 TIM3 后电机 100ms 就松开, 有重力负载会下垂; " + "先 disable() 再调 enter_dfu()"; + +const char* const DFU_REVOKED_MESSAGE = + "DFU 登记被撤销/未执行 —— 固件回了 ACK 但设备在等待窗口内没有离开 CDC " + "(并发使能或交棒前向量表复读失败都会静默清掉登记, 都不回报); " + "臂与控制链路原样可用, 确认已失能后重试即可"; + +namespace { + +int current_pid() { return LITEARM_GETPID(); } + +std::string trim(const std::string& s) { + size_t a = 0, b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +std::string fnum(double v, int prec = 1) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.*f", prec, v); + return buf; +} + +/// `%.6g` 的数字格式化 —— **预检文案专用**。 +/// +/// ⚠ 别拿 `fnum` 顶替: 它是 `%.*f` 且默认 1 位小数, 报"第 1 轴目标 0.0123 越软限"会印成 +/// `0.0` —— 调用方拿着这条文案去排查, 看到的数与实际发的数对不上。 +/// 而预检的**全部价值**就在"说清哪一个轴、什么值、什么界限"。 +/// ⚠ 对 NaN / ±inf 有定义输出 (`nan` / `inf`), 这正是"非有限值"那条要印的东西。 +std::string gnum(double v) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "%.6g", v); + return buf; +} + +} // namespace + +// =========================================================================== +// LicenseInfo +// =========================================================================== + +const char* license_state_name(int state) { + switch (state) { + case 0: return "not_activated"; + case 1: return "activated"; + case 2: return "activated_factory"; + default: return "unknown"; } - state_sub_ = tp_->sub(state_topic(arm_id)); } -Arm::~Arm() { - try { close(); } catch (...) {} +std::string LicenseInfo::state_name() const { + if (state == 0 || state == 1 || state == 2) return license_state_name(state); + return "unknown_state_" + std::to_string(state); +} + +std::string LicenseInfo::uid_hex() const { + static const char* kHex = "0123456789abcdef"; + std::string out; + out.reserve(24); + for (uint8_t b : uid) { + out.push_back(kHex[b >> 4]); + out.push_back(kHex[b & 0xF]); + } + return out; } -// ── Internal RPC ──────────────────────────────────────────────────────────── +LicenseInfo LicenseInfo::decode(const std::vector& payload) { + // 本帧载荷的首字节是 state, 不是冗余帧 id —— 与 RSP_MODEL_STATUS / RSP_FF_VEC / + // RSP_FF_SCALAR 那一类 (固件把本 id 冗余回填进 payload[0]) 不同。 + // 别照抄它们的 decode 去比 payload[0] == RSP_LICENSE。 + if (payload.size() != 26) { + throw TransportError("RSP_LICENSE 帧长 " + std::to_string(payload.size()) + + "B, 期望 26B"); + } + LicenseInfo info; + info.state = int(payload[0]); + info.ver = int(payload[1]); + std::copy(payload.begin() + 2, payload.begin() + 14, info.uid.begin()); + info.cust_id = proto::read_u32le(payload, 14); + info.issued = proto::read_u32le(payload, 18); + info.flags = proto::read_u32le(payload, 22); + return info; +} + +// =========================================================================== +// ZeroGSession +// =========================================================================== + +void ZeroGSession::stop() { + if (arm_ != nullptr) { + Arm* a = arm_; + arm_ = nullptr; + a->zero_g_stop(true); + } +} -LiteArmValue Arm::rpc(const std::string& method, - std::map kwargs) -{ - std::string payload = encode_request(method, kwargs); - std::string reply = tp_->query(rpc_topic_, payload); - return decode_reply(reply); +void ZeroGSession::reset() { + if (arm_ != nullptr) { + Arm* a = arm_; + arm_ = nullptr; + // 析构路径不能抛 —— C++ 里从析构函数抛异常会直接 terminate。故这一支吞掉全部异常; + // 要拿到"保活曾中断"那条, 请显式调 stop() (它走 raise_on_lost=true)。 + try { + a->zero_g_stop(false); + } catch (...) { + } + } } -// ── Pure computation API ──────────────────────────────────────────────────── +bool ZeroGSession::active() const { return arm_ != nullptr && arm_->zero_g_active(); } + +// =========================================================================== +// Arm —— 构造 / 连接 / 收尾 +// =========================================================================== -std::pair, std::vector>> -Arm::fk(const std::vector& q) { - auto result = rpc("fk", {{"q", LiteArmValue::from_vec(q)}}); - // Result is [position, rotation_matrix] - const auto& list = result.as_list(); - return {list[0].to_vec(), list[1].to_mat()}; +Arm::Arm() = default; + +Arm::Arm(const std::string& port) : port_(port) {} + +Arm::Arm(const ArmOptions& opts) + : port_(opts.port.value_or("")), + transport_factory_(opts.transport_factory), + min_firmware_(opts.min_firmware) { + clock_ = opts.clock; // 空则 `clock()` 回落进程级 SteadyClock + q_tol = opts.q_tol; + dq_tol = opts.dq_tol; + arrive_frames = opts.arrive_frames; + move_timeout = opts.move_timeout; + // 构造点 ①: 与 write 口同寿 —— raw_write 里挂着它的清队钩子, 故必须早于任何写存在。 + cart_ = std::make_unique(cart_absorb_ttl(move_timeout), 0, this); } -std::pair, bool> -Arm::ik(const std::vector& pos_d, - const std::vector>& R_d, - const std::optional>& q_seed) -{ - std::map kwargs; - kwargs["pos_d"] = LiteArmValue::from_vec(pos_d); - kwargs["R_d"] = LiteArmValue::from_mat(R_d); - if (q_seed) { - kwargs["q_seed"] = LiteArmValue::from_vec(*q_seed); +Arm::~Arm() { + // 兜底收尾 —— 只委托给 close(), 自己一行业务都不写 (第二套清理逻辑必然漂)。 + try { + close(); + } catch (...) { + // 析构/ GC 时刻抛出去没人接; 解释器退出时更可能挂死。 + } +} + +Arm& Arm::connect() { return connect_impl(std::nullopt); } +Arm& Arm::connect(const std::string& port) { return connect_impl(port); } + +Arm& Arm::connect_impl(const std::optional& port) { + // 终态不因 connect() 复活 (唯一的出路是新建一个 Arm)。放在最前: 终态下连收尾动作 + // 都不做 (fail-fast、无副作用)。 + reject_if_in_dfu(); + // fork 守卫也放在最前: 否则下面那句"幂等早退"会在子进程里成功返回 —— 用户以为连上了, + // 拿到的是一个没有读线程的会话。 + reject_if_wrong_process(); + // 幂等早退。比较的是当前链路的 port (不是 port_ 成员): 后者只是构造函数的入参。 + if (tr_ && (!port.has_value() || *port == tr_->port_name())) return *this; + if (tr_) { + // 只有"改靶"才走到这里 —— 先收干净旧会话再开新的。 + close(); + } + std::string p; + if (port.has_value()) { + p = *port; + } else if (!port_.empty()) { + p = port_; } else { - kwargs["q_seed"] = LiteArmValue(nullptr); - } - - auto result = rpc("ik", std::move(kwargs)); - const auto& list = result.as_list(); - return {list[0].to_vec(), list[1].as_bool()}; -} - -std::vector> -Arm::plan_movel(const std::vector& q_start, - const LiteArmValue& pose_goal) -{ - auto result = rpc("plan_movel", { - {"q_start", LiteArmValue::from_vec(q_start)}, - {"pose_goal", pose_goal}, - }); - return result.to_mat(); -} - -std::vector> -Arm::plan_movec(const std::vector& q_start, - const LiteArmValue& pose_via, - const LiteArmValue& pose_goal) -{ - auto result = rpc("plan_movec", { - {"q_start", LiteArmValue::from_vec(q_start)}, - {"pose_via", pose_via}, - {"pose_goal", pose_goal}, - }); - return result.to_mat(); -} - -std::vector> -Arm::plan_movep(const std::vector& q_start, - const LiteArmValue& poses_goal) -{ - auto result = rpc("plan_movep", { - {"q_start", LiteArmValue::from_vec(q_start)}, - {"poses_goal", poses_goal}, - }); - return result.to_mat(); -} - -// ── Motion execution ──────────────────────────────────────────────────────── - -bool Arm::movej(const std::vector& q_target, - double speed, double settle_s, - std::optional max_cycles, - bool allow_start_collision_recovery) -{ - std::map kwargs; - kwargs["q_target"] = LiteArmValue::from_vec(q_target); - kwargs["speed"] = LiteArmValue(speed); - kwargs["settle_s"] = LiteArmValue(settle_s); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - kwargs["allow_start_collision_recovery"] = LiteArmValue(allow_start_collision_recovery); - - return rpc("movej", std::move(kwargs)).as_bool(); -} - -bool Arm::recover_joint_limits(double speed, double settle_s, - std::optional max_cycles, - double inset_rad) -{ - std::map kwargs; - kwargs["speed"] = LiteArmValue(speed); - kwargs["settle_s"] = LiteArmValue(settle_s); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - kwargs["inset_rad"] = LiteArmValue(inset_rad); - - return rpc("recover_joint_limits", std::move(kwargs)).as_bool(); -} - -bool Arm::home(double speed, double settle_s, - std::optional max_cycles) -{ - std::map kwargs; - kwargs["speed"] = LiteArmValue(speed); - kwargs["settle_s"] = LiteArmValue(settle_s); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - - return rpc("home", std::move(kwargs)).as_bool(); -} - -bool Arm::movel(const LiteArmValue& pose_goal, - double speed, double settle_s, - std::optional max_cycles) -{ - std::map kwargs; - kwargs["pose_goal"] = pose_goal; - kwargs["speed"] = LiteArmValue(speed); - kwargs["settle_s"] = LiteArmValue(settle_s); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - - return rpc("movel", std::move(kwargs)).as_bool(); -} - -bool Arm::movec(const LiteArmValue& pose_via, - const LiteArmValue& pose_goal, - double speed, double settle_s, - std::optional max_cycles) -{ - std::map kwargs; - kwargs["pose_via"] = pose_via; - kwargs["pose_goal"] = pose_goal; - kwargs["speed"] = LiteArmValue(speed); - kwargs["settle_s"] = LiteArmValue(settle_s); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - - return rpc("movec", std::move(kwargs)).as_bool(); -} - -bool Arm::movep(const LiteArmValue& poses_goal, - double speed, double settle_s, - std::optional max_cycles) -{ - std::map kwargs; - kwargs["poses_goal"] = poses_goal; - kwargs["speed"] = LiteArmValue(speed); - kwargs["settle_s"] = LiteArmValue(settle_s); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - - return rpc("movep", std::move(kwargs)).as_bool(); -} - -bool Arm::replay_joint_path(const std::vector>& q_path, - double speed, double settle_s, - bool goto_start, double goto_speed, - std::optional max_cycles) -{ - std::map kwargs; - kwargs["q_path"] = LiteArmValue::from_mat(q_path); - kwargs["speed"] = LiteArmValue(speed); - kwargs["settle_s"] = LiteArmValue(settle_s); - kwargs["goto_start"] = LiteArmValue(goto_start); - kwargs["goto_speed"] = LiteArmValue(goto_speed); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - - return rpc("replay_joint_path", std::move(kwargs)).as_bool(); -} - -bool Arm::replay_trajectory(const JointTrajectory& traj, - double speed, bool goto_start, - double goto_speed, - std::optional max_cycles, - bool check_singularity) -{ - return replay_trajectory(traj.to_value(), speed, goto_start, - goto_speed, max_cycles, check_singularity); -} - -bool Arm::replay_trajectory(const LiteArmValue& traj_q, - double speed, bool goto_start, - double goto_speed, - std::optional max_cycles, - bool check_singularity) -{ - std::map kwargs; - kwargs["traj_q"] = traj_q; - kwargs["speed"] = LiteArmValue(speed); - kwargs["goto_start"] = LiteArmValue(goto_start); - kwargs["goto_speed"] = LiteArmValue(goto_speed); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - kwargs["check_singularity"] = LiteArmValue(check_singularity); - - return rpc("replay_trajectory", std::move(kwargs)).as_bool(); -} - -bool Arm::replay_timed_trajectory( - const std::vector>& traj_q, - const std::vector& traj_t, - double speed, bool goto_start, double goto_speed, - double simplify_tolerance_rad, std::optional max_cycles) -{ - std::map kwargs; - kwargs["traj_q"] = LiteArmValue::from_mat(traj_q); - kwargs["traj_t"] = LiteArmValue::from_vec(traj_t); - kwargs["speed"] = LiteArmValue(speed); - kwargs["goto_start"] = LiteArmValue(goto_start); - kwargs["goto_speed"] = LiteArmValue(goto_speed); - kwargs["simplify_tolerance_rad"] = LiteArmValue(simplify_tolerance_rad); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - - return rpc("replay_timed_trajectory", std::move(kwargs)).as_bool(); -} - -bool Arm::play_trajectory(const JointTrajectory& trajectory, - double speed, bool goto_start, - double goto_speed, bool verify_robot, - double simplify_tolerance_rad, - std::optional max_cycles) -{ - std::map kwargs; - kwargs["trajectory"] = trajectory.to_value(); - kwargs["speed"] = LiteArmValue(speed); - kwargs["goto_start"] = LiteArmValue(goto_start); - kwargs["goto_speed"] = LiteArmValue(goto_speed); - kwargs["verify_robot"] = LiteArmValue(verify_robot); - kwargs["simplify_tolerance_rad"] = LiteArmValue(simplify_tolerance_rad); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - - return rpc("play_trajectory", std::move(kwargs)).as_bool(); -} - -bool Arm::play_trajectory(const std::string& trajectory, - double speed, bool goto_start, - double goto_speed, bool verify_robot, - double simplify_tolerance_rad, - std::optional max_cycles) -{ - std::map kwargs; - kwargs["trajectory"] = LiteArmValue(trajectory); - kwargs["speed"] = LiteArmValue(speed); - kwargs["goto_start"] = LiteArmValue(goto_start); - kwargs["goto_speed"] = LiteArmValue(goto_speed); - kwargs["verify_robot"] = LiteArmValue(verify_robot); - kwargs["simplify_tolerance_rad"] = LiteArmValue(simplify_tolerance_rad); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - - return rpc("play_trajectory", std::move(kwargs)).as_bool(); -} - -JointTrajectory Arm::record_trajectory( - const std::string& output, - std::optional duration_s, - double sample_rate_hz, - double filter_alpha, - std::optional name) -{ - std::map kwargs; - kwargs["output"] = LiteArmValue(output); - kwargs["duration_s"] = duration_s - ? LiteArmValue(*duration_s) - : LiteArmValue(nullptr); - kwargs["sample_rate_hz"] = LiteArmValue(sample_rate_hz); - kwargs["filter_alpha"] = LiteArmValue(filter_alpha); - kwargs["name"] = name - ? LiteArmValue(*name) - : LiteArmValue(nullptr); - - auto result = rpc("record_trajectory", std::move(kwargs)); - return JointTrajectory::from_value(result); -} - -bool Arm::hold(double kp_scale, std::optional max_cycles) { - std::map kwargs; - kwargs["kp_scale"] = LiteArmValue(kp_scale); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - - return rpc("hold", std::move(kwargs)).as_bool(); -} - -bool Arm::zero_gravity(std::optional max_cycles, - std::optional duration_s, - std::optional measured_overspeed_factor, - std::optional> vel_max) -{ - std::map kwargs; - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - kwargs["duration_s"] = duration_s - ? LiteArmValue(*duration_s) - : LiteArmValue(nullptr); - kwargs["measured_overspeed_factor"] = measured_overspeed_factor - ? LiteArmValue(*measured_overspeed_factor) - : LiteArmValue(nullptr); - kwargs["vel_max"] = vel_max - ? LiteArmValue::from_vec(*vel_max) - : LiteArmValue(nullptr); - - return rpc("zero_gravity", std::move(kwargs)).as_bool(); -} - -bool Arm::joint_impedance(const std::vector& q_des, - const LiteArmValue& K, - const LiteArmValue& B, - std::optional tau_max, - double engage_sec, - std::optional max_cycles) -{ - std::map kwargs; - kwargs["q_des"] = LiteArmValue::from_vec(q_des); - kwargs["K"] = K; - kwargs["B"] = B; - kwargs["tau_max"] = tau_max ? *tau_max : LiteArmValue(nullptr); - kwargs["engage_sec"] = LiteArmValue(engage_sec); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - - return rpc("joint_impedance", std::move(kwargs)).as_bool(); -} - -bool Arm::cartesian_impedance(const std::vector& q_des, - const LiteArmValue& K_cart, - const LiteArmValue& B_cart, - std::optional v_des, - std::optional tau_max, - double engage_sec, - std::optional max_cycles, - std::optional sigma_min_thresh, - std::optional max_ori_err, - std::optional measured_overspeed_factor, - std::optional> vel_max) -{ - std::map kwargs; - kwargs["q_des"] = LiteArmValue::from_vec(q_des); - kwargs["K_cart"] = K_cart; - kwargs["B_cart"] = B_cart; - kwargs["v_des"] = v_des ? *v_des : LiteArmValue(nullptr); - kwargs["tau_max"] = tau_max ? *tau_max : LiteArmValue(nullptr); - kwargs["engage_sec"] = LiteArmValue(engage_sec); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - kwargs["sigma_min_thresh"] = sigma_min_thresh - ? LiteArmValue(*sigma_min_thresh) - : LiteArmValue(nullptr); - kwargs["max_ori_err"] = max_ori_err - ? LiteArmValue(*max_ori_err) - : LiteArmValue(nullptr); - kwargs["measured_overspeed_factor"] = measured_overspeed_factor - ? LiteArmValue(*measured_overspeed_factor) - : LiteArmValue(nullptr); - kwargs["vel_max"] = vel_max - ? LiteArmValue::from_vec(*vel_max) - : LiteArmValue(nullptr); - - return rpc("cartesian_impedance", std::move(kwargs)).as_bool(); -} - -bool Arm::joint_follow(std::optional K, - std::optional B, - std::optional speed_limit, - std::optional accel_limit, - double engage_sec, - std::optional max_cycles, - std::optional duration_s) -{ - std::map kwargs; - kwargs["K"] = K ? *K : LiteArmValue(nullptr); - kwargs["B"] = B ? *B : LiteArmValue(nullptr); - kwargs["speed_limit"] = speed_limit ? *speed_limit : LiteArmValue(nullptr); - kwargs["accel_limit"] = accel_limit ? *accel_limit : LiteArmValue(nullptr); - kwargs["engage_sec"] = LiteArmValue(engage_sec); - kwargs["max_cycles"] = max_cycles - ? LiteArmValue(static_cast(*max_cycles)) - : LiteArmValue(nullptr); - kwargs["duration_s"] = duration_s - ? LiteArmValue(*duration_s) - : LiteArmValue(nullptr); - - return rpc("joint_follow", std::move(kwargs)).as_bool(); -} - -// ── State reading ─────────────────────────────────────────────────────────── - -std::optional Arm::get_state(bool /*refresh*/) { - auto raw = state_sub_->drain_latest(); - if (raw.has_value()) { - last_state_ = decode_state(*raw); - } - return last_state_; -} - -std::pair, std::vector>> -Arm::get_tcp_pose() { - auto result = rpc("get_tcp_pose"); - const auto& list = result.as_list(); - return {list[0].to_vec(), list[1].to_mat()}; -} - -// ── Emergency stop ────────────────────────────────────────────────────────── - -void Arm::request_stop() { - tp_->pub(estop_topic_, encode_estop()); -} - -void Arm::clear_stop() { - rpc("clear_stop"); -} - -// ── Parameter tuning ──────────────────────────────────────────────────────── - -LiteArmValue Arm::set_gains(std::optional kp, - std::optional kd) -{ - std::map kwargs; - kwargs["kp"] = kp ? *kp : LiteArmValue(nullptr); - kwargs["kd"] = kd ? *kd : LiteArmValue(nullptr); - return rpc("set_gains", std::move(kwargs)); -} - -LiteArmValue Arm::get_gains() { - return rpc("get_gains"); -} - -LiteArmValue Arm::clear_faults() { - return rpc("clear_faults"); + const auto found = find_cdc_port(); + if (found) p = *found; + } + if (p.empty()) { + throw TransportError("未找到 STM32 CDC (VID:PID 1d50:606f), 请显式指定 port"); + } + // 唯一的传输构造点 (reconnect = close + connect, 故也走这里)。 + // 注入了工厂就用工厂 —— 离线与真机走同一条会话装配路径。 + live_port_ = p; // `port_string()` 报的就是它 (构造入参可能压根是空的) + tr_ = transport_factory_ ? transport_factory_(p) + : std::unique_ptr(new SerialTransport(p)); + a_ = std::make_unique(this); + // fork 守卫的落章点 —— 本会话属于这个进程。位置: 会话对象一建好就落, 早于握手写帧, + // 也早于读线程起。 + pid_ = current_pid(); + // 读线程的起点只有这一处合法位置 —— Ack 已就绪、而下面那句握手的 expect 必须已经有人读。 + a_->start_reader(tr_.get()); + // 新会话 = 新配对状态。重建要做两半, 缺一半都会出问题: + // 1. 旧对象走一次 clear_pending —— 把在途 token 标成"结局未知"并唤醒仍在等它的线程; + // 2. 把旧的**在途条数**作为吸收额度继承给新对象 —— 旧会话那几条仍可能欠着应答, + // 新对象一格额度都没有的话, 那条迟到应答一到就撞上"队列为空"判据, LiteArmError + // 会从毫不相干的读路径里炸出来, 归因指向"固件与主机已错配", 真相是"我们自己重建 + // 了会话"。 + // 继承那一格的射程只有一条命令: 被吃掉的那条请求随后超时放弃, 而超时路径不再给它补 + // 额度 => 第二条起恢复正常。 + const int inflight = cart_ ? cart_->pending() : 0; + if (cart_) { + cart_->clear_pending("会话重建 (connect) —— 固件侧不会再发 0x4E, 结局未知"); + } + cart_ = std::make_unique(cart_absorb_ttl(move_timeout), inflight, this); + + // 握手: 固件版本 + raw_write(proto::CMD_GET_FIRMWARE); + try { + const auto r = a_->expect(proto::RSP_FIRMWARE, 1.5, "get_firmware", true, + proto::CMD_GET_FIRMWARE); + firmware_ = trim(std::string(r.payload.begin(), r.payload.end())); + } catch (...) { + close(); + throw; + } + const auto ver = proto::parse_firmware_version(firmware_); + if (!ver || firmware_.rfind(FIRMWARE_PREFIX, 0) != 0) { + close(); + throw FirmwareMismatchError( + "固件版本不符合约定: '" + firmware_ + "' —— 应为 " + FIRMWARE_PREFIX + + "<主.次.修>-{7J|1J}; 请烧录 >=" + FIRMWARE_PREFIX + + std::to_string(min_firmware_.major) + "." + + std::to_string(min_firmware_.minor) + "." + + std::to_string(min_firmware_.patch)); + } + if (*ver < min_firmware_) { + close(); + throw FirmwareMismatchError( + "固件 " + firmware_ + " 过旧: 需 >=" + FIRMWARE_PREFIX + + std::to_string(min_firmware_.major) + "." + + std::to_string(min_firmware_.minor) + "." + + std::to_string(min_firmware_.patch) + " (move_j 受控静止保持语义)"); + } + fw_version_ = *ver; + // 等待一帧状态定关节数 + const auto st = read_status(1.0); + if (!st) { + close(); + throw TransportError("连上但收不到状态帧"); + } + n_ = st->n(); + // 笛卡尔能力探测 —— 连接时一次, 不是首次调用时才探。 + // 探测本身不抛"固件不支持" (它只是返回 false); 能从上面逃出来的是写失败 (链路问题) + // —— 与版本握手失败同级: 关链路再抛, 不留半开的会话。 + try { + cart_supported_ = probe(this); + } catch (...) { + close(); + throw; + } + // ★★ 最后一步: 建软限缓存 (供 `precheck_q_` / `home` 用)。 + // ⚠ 位置在**版本门禁与能力探测之后**、且**在 try 之外** —— 它自己全程吞异常做 + // fail-open, 把它圈进上面的 try 只会让"限位读不到"变成"连接失败"。 + // ⚠⚠ 这一步会给连接期**多出 n 条 `0x24`**。参照实现(Python)的 connect() **不读软限** + // ⇒ 这是**与 Python 分叉**的一处, 已登记。跨语言对拍时连接期的下行两侧本来就不同名, + // 故对拍一律**从 connect 之后开始录**(见 /tmp/gen_conformance.py)。 + refresh_limits(); + return *this; } -namespace { -std::string json_list(const std::vector& v) { - std::string s = "["; - for (size_t i = 0; i < v.size(); ++i) { - if (i) s += ","; - s += std::to_string(v[i]); +Arm& Arm::reconnect() { + close(); + return connect(); +} + +Arm& Arm::reconnect(const std::string& port) { + close(); + return connect(port); +} + +void Arm::close() { + // 第一步收保活线程必须是第一步 (它持有一个写者; 残留会让进程退出时打哑 CDC)。 + // 收不干净 (串口写卡住) 时 zero_g_stop 会抛错 —— teardown 不能因此挂死或半途而废, + // 这里吞掉异常后照常关链路。 + if (pid_ != -1 && current_pid() != pid_) { + // fork 出来的子进程里, 本方法**不做传输层收尾** —— 那会永久挂死。三条**独立**的 + // 死锁, 各自实测过, 缺一条都不足以解释: + // * `SerialTransport::close` 要取 rlock, 而父进程的读线程几乎一直持着它 => + // 子进程继承到一把**已加锁**的互斥量, 而能解锁的那个线程在子进程里不存在; + // * `Ack` 的析构会 join 一条在子进程里**不存在**的线程 (join 永久阻塞); + // * ⚠⚠ **最隐蔽也最要紧的一条**: `Ack` 析构时必然拆掉它的两个 + // `std::condition_variable` 成员, 而父进程的读线程正停在 `stop_cv_` 上 —— + // glibc 的 `pthread_cond_destroy` 会去取 condvar 的内部锁, 那把锁与已不存在的 + // 等待者绑在一起 ⇒ **永久阻塞**。最小复现与逐项剥离见 `Ack::~Ack` 的注释。 + // ⇒ 这一条**躲不开**: 它在成员析构里, 只要 `~Ack` 跑起来就会发生。 + // 故这里必须 `release()` (不析构), 而**不是**靠 `Ack` 里的属主守卫。 + // 故这里**故意泄漏**两个句柄 (release 而不是 reset): + // * 传输出去的那个继承来的 fd 留给进程退出时由内核关闭 —— 它既不读也不写 + // (守卫把命令全拒了), 父进程也另有自己的 fd, 故留着是无害的; + // * ⚠ **`a_.release()` 这一句是载重的, 不是"顺手再加一层保险"**: 改成 + // `a_.reset()` 会立刻把上面第三条死锁引回来 (而 `Ack` 里的属主守卫拦不住它)。 + // 删它之前请先读 `Ack::~Ack` 的注释。 + // ⚠ 会话状态照清, 且**不取任何锁** (`clear_session_state` 会取 tx 表那把锁, 而 + // 零重力保活线程可能正持着它): 子进程里那几张表留着也无害。 + a_.release(); + tr_.release(); + firmware_.clear(); + fw_version_.reset(); + n_ = 0; + cart_supported_ = false; + cart_probe_silent_ = false; + pid_ = -1; + return; + } + if (zg_thread_.joinable() || zg_active_) { + try { + zero_g_stop(false); + } catch (...) { + // 关链路时保活故障不该阻断关闭 + } + } + // 停读线程必须排在 tr_->close() 之前: 否则它会从已经关掉的传输上抛 TransportError + // => reader_error 非空 => 一次正常关闭被记成"链路丢失"。 + if (a_) a_->stop_reader(); + if (tr_) { + tr_->close(); + tr_.reset(); + a_.reset(); + } + clear_session_state(); +} + +void Arm::clear_session_state() { + // 清会话派生的状态 —— 关掉之后不许再报上一个会话的事实。 + firmware_.clear(); + fw_version_.reset(); + n_ = 0; + cart_supported_ = false; + cart_probe_silent_ = false; + // fork 守卫的落章与会话同寿 —— 清掉之后守卫放行, 但 a_/tr_ 也在同一次 close 里清成 + // 空 => require() 抛 NotConnectedError, 两条路等价。 + pid_ = -1; + live_port_.clear(); + // ⚠ 这里原来还有一段"清同帧节流的时间戳表"—— 节流整套已删 (2026-09-28, 见 README), + // 那段随之消失。它当年存在的理由是"新会话第一帧不该撞上上个会话留下的时刻戳而 + // 被静默丢掉"; 现在**没有能丢掉帧的机制**, 那条理由本身不存在了。 +} + +// =========================================================================== +// 守卫 +// =========================================================================== + +void Arm::reject_if_in_dfu() const { + if (!dfu_entered_) return; + throw ArmIsInDfuError( + "本 Arm 已交棒进 DFU (会话终态): 设备当时从 CDC 上消失, 此后所有会走到取帧/写帧" + "收口的入口都抛本异常 (含 move_js/send_mit/send_mit_all 这类带本地 arity/idx " + "预检的)。参数本身已非法时, 入口自己的本地预检会先抛 InvalidCommandError —— " + "那是另一件事 (与终态无关), 不是说终态还能接着用。" + "烧完固件请新建一个 Arm 连新固件 (本对象不会复活)"); +} + +void Arm::reject_if_wrong_process() const { + const int pid = pid_; + if (pid != -1 && current_pid() != pid) { + throw ForkedSessionError( + "本会话是在 PID " + std::to_string(pid) + " 里建立的, 当前进程是 " + + std::to_string(current_pid()) + + " —— fork 出的子进程不继承线程 (读线程不存在), 命令会真的写出去却永远等不到" + "应答, 调用方只会看到'无应答'超时 (读状态更隐蔽: 静默回继承来的陈旧值)。" + "故本包在这里 fail-closed: 一个字节都不下发。" + "子进程要用臂请新建一个 Arm; close() 仍可调, 收尾不受影响"); } - s += "]"; - return s; } -} // namespace -void Arm::send_mit(const std::vector& kp, const std::vector& kd, - const std::vector& q_ref, const std::vector& dq_ref, - const std::vector& tau_ff) { - std::string frame = R"({"type":"mit","client_id":")" + client_id_ + - R"(","seq":)" + std::to_string(seq_++) + - R"(,"ts":)" + std::to_string( - std::chrono::duration( - std::chrono::system_clock::now().time_since_epoch()).count()) + - R"(,"kp":)" + json_list(kp) + - R"(,"kd":)" + json_list(kd) + - R"(,"q_ref":)" + json_list(q_ref) + - R"(,"dq_ref":)" + json_list(dq_ref) + - R"(,"tau_ff":)" + json_list(tau_ff) + "}"; - tp_->pub(command_topic_, frame); -} - -LiteArmValue Arm::set_guards(std::optional slew_limit, - std::optional tau_max, - std::optional watchdog_timeout, - std::optional position_bounds, - std::optional velocity_bounds, - std::optional jerk_limit) { - std::map kw; - if (slew_limit) kw["slew_limit"] = *slew_limit; - if (tau_max) kw["tau_max"] = *tau_max; - if (watchdog_timeout) kw["watchdog_timeout"] = *watchdog_timeout; - if (position_bounds) kw["position_bounds"] = *position_bounds; - if (velocity_bounds) kw["velocity_bounds"] = *velocity_bounds; - if (jerk_limit) kw["jerk_limit"] = *jerk_limit; - return rpc("set_guards", kw); -} - -LiteArmValue Arm::get_guards() { - return rpc("get_guards", {}); -} - -void Arm::enable() { - rpc("enable"); +void Arm::guard_for_ack_wait() { + // ① 终态必须排在"已关"之前 + reject_if_in_dfu(); + // ② fork 守卫: 唯一取帧口 + reject_if_wrong_process(); + if (a_ == nullptr) { + throw NotConnectedError("链路已关闭 (Arm::close() 之后) —— 请先 connect()"); + } } -void Arm::disable() { - rpc("disable"); +void Arm::reject_if_cart_in_flight() { + // 反向守卫 —— 笛卡尔在途时拒绝进入零重力。 + // + // 为什么拦: 固件 0x06 丢掉位置环、只剩重力前馈 => 轨迹中途进场会让臂靠摩擦滑停 + // (coast), 比 movej 的受控接管 / emergency_stop 差 => 拦住它并把替代动作写进消息。 + // 只拦这一侧: movej/movej_sync 是受控接管; zero_g_stop/emergency_stop/disable 是 + // 降能量方向, 永远可达, 本方法也不碰它们。 + // + // 两条信号缺一不可: + // ① 串行锁被持有 —— 三条笛卡尔入口全程持它, 所以"被持有"= 有一次调用正在途; + // ② 现取一帧状态的 bit10 (cart_busy) 为 1 —— 臂仍在跑。 + // 只用 ① 会漏掉 wait=false (那条调用已经返回、锁已释放, 而臂仍在跑); + // 只用 ② 会漏掉"锁已被持有、规划还没起"的那一小段。 + // ② 必须现取, 不能用缓存: 那可能是很久以前的帧 —— 臂已停稳却仍报忙 = 假拒绝。 + if (!cart_serial_.try_lock()) { + throw InvalidCommandError(CART_IN_FLIGHT_GUARD_MESSAGE); + } + cart_serial_.unlock(); + try { + if (get_status_now().value.cart_busy()) { + throw InvalidCommandError(CART_IN_FLIGHT_GUARD_MESSAGE); + } + // ⚠⚠ 下面这三条 catch 是**保守拒绝**那一侧, 而它的成立依赖 `get_status_now()` 在 + // "0x40 被拒"时**确实抛**。已知缺陷 (`get_status_now` 的 done 谓词, 见那里的注释): + // 被动流的一帧若落进判据窗口, 它会**误以为命令成功**, 拿回一帧**陈旧**的状态 —— + // 于是这里 `cart_busy()` 读到 0, **守卫 fail-open 放行**。 + // 实测 TSan 下 `zero_g_is_refused_conservatively_when_the_status_is_unavailable` + // 30 次里翻红 3 次 (2026-09-28)。 + // ⇒ 语义与上游一致, 不在此处单独加宽限 (那关不严, 只会造出"已修好"的错觉); + // 缺口登记在 README「已知继承的差异/缺口」。 + } catch (const MotionTimeoutError&) { + throw InvalidCommandError(CART_IN_FLIGHT_UNCONFIRMED_MESSAGE); + } catch (const TransportError&) { + throw InvalidCommandError(CART_IN_FLIGHT_UNCONFIRMED_MESSAGE); + } catch (const CommandRejectedError&) { + throw InvalidCommandError(CART_IN_FLIGHT_UNCONFIRMED_MESSAGE); + } } -LiteArmValue Arm::set_payload(double mass, const std::vector& com) { - return rpc("set_payload", { - {"mass", LiteArmValue(mass)}, - {"com", LiteArmValue::from_vec(com)}, - }); +// =========================================================================== +// 收口: require / 报文出口 +// =========================================================================== + +Ack& Arm::require() { + reject_if_in_dfu(); + reject_if_wrong_process(); + if (a_ == nullptr) throw NotConnectedError("未 connect()"); + return *a_; +} + +bool Arm::pump_until(double timeout, const std::function& done, + const std::string& label) { + // 驱动型读者的收口: 等到 done(a) 为真、或链路没了、或窗口用尽。 + // 它一条帧都不认领 —— 状态帧由读线程填进单槽, 本方法只是等单槽前进到满足条件。 + (void)label; + Ack& a = require(); + const double end = clock().now_s() + timeout; + Ack::MuLock lk(a.mu, a.dbg_mu_owner); + a.dbg_check("pump_until"); + while (true) { + if (a.reader_error) { + throw TransportError("读线程已退出: " + a.reader_error_text); + } + if (!a.errors.empty()) { + // 协议层异常 (典型: 笛卡尔收集器的"多了一条") —— 读线程把它存起来交给等待者。 + // 驱动型读者也要看它: 否则一次"配对已错位"只对队列等待者可见。 + std::exception_ptr e = a.errors.front(); + a.errors.pop_front(); + std::rethrow_exception(e); + } + if (done(a)) return true; + const double left = end - clock().now_s(); + if (left <= 0.0) return false; + a.cv.wait_for(lk.ul(), std::chrono::duration(left)); + } } -LiteArmValue Arm::get_payload() { - return rpc("get_payload"); +void Arm::raw_write(uint8_t cmd, const uint8_t* payload, size_t len) { + // 最底层写口 —— 所有下行帧的唯一出口 (含零重力保活线程)。 + // + // 保活线程刻意绕过 write_cmd (避开零重力守卫), 但不该绕过清队: 它发的 0x06 正是会 + // 静默作废在途笛卡尔规划的那一类。所以清队钩子挂在这一层。 + // + // 发出前清, 不看应答: 固件的 cart_invalidate_before_motion 是 ctrl_accept_move_j + // 的第一条语句, 位于全部门禁之前 —— 所以被 ERR 0x03/0x04/0x06 拒掉的命令同样已经作废 + // 了在途规划。写成"收到 ACK 才清队"就会在这条被拒路径上留下陈旧 token。 + // + // ⚠⚠ **2026-09-28: 这里原来还有一道"同帧节流"闸** (排在清队之前, 只对 `droppable=true` + // 的写口生效)。整套节流已删除 (参照实现里它是私有且默认全关的 ⇒ 行为上等价于无, + // 故按 A 侧的做法**不做**; 见 README)。删掉之后本函数的不变式变强了: + // **进了这个函数就一定会写出去** —— "清队了但帧没发"这种半状态**不可构造**。 + reject_if_in_dfu(); + reject_if_wrong_process(); // fork 守卫: 唯一写口, 结构性地保证"子进程零下发" + if (cart_clears_upon().count(cmd) != 0) { + char hexbuf[8]; + std::snprintf(hexbuf, sizeof(hexbuf), "%02X", cmd); + cart_->clear_pending(std::string("被 0x") + hexbuf + + " 作废 (固件侧 cart_invalidate_before_motion)"); + } + // 清本命令的应答队列必须排在写之前: 我们的应答只可能在写之后到达, 故此刻清队不可能 + // 吃掉自己的应答。反过来会 100% 吃掉。 + if (a_) a_->drain_for(cmd); + tr_->write_frame(cmd, payload, len); } -LiteArmValue Arm::set_installation( - std::optional> base_rpy, - std::optional> gravity) -{ - std::map kwargs; - kwargs["base_rpy"] = base_rpy - ? LiteArmValue::from_vec(*base_rpy) - : LiteArmValue(nullptr); - kwargs["gravity"] = gravity - ? LiteArmValue::from_vec(*gravity) - : LiteArmValue(nullptr); - return rpc("set_installation", std::move(kwargs)); +void Arm::write_query(uint8_t cmd, const uint8_t* payload, size_t len) { + // 查询类下行命令的唯一出口 (get_tcp/get_ik/参数读回/采集/自检)。 + // 与 write_cmd 的差别: 不做零重力守卫 —— 拖动示教期间仍应能读状态。 + // 两者都先 require() 再写: 未连接时必须是 NotConnectedError。 + require(); + raw_write(cmd, payload, len); } -LiteArmValue Arm::get_installation() { - return rpc("get_installation"); +void Arm::write_cmd(uint8_t cmd, const uint8_t* payload, size_t len, bool guarded) { + // 动作类下行命令的唯一出口 —— 零重力保活期在这里统一拒绝。 + // guarded=false 只给「降能量」方向的安全动作 (急停/失能) —— 它们必须永远可达。 + if (guarded && zg_active_) { + throw InvalidCommandError(ZERO_G_GUARD_MESSAGE); + } + write_query(cmd, payload, len); } -LiteArmValue Arm::set_gravity_scale(std::vector scale, - double transition_s) { - return rpc("set_gravity_scale", { - {"scale", LiteArmValue::from_vec(scale)}, - {"transition_s", LiteArmValue(transition_s)}, - }); +void Arm::cmd_expect_ack(uint8_t cmd, const uint8_t* payload, size_t len, + const std::string& label, double timeout) { + Ack& a = require(); + write_cmd(cmd, payload, len); + a.expect(proto::RSP_ACK, timeout, label, true, cmd); } -LiteArmValue Arm::get_gravity_scale() { - return rpc("get_gravity_scale"); +bool Arm::link_dead(std::string* why) const { + if (a_ == nullptr) { + if (why) *why = "会话已关闭"; + return true; + } + Ack::MuLock lk(a_->mu, a_->dbg_mu_owner); + a_->dbg_check("link_dead"); + if (a_->reader_error) { + if (why) *why = a_->reader_error_text; + return true; + } + return false; } -LiteArmValue Arm::save_gravity_scale() { - return rpc("save_gravity_scale"); +void Arm::on_cart_plan_reply(const std::vector& payload) const { + if (cart_) cart_->on_reply(payload); } -LiteArmValue Arm::save_payload() { - return rpc("save_payload"); +// =========================================================================== +// 状态读取 +// =========================================================================== + +std::optional Arm::read_status(double timeout) { + // 等到一条新的状态帧 (status_seq 前进), 返回它解码出的 state。 + Ack& a = require(); + const uint64_t seq0 = a.status_seq_now(); + pump_until(timeout, + [seq0](Ack& a) { return a.status_seq_now() != seq0; }, "状态帧"); + return a.state_copy(); } -LiteArmValue Arm::save_installation() { - return rpc("save_installation"); +Msg> Arm::get_state(bool refresh, double timeout) { + Ack& a = require(); + std::optional st; + if (!a.state_copy() || refresh) { + st = read_status(timeout); + } else { + st = a.state_copy(); + } + return wrap(st, proto::RSP_STATUS); +} + +Msg Arm::get_status_now(double timeout) { + Ack& a = require(); + write_query(proto::CMD_GET_STATUS); + if (timeout <= 0.0) { + // 不等待: 读线程一直在填, "现在这一刻的状态"就是缓存里那个。 + auto st = a.state_copy(); + if (!st) throw MotionTimeoutError("get_status_now: 还没有任何状态帧 (链路刚起?)"); + return wrap(*st, proto::RSP_STATUS); + } + const uint64_t seq0 = a.status_seq_now(); + const QueueKey err_key{proto::RSP_ERR, int(proto::CMD_GET_STATUS)}; + auto done = [&](Ack& a) { + // 本命令被拒也算"有结论": 0x40 被拒时固件回 ERR{0x40,code}。 + // 只看回显本命令码的那条: 别人的 ERR 落在别的队列里, 不是我们的结论。 + // + // ⚠⚠ **已知缺陷 (上游同款, 移植刻意保留原语义)**: 下面这后半条判据认的不是 + // "我们那条命令的应答", 而是**任何**一帧新状态帧 —— 而固件的 100Hz 被动流 + // 与我们的 `0x40` 是两条独立的线, 谁先被读线程投递没有保证。 + // 若在 `seq0` 采样与首次判据求值之间落进一帧状态帧, 判据**当场成立**, 而那条 + // `ERR{0x40,code}` 可能还排在 `resp_` 里没被投递 ⇒ 下面查一眼 ERR 队列 (空) + // 便一路落到 `state_copy()` 成功 ⇒ **固件拒了, 函数报成功**。 + // 实测: 调用期间一帧状态帧都没交付时 150/150 正确抛出; 交付了恰好 1 帧时, + // 150 次里有 26~35 次报成功。 + // + // 为什么**不在这修**: 状态帧里**没有命令回显** (那是 ACK/ERR 才有的), 所以 + // "这一帧是不是我们那条命令的应答"在协议上**不可判定**。能给"失败"分支加宽限 + // 窗口, 但关不严 —— 只是把窗口压小, 却会造出"已经修好了"的错觉, 那比明写着的 + // 已知限制更危险。另外这条路径在受支持的固件上**不可达**: `connect()` 的版本门 + // 已经挡掉了没有 `0x40` 的旧固件 (0x40 的失败只可能来自固件 default 分支)。 + // ⇒ 记在这里 + README「已知继承的差异/缺口」, 语义与上游保持一致。 + // ⚠ 共用例 (tests/test_commands.cpp) 必须先把链路静下来 (`lt::quiesce`) 再断言, + // 否则它测的是上面这个窗口, 不是 ERR 映射。 + const auto it = a.queues.find(err_key); + return (it != a.queues.end() && !it->second.empty()) || + a.status_seq_now() != seq0; + }; + pump_until(timeout, done, "get_status_now"); + { + Ack::MuLock lk(a.mu, a.dbg_mu_owner); + a.dbg_check("get_status_now"); + const auto it = a.queues.find(err_key); + if (it != a.queues.end() && !it->second.empty()) { + const auto p = it->second.front().payload; + it->second.pop_front(); + raise_firmware_error(p, "get_status_now: "); + } + } + auto st = a.state_copy(); + if (!st) { + throw MotionTimeoutError("get_status_now 无状态帧应答 (超时 " + + fnum(timeout) + "s)"); + } + return wrap(*st, proto::RSP_STATUS); +} + +Msg>> Arm::get_tcp(double timeout) { + Ack& a = require(); + write_query(proto::CMD_GET_TCP); + const auto r = a.expect(proto::RSP_TCP, timeout, "get_tcp", true, proto::CMD_GET_TCP); + if (r.payload.size() >= 24) { + const auto v = proto::unpack_f32s(r.payload.data(), 0, 6); + return wrap(std::optional>( + std::array{v[0], v[1], v[2], v[3], v[4], v[5]}), + proto::RSP_TCP); + } + return wrap(std::optional>(std::nullopt), proto::RSP_TCP); +} + +std::string Arm::last_reset_reason() { + if (!tr_) return ""; + const auto r = proto::parse_boot_banner(tr_->text_log()); + return r.value_or(""); } -// ── Peripheral devices ─────────────────────────────────────────────────────── +const std::string& Arm::port_string() const { + // 链路在 ⇒ 报**实际**端口; 否则回退到构造入参。 + // ⚠ 两者确实可能不同: `Arm()` 不带端口时 `port_` 恒空, 真正连上的是 + // `find_cdc_port()` 找到的那个。见 arm.hpp 里这处的说明。 + return live_port_.empty() ? port_ : live_port_; +} + +// ---------------------------------------------------------------- 只读访问器 +// (⚠ 与 Python 参照分叉的加法 —— 参照侧一个都没有; 见 arm.hpp) + +bool Arm::is_connected() const { + if (tr_ == nullptr || a_ == nullptr) return false; + if (!tr_->is_open()) return false; + if (a_->reader_error) return false; // 读线程死了 + // 线程活着, 但对方可能**早就不发字节了** —— 用"最后一帧状态帧有多旧"判。 + // ⚠ 两个条件缺一不可: 只看指针会把"读线程死了"读成健康; 只看"读到过帧"会把 + // "连着但不再发"读成健康。 + const auto st = a_->recv_stats(proto::RSP_STATUS); + if (st.second <= 0.0) return false; // 这一会话从没成功收到过状态帧 + return (clock().now_s() - st.second) < STATUS_STALE_MAX_S; +} + +uint64_t Arm::status_seq() const { return a_ == nullptr ? 0 : a_->status_seq_now(); } -RemoteDevice Arm::device(const std::string& device_id) { - if (!devices_) { - devices_ = std::make_shared( - [this](const std::string& method, - std::map kwargs) { - return rpc(method, std::move(kwargs)); - }); +double Arm::msg_hz(uint8_t id) const { + return a_ == nullptr ? 0.0 : a_->recv_stats(id).first; +} + +std::optional Arm::banner_version() const { + if (tr_ == nullptr) return std::nullopt; + const std::string log = tr_->text_log(); + // ⚠ 锚是**大小写敏感**的 "Litearm" —— 开机横幅里另有一处小写的 + // `[litearm-usbcdc]`, 不能被它带跑。 + const size_t p = log.find(FIRMWARE_PREFIX); + if (p == std::string::npos) return std::nullopt; + size_t e = p; + while (e < log.size()) { + const char c = log[e]; + if (!(std::isalnum(static_cast(c)) || c == '.' || c == '-')) break; + ++e; + } + const std::string tok = log.substr(p, e - p); + // ★ 必须**真的能解析成版本串**才认。只找到前缀不算 —— 那会把 "Litearm" 这种半截 + // 东西当版本报出去, 而本函数的全部用处就是"告诉现场板子上跑的是哪一版"。 + if (!proto::parse_firmware_version(tok).has_value()) return std::nullopt; + return tok; +} + +HostStats Arm::host_stats() const { + HostStats s; + if (a_ != nullptr) { + Ack::MuLock lk(a_->mu, a_->dbg_mu_owner); + a_->dbg_check("host_stats"); + s.dropped = a_->dropped; + s.bad_status_frames = a_->bad_status_frames; } - return devices_->get(device_id); + if (tr_ != nullptr) s.flush_failures = tr_->flush_failures(); + if (cart_ != nullptr) { + s.cart_evicted_unclaimed = cart_->evicted_unclaimed; + s.cart_extra_replies = cart_->extra_replies; + } + s.cart_probe_silent = cart_probe_silent_; + return s; } -DeviceManager& Arm::devices() { - if (!devices_) { - devices_ = std::make_shared( - [this](const std::string& method, - std::map kwargs) { - return rpc(method, std::move(kwargs)); - }); +ArmOptions Arm::options() const { + // ⚠ 逐次组装成**现值**, 不是构造期那份死快照 —— 理由见 arm.hpp 里这条的说明。 + ArmOptions o; + o.port = port_.empty() ? std::nullopt : std::optional(port_); + o.transport_factory = transport_factory_; + // ⚠ 报的是**注入的那一份** (`clock_`), 没注入时是空 —— 不回报 `clock()` 那个 + // "回落到 SteadyClock" 的结果: 那会让人以为有人显式注入过。 + o.clock = clock_; + o.min_firmware = min_firmware_; + o.q_tol = q_tol; + o.dq_tol = dq_tol; + o.arrive_frames = arrive_frames; + o.move_timeout = move_timeout; + return o; +} + +// =========================================================================== +// 使能 / 安全 +// =========================================================================== + +void Arm::enable(int attempts) { + // 只有固件明说"可重试"的码才重试 —— 判据是白名单 (= {0x03}), 不是"除锁存外都重试"。 + // 每次重试之间 sleep(0.3), 12 次尝试只有 11 次 sleep 约 3.3 秒。 + Ack& a = require(); + for (int i = 0; i < attempts; ++i) { + write_cmd(proto::CMD_ENABLE); + try { + a.expect(proto::RSP_ACK, 1.0, "enable(" + std::to_string(i + 1) + ")", true, + proto::CMD_ENABLE); + return; + } catch (const CommandRejectedError& e) { + // echo_cmd 保证到达这里的 ERR 回显的就是 0x10, 故 e.code 就是判据那一列。 + if (i >= attempts - 1 || + enable_retryable_codes().count(e.code) == 0) { + throw; + } + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + } } - return *devices_; + throw LiteArmError("enable 超时"); } -// ── Server extension RPCs ──────────────────────────────────────────────────── +void Arm::disable() { + // 同样绕过零重力守卫 (降能量方向)。 + write_cmd(proto::CMD_DISABLE, nullptr, 0, false); + require().expect(proto::RSP_ACK, 1.2, "disable", true, proto::CMD_DISABLE); +} -LiteArmValue Arm::get_system_stats() { - return rpc("get_system_stats"); +void Arm::emergency_stop() { + // 绕过零重力守卫 (降能量方向的安全动作必须永远可达)。 + write_cmd(proto::CMD_EMERGENCY_STOP, nullptr, 0, false); + require().expect(proto::RSP_ACK, 1.2, "emergency_stop", true, + proto::CMD_EMERGENCY_STOP); } -LiteArmValue Arm::get_logs(int page, int size, const std::string& search) { - return rpc("get_logs", { - {"page", LiteArmValue(static_cast(page))}, - {"size", LiteArmValue(static_cast(size))}, - {"search", LiteArmValue(search)}, - }); +void Arm::reset() { cmd_expect_ack(proto::CMD_RESET, nullptr, 0, "reset"); } + +void Arm::clear_faults() { + cmd_expect_ack(proto::CMD_CLEAR_FAULTS, nullptr, 0, "clear_faults"); } -LiteArmValue Arm::restart_service() { - return rpc("restart_service"); +void Arm::set_motion_mode(int mode) { + if (mode < 0 || mode > 255) { + throw InvalidCommandError("mode 需 0..255 (给的是 " + std::to_string(mode) + ")"); + } + if (mode != 0) { + // 本固件只识别 0 —— 其它值固件回 ACK 但模式不变。响亮失败优于静默无效。 + throw InvalidCommandError( + "set_motion_mode(" + std::to_string(mode) + + "): 本固件只识别 0 (park 声明) —— 其它值固件回 ACK 但模式不变 " + "(ctrl_set_motion_mode 只写 park_requested, 不写 g_arm.mode); " + "要声明 park 请用 arm.park()"); + } + const uint8_t p = uint8_t(mode); + cmd_expect_ack(proto::CMD_SET_MOTION_MODE, &p, 1, "set_motion_mode"); } -LiteArmValue Arm::reconnect() { - return rpc("reconnect"); +void Arm::park() { set_motion_mode(0); } + +void Arm::set_speed(int percent) { + if (percent < 0 || percent > 100) { + throw InvalidCommandError( + "percent 需整数百分比 0..100; 若手里是 0..1 的倍率请乘 100"); + } + const uint8_t p = uint8_t(percent); + cmd_expect_ack(proto::CMD_SET_SPEED_PERCENT, &p, 1, "set_speed"); +} + +// =========================================================================== +// 零重力 +// =========================================================================== + +ZeroGSession Arm::zero_g(double period) { + zero_g_start(period); + return ZeroGSession(this); +} + +void Arm::zero_g_start(double period) { + if (!(period >= 0.005 && period < 0.10)) { + throw InvalidCommandError( + "保活周期需 ∈[0.005, 0.10) —— 固件看门狗超时是 0.10s"); + } + // 反向守卫排在 zg_lock 之外: 它要读一帧状态 (上限 get_status_now 的 timeout), + // 圈进锁里就会让 zero_g_stop / close 陪着等 —— 降能量方向的退出动作不该被一次查询拖住。 + // 位置仍在写 0x06 之前、置 zg_active 之前, 两条都满足。 + if (!zg_active_) { + reject_if_cart_in_flight(); + } + std::lock_guard lk(zg_lock_); + if (zg_active_) return; // 已激活 = 幂等 no-op: 根本不写 0x06, 无 coast 风险 + const uint8_t on = 0x01; + cmd_expect_ack(proto::CMD_ZERO_G, &on, 1, "zero_g(on)"); // 先确认进入再开线程 + zg_error_ = nullptr; + zg_error_text_.clear(); + zg_stop_.store(false); + // 顺序要紧: 先置 active (守卫立刻生效) -> 起线程 -> 最后才发布句柄。 + // 反过来时, 插进来的 zero_g_stop 会 join 一个尚未启动的 thread, 且异常在置 + // active=false 之前抛出 => active 永久卡 true: 保活一帧不发、动作命令被守卫永久拒绝。 + zg_active_ = true; + zg_thread_ = std::thread(&Arm::zg_keepalive, this, period); +} + +void Arm::zg_keepalive(double period) { + // 保活线程: 只写不读。 + // + // 不读是刻意的 —— 主线程正在等某条命令的 ACK, 保活线程若去读串口就会把那条 ACK 抢走, + // 制造难查的偶发超时。 + // + // 写走 raw_write (而不是直写 tr_): 它绕开的是零重力守卫, 不该顺带绕开清队 —— + // 保活每 40ms 一条 0x06 正是会静默作废在途笛卡尔规划的那一类。 + while (!zg_stop_.load()) { + std::this_thread::sleep_for(std::chrono::duration(period)); + if (zg_stop_.load()) return; + try { + const uint8_t on = 0x01; + // ⚠ 这里原来是全包唯一一处 `droppable=true` (声明"丢了也无所谓、可被节流")。 + // 同帧节流整套已删 (2026-09-28) ⇒ 本帧**一定会写出去**; 保活的节奏由上面那句 + // `sleep_for(period)` 单独决定, 没有第二个人在丢它的帧。 + raw_write(proto::CMD_ZERO_G, &on, 1); + } catch (...) { + // 写失败 = 保活已断, 必须让调用方知道。 + // ⚠ 这里只取 zg_err_mu_, **不能**取 zg_lock_ —— zero_g_stop 持着 zg_lock_ + // 等 join, 本线程再取它就是死锁。 + { + std::lock_guard lk(zg_err_mu_); + zg_error_ = std::current_exception(); + try { + std::rethrow_exception(zg_error_); + } catch (const std::exception& ex) { + zg_error_text_ = ex.what(); + } catch (...) { + zg_error_text_ = "未知保活错误"; + } + } + zg_active_.store(false); + zg_stop_.store(true); + return; + } + } +} + +void Arm::zero_g_stop(bool raise_on_lost) { + std::lock_guard lk(zg_lock_); + const bool was_session = zg_thread_.joinable() || zg_active_.load(); + // 先置 stop 再 join: 线程在 period 到点后会看到它并退出。 + zg_stop_.store(true); + zg_active_.store(false); + if (zg_thread_.joinable()) { + // std::thread 没有超时 join, 故这里是无界 join —— 但它是有界的: 保活线程要么在 + // sleep(period) (period < 0.10s), 要么在一次写里 (写有 0.5s 硬上界)。 + zg_thread_.join(); + } + std::exception_ptr err; + { + std::lock_guard lk(zg_err_mu_); + err = zg_error_; + zg_error_ = nullptr; + zg_error_text_.clear(); + } + std::exception_ptr exit_err; + // 从未进入过就不发任何 0x06 —— 幂等退出不等于无中生有。 + if (was_session && tr_) { + try { + const uint8_t off = 0x00; + write_cmd(proto::CMD_ZERO_G, &off, 1); // 此刻 inactive, 守卫放行 + } catch (...) { + exit_err = std::current_exception(); + } + } + if (raise_on_lost) { + // 保活中断优先于退出帧失败上报 (前者才是臂为什么脱离零重力的原因) + if (err) std::rethrow_exception(err); + if (exit_err) std::rethrow_exception(exit_err); + } +} + +bool Arm::zero_g_active() const { return zg_active_.load(); } + +std::exception_ptr Arm::zero_g_error() { + std::lock_guard lk(zg_err_mu_); + return zg_error_; +} + +std::string Arm::zero_g_error_text() { + std::lock_guard lk(zg_err_mu_); + return zg_error_text_; +} + +// =========================================================================== +// 到位等待 / 关节运动 +// =========================================================================== + +RobotState Arm::arrive(const std::vector& target, double timeout) { + Ack& a = require(); + const std::vector tgt = target; + const double budget = (timeout < 0.0) ? move_timeout : timeout; + auto done = [&](const RobotState& st) { + if (int(st.joints.size()) != int(tgt.size())) return false; + const auto qs = st.q(); + for (size_t i = 0; i < tgt.size(); ++i) { + if (std::fabs(qs[i] - tgt[i]) >= q_tol) return false; + } + for (double d : st.dq()) { + if (std::fabs(d) >= dq_tol) return false; + } + return true; + }; + int n_ok = 0; // 连续到位拍数 (防到位瞬间误判/抖动) + uint64_t seq0 = a.status_seq_now(); + const double end = clock().now_s() + budget; + Ack::MuLock lk(a.mu, a.dbg_mu_owner); + a.dbg_check("arrive"); + while (true) { + if (a.reader_error) { + throw TransportError("读线程已退出: " + a.reader_error_text); + } + if (a.state && a.status_seq_now() != seq0) { + seq0 = a.status_seq_now(); + if (a.state->faulted()) { + throw MotorFaultError("未到位即故障: FAULT " + a.state->fault_detail()); + } + n_ok = done(*a.state) ? n_ok + 1 : 0; + if (n_ok >= arrive_frames) return *a.state; + } + const double left = end - clock().now_s(); + if (left <= 0.0) break; + a.cv.wait_for(lk.ul(), std::chrono::duration(left)); + } + throw MotionTimeoutError("未到位, 超时 " + fnum(budget) + "s"); +} + +// =========================================================================== +// 客户端预检 (⚠ 与 Python 参照分叉 —— 见 arm.hpp 与 README) +// =========================================================================== + +void Arm::precheck_q_(const std::vector& q, const char* who) const { + // ★★ ① **非有限值 ⇒ 抛**。⚠⚠ 它**必须排在"缓存空 ⇒ 放行"之前**: + // NaN 与**任何**数的比较都为假 ⇒ 若让它走到下面那两条越限比较, 会**静默放行**; + // 而 NaN 进到固件的 `clampf` 同样是 UB。这一条**与软限位无关** —— 缓存空也要拦。 + for (size_t i = 0; i < q.size(); ++i) { + if (!std::isfinite(q[i])) { + throw InvalidCommandError( + std::string(who) + ": 第 " + std::to_string(i + 1) + " 轴的目标不是有限数 (" + + gnum(q[i]) + + ") —— 非有限值会让所有越限比较为假 (静默放行), 拒绝"); + } + } + // ★ ② **缓存空 ⇒ 放行** (fail-open)。一条限位都没拿到 ⇒ 不预检, 由固件钳制 + 到位判据兜。 + // fail-closed 会让**所有**运动命令全废 —— 那是更坏的错误。 + if (limits_.empty()) return; + // ★ ③ **长度不匹配 ⇒ 放行**: 拿到的限位不是这台臂的 ⇒ 判不了, 保守取"不拦"。 + // ⚠ 按索引硬比会把目标与**别的轴**的限位配在一起 —— 那比不判更坏。 + // ⚠ move_p 走的就是这一支 (位姿恒 6 个数, 而 limits_ 是 n 条) ⇒ 对 it 而言 + // 本函数**只兜非有限值**那一条。别把这段读成"笛卡尔目标也越限检查过了"。 + if (limits_.size() != q.size()) return; + // ★ ④ 逐轴比; 越限 ⇒ 抛, 文案**点名轴号 / 目标值 / 限位** (现场定位用)。 + for (size_t i = 0; i < q.size(); ++i) { + if (q[i] < limits_[i].q_min || q[i] > limits_[i].q_max) { + throw InvalidCommandError(std::string(who) + ": 第 " + std::to_string(i + 1) + + " 轴目标 " + gnum(q[i]) + " 越软限 [" + + gnum(limits_[i].q_min) + ", " + gnum(limits_[i].q_max) + + "]"); + } + } +} + +void Arm::precheck_speed_(double speed, const char* who) const { + // ⚠⚠ 判据形态必须是 `!(speed >= 0.0 && speed <= 1.0)`: 写成 `speed < 0 || speed > 1` + // 会让 **NaN 静默放行** (两个比较都为假) —— 与 `precheck_q_` 第 ① 条同一条理由。 + // ⚠ 这和 `movej` 里原有的那句是**同一个判据**, 只是文案更完整 —— 保留两处是刻意的: + // 那两处是 Python 逐字移植的原文案, 动它会破坏与参照的对齐。 + if (!(speed >= 0.0 && speed <= 1.0)) { + throw InvalidCommandError(std::string(who) + ": speed 必须在 [0, 1] 的倍率内, 收到 " + + gnum(speed) + + " (⚠ 0..1 是**单条轨迹的倍率**; 全局百分比请走 set_speed(0..100))"); + } +} + +void Arm::refresh_limits() { + // 建软限缓存。**只在 `connect_impl` 末尾调一次**, 全程 fail-open。 + // + // ⚠⚠ **本实现与 A 侧有一处结构性差异, 是有意的**: A 的 `refresh_limits_` 前面有一大段 + // "等第一帧状态帧"的分片等待 (50 轮 × 推钟 1ms), 因为 A 的 `connect()` 不保证 + // "已经解码过一帧状态帧"。**本仓的 connect() 已经保证了** —— 它有一句 + // `read_status(); if (!st) throw "连上但收不到状态帧"`, 走到这里 `n_` 必然已知。 + // ⇒ 那段等待在本仓是**死代码**, 直接省掉 (省掉还顺带避免了那段代码在 + // "假传输 + 静默链路"下要退化成 skip 的麻烦)。 + // ⚠ 若将来 connect 的时序改了 (不再保证状态帧), 这一段必须补回来。 + limits_.clear(); + try { + limits_ = params().all_joint_params(); + } catch (const LiteArmError& e) { + // ⚠ **fail-open**: 读不到限位 ⇒ 不预检、放行, 由固件钳制 + 到位判据兜。 + // 在这里抛出去只会让**连接失败** —— 那是更坏的错误。 + (void)e; + return; + } + // ⚠⚠ **合理性校验** (规格没写, 是本仓自己加的; 不加会把未定义行为带进判据): + // 任一条限位**非有限**、或 **`q_min > q_max` (倒置)** ⇒ **整份缓存弃用**。 + // 为什么必须**整份**弃用、而不是"跳过那一轴": `std::clamp(v, lo, hi)` 的**标准要求是 + // `lo <= hi`** (违反即 UB), 而 `home` 的目标要靠它钳。留一条坏的 = 上面那条 + // "非空 ⇒ 每条都有限且不倒置"的不变式就断了, 而那种断法在运行期**观测不到**。 + for (const auto& jp : limits_) { + if (!std::isfinite(jp.q_min) || !std::isfinite(jp.q_max) || jp.q_min > jp.q_max) { + limits_.clear(); + return; + } + } +} + +RobotState Arm::movej(const std::vector& q, double speed) { + // 先判连接: 未连接时 n_ 还是 0, 拿它做 arity 校验只会给出误导性的"需要 0 个关节角"。 + Ack& a = require(); + if (!(speed >= 0.0 && speed <= 1.0)) throw InvalidCommandError("speed 需 0..1"); + if (int(q.size()) != n_) { + throw InvalidCommandError("movej 需要 " + std::to_string(n_) + + " 个关节角 (N=" + std::to_string(n_) + ")"); + } + // ⚠ 软限预检排在 arity 之后 (arity 已保证 `q.size() == n_` ⇒ `precheck_q_` 的 + // "长度不匹配 ⇒ 放行"那一支在这里**不可达**, 真正生效的是越限与非有限值两条)。 + // ⚠ 而上面的 speed 检查**保持 Python 原文案不动** —— 只有 `move_p` 那只速度预检是新增的。 + precheck_q_(q, "movej"); + std::vector payload = proto::pack_f32s(q); + const auto sp = proto::pack_f32le(speed); + payload.insert(payload.end(), sp.begin(), sp.end()); + write_cmd(proto::CMD_MOVE_J, payload); + a.expect(proto::RSP_ACK, 1.0, "movej", true, proto::CMD_MOVE_J); + return arrive(q); +} + +RobotState Arm::movej_sync(const std::vector& q, double speed) { + Ack& a = require(); + if (!(speed >= 0.0 && speed <= 1.0)) throw InvalidCommandError("speed 需 0..1"); + if (int(q.size()) != n_) { + throw InvalidCommandError("movej_sync 需要 " + std::to_string(n_) + + " 个关节角 (N=" + std::to_string(n_) + ")"); + } + // 软限预检 —— 与 `movej` 同一条理由与同一个位置 (见那里)。 + precheck_q_(q, "movej_sync"); + std::vector payload = proto::pack_f32s(q); + const auto sp = proto::pack_f32le(speed); + payload.insert(payload.end(), sp.begin(), sp.end()); + write_cmd(proto::CMD_MOVE_J_SYNC, payload); + a.expect(proto::RSP_ACK, 1.0, "movej_sync", true, proto::CMD_MOVE_J_SYNC); + return arrive(q); +} + +bool Arm::pose_near(const std::array& tcp, const std::array& goal, + double pos_tol, double rpy_tol) const { + // 位置逐分量 + 朝向的逐分量或旋转等价判定。 + // + // 朝向必须补一条旋转等价判定: 固件 kin_rot_to_rpy 在 |pitch| ~= pi/2 (万向锁) 时强制 + // yaw=0, 于是同一个旋转的 rpy 分量可以差很远, 逐分量比较会永远判不到位 —— 表现为每次 + // move_p 都耗满 move_timeout。旋转判据是逐分量判据的超集 (先试逐分量, 不满足再看夹角), + // 不会放松原语义。 + double dp = 0.0; + for (int i = 0; i < 3; ++i) dp = std::max(dp, std::fabs(tcp[i] - goal[i])); + if (dp >= pos_tol) return false; + double dr = 0.0; + for (int i = 3; i < 6; ++i) dr = std::max(dr, std::fabs(tcp[i] - goal[i])); + if (dr < rpy_tol) return true; + return rot::orient_angle({tcp[3], tcp[4], tcp[5]}, + {goal[3], goal[4], goal[5]}) < rpy_tol; +} + +RobotState Arm::move_p(const rot::PoseInput& pose, double speed, double pos_tol, + double rpy_tol) { + require(); // 先判连接: 未连接时的形状报错会掩盖第一因 + // ⚠⚠ **容差闸 (纵深第一层, 排在 speed 预检之前、写帧之前)** —— 两个容差必须是**正有限值**。 + // 为什么必须拦: `pose_near` 的第一句是 `if (dp >= pos_tol) return false;`, 而 + // **`dp >= NaN` 为假** ⇒ **位置那一半被静默跳过** ⇒ 到位只由朝向决定 ⇒ + // **十米外也判"到位"**(本命令会当场成功返回, 而臂没到)。这与 `precheck_q_` ① / + // `precheck_speed_` 守的是同一族"NaN 让比较全为假 ⇒ 静默放行", 只是长在容差上。 + // ≤ 0 的容差是"永远到不了位"(安全的另一侧), 但同样是调用方 bug ⇒ 一并拒绝 —— + // **不许**静默把整条 `move_timeout` 预算烧光。 + // ⚠ 判据形态必须是 `!(x > 0.0)`: 它同时覆盖 NaN 与 ≤0; 写成 `x <= 0.0` 对 NaN 为**假** + // ⇒ **拦不住**(与 `precheck_speed_` 同一条理由)。 + if (!(pos_tol > 0.0) || !(rpy_tol > 0.0)) { + throw InvalidCommandError( + std::string("move_p: pos_tol / rpy_tol 必须是**正有限值** (收到 pos_tol=") + + gnum(pos_tol) + ", rpy_tol=" + gnum(rpy_tol) + + ") —— pos_tol 为 NaN 会让**位置判定被静默跳过**(`dp >= NaN` 为假)" + "⇒ 十米外也判到位; ≤ 0 则永远到不了位。本命令在**发帧之前**被拒。"); + } + const std::array vec = rot::as_pose6(pose); + // ⚠ 下面两条都是**与 Python 参照分叉**的本地拒发 (Python 直接发走、由固件钳): + // ② speed: `move_p` 的这只是六个运动入口里**唯一**参照没预检的。 + precheck_speed_(speed, "move_p"); + // ③ 软限预检这一行**实际只兜"非有限位姿"**: 位姿恒 6 个数而 `limits_` 是 n 条 + // ⇒ `precheck_q_` 的"长度不匹配 ⇒ 放行"**恒命中**, 越限那一支对 move_p 不可达。 + // ⚠ 别把它读成"笛卡尔目标也做过越限检查了" —— 那需要 IK, 而 PC 侧没有本地 IK + // (发一条 0x43 问固件会凭空多一帧、还多一次超时窗口) ⇒ 不做。 + precheck_q_(std::vector(vec.begin(), vec.end()), "move_p"); + std::vector payload = proto::pack_f32s(vec); + const auto sp = proto::pack_f32le(speed); + payload.insert(payload.end(), sp.begin(), sp.end()); + write_cmd(proto::CMD_MOVE_P, payload); + a_->expect(proto::RSP_ACK, 1.0, "move_p", true, proto::CMD_MOVE_P); + const double end = clock().now_s() + move_timeout; + while (clock().now_s() < end) { + // 先看状态: 故障即抛 + const auto st = get_state(true, 0.1).value; + if (st && st->faulted()) { + throw MotorFaultError("move_p: FAULT " + st->fault_detail()); + } + const auto tcp = get_tcp().value; + if (st && tcp && pose_near(*tcp, vec, pos_tol, rpy_tol)) return *st; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + throw MotionTimeoutError("move_p 未到目标位姿, 超时 " + fnum(move_timeout) + "s"); +} + +RobotState Arm::home(double timeout) { + // timeout 语义与 arrive 一致: <0 表示用 move_timeout。 + write_cmd(proto::CMD_HOME); + Ack& a = require(); + a.expect(proto::RSP_ACK, 1.5, "home", true, proto::CMD_HOME); + return arrive(std::vector(size_t(n_), 0.0), timeout); +} + +void Arm::move_js(const std::vector& q, const std::vector& dq, + const std::vector& tau_ff) { + // 终态守卫排在本 arity 预检之前: 终态下 n_ 已是 0, 让预检先跑只会报 "q 需 N 个" (N=0) + // 这种误导性消息。 + reject_if_in_dfu(); + if (int(q.size()) != n_) throw InvalidCommandError("move_js q 需 N 个"); + const std::vector dqv = + dq.empty() ? std::vector(size_t(n_), 0.0) : dq; + if (int(dqv.size()) != n_) throw InvalidCommandError("move_js dq 需 N 个"); + std::vector all = q; + all.insert(all.end(), dqv.begin(), dqv.end()); + std::vector payload = proto::pack_f32s(all); + if (!tau_ff.empty()) { + if (int(tau_ff.size()) != n_) throw InvalidCommandError("move_js tau_ff 需 N 个"); + const auto extra = proto::pack_f32s(tau_ff); + payload.insert(payload.end(), extra.begin(), extra.end()); + } + cmd_expect_ack(proto::CMD_MOVE_JS, payload, "move_js"); +} + +void Arm::send_mit(int idx, double q, double dq, double kp, double kd, double tau) { + reject_if_in_dfu(); // 排在 idx 预检之前 (终态下 n_=0 => 否则报"idx 越界") + if (idx < 0 || idx >= n_) throw InvalidCommandError("idx 越界"); + std::vector payload{uint8_t(idx)}; + const auto vals = proto::pack_f32s(std::vector{q, dq, kp, kd, tau}); + payload.insert(payload.end(), vals.begin(), vals.end()); + cmd_expect_ack(proto::CMD_MOVE_MIT, payload, "send_mit"); +} + +void Arm::send_mit_all(const std::vector& q, const std::vector& dq, + const std::vector& kp, const std::vector& kd, + const std::vector& tau) { + reject_if_in_dfu(); // 排在 arity 预检之前 (终态下 n_=0) + const std::vector*, const char*>> arrs = { + {&q, "q"}, {&dq, "dq"}, {&kp, "kp"}, {&kd, "kd"}, {&tau, "tau"}}; + for (const auto& kv : arrs) { + if (int(kv.first->size()) != n_) { + throw InvalidCommandError(std::string("send_mit_all ") + kv.second + + " 需 N 个"); + } + } + std::vector all; + for (const auto& kv : arrs) all.insert(all.end(), kv.first->begin(), kv.first->end()); + cmd_expect_ack(proto::CMD_MOVE_MIT_ALL, proto::pack_f32s(all), "send_mit_all"); } -LiteArmValue Arm::get_joint_limits() { - return rpc("get_joint_limits"); +// =========================================================================== +// FF / 动力学调参 +// =========================================================================== + +void Arm::set_ff_mask(uint32_t mask) { + if ((mask & ~uint32_t(proto::FF_ALL)) != 0) { + throw InvalidCommandError( + "ff_mask 超出范围 —— 有效位只有 FF_ALL=0x1FF (固件侧会静默掩码, " + "误用高位会被折成 0=前馈全关)"); + } + cmd_expect_ack(proto::CMD_SET_FF_FLAGS, proto::pack_u32le(mask), "set_ff_mask"); } -LiteArmValue Arm::set_joint_limits(const LiteArmValue& limits) { - return rpc("set_joint_limits", {{"limits", limits}}); +void Arm::ff_preset(int preset) { + if (preset != 0 && preset != 1 && preset != 2) { + throw InvalidCommandError("preset 需 0(全关)/1(出厂)/2(全开)"); + } + const uint8_t p = uint8_t(preset); + cmd_expect_ack(proto::CMD_FF_PRESET, &p, 1, "ff_preset"); } -LiteArmValue Arm::get_zero_offsets() { - return rpc("get_zero_offsets"); +const std::vector>& Arm::ff_vec_items() { + static const std::vector> kItems = { + {1, "friction"}, {2, "ki"}, {3, "i_max"}, + {4, "wall_stiff"}, {5, "wall_damp"}, {6, "wall_tau_max"}, + {7, "gravity_scale"}, {8, "inertia_scale"}, + {9, "friction_v"}, {10, "friction_fc0"}, {11, "friction_fc1"}, + {12, "zg_kp"}, {13, "zg_kd"}, {14, "zg_damping"}, + {15, "kd_extra"}}; + return kItems; } -LiteArmValue Arm::set_zero_offsets(const LiteArmValue& offsets) { - return rpc("set_zero_offsets", {{"offsets", offsets}}); +const std::vector>& Arm::ff_scalar_items() { + static const std::vector> kItems = { + {1, "fric_db"}, {2, "wall_margin"}, {3, "friction_slew"}, + {4, "payload_mass"}, {5, "payload_com"}, {6, "gravity"}, + {7, "friction_model"}, {8, "fric_v2_eps"}, + {10, "drag_gain"}, {11, "drag_db"}, {12, "drag_kd_margin"}, + {13, "zg_vel_thr"}, {14, "zg_engage_sec"}, {15, "zg_engage_kp"}, + {16, "zg_engage_kd"}, {17, "wall_fw_kd"}, {18, "hold_kp_gain"}}; + return kItems; } -LiteArmValue Arm::get_end_effector() { - return rpc("get_end_effector"); +const std::vector>& Arm::ff_scalar_ro_items() { + static const std::vector> kItems = {{9, "ff_mask"}}; + return kItems; } -LiteArmValue Arm::set_end_effector(const LiteArmValue& config) { - return rpc("set_end_effector", {{"config", config}}); +namespace { +bool in_items(const std::vector>& items, int item) { + for (const auto& kv : items) { + if (kv.first == item) return true; + } + return false; } +} // namespace -LiteArmValue Arm::get_cartesian_limits() { - return rpc("get_cartesian_limits"); +void Arm::set_ff_vec(int item, const std::vector& values) { + if (!in_items(ff_vec_items(), item) || values.size() != 7) { + throw InvalidCommandError("set_ff_vec item∈1..15 且需 7 值"); + } + std::vector payload{uint8_t(item)}; + const auto vals = proto::pack_f32s(values); + payload.insert(payload.end(), vals.begin(), vals.end()); + cmd_expect_ack(proto::CMD_SET_FF_VEC, payload, "set_ff_vec item" + std::to_string(item)); } -LiteArmValue Arm::set_cartesian_limits(const LiteArmValue& limits) { - return rpc("set_cartesian_limits", {{"limits", limits}}); +void Arm::set_ff_scalar(int item, int sub, double value) { + if (!in_items(ff_scalar_items(), item) || sub < 0 || sub > 2) { + throw InvalidCommandError("set_ff_scalar item∈{1..8,10..18}, sub∈0..2"); + } + std::vector payload{uint8_t(item), uint8_t(sub)}; + const auto v = proto::pack_f32le(value); + payload.insert(payload.end(), v.begin(), v.end()); + cmd_expect_ack(proto::CMD_SET_FF_SCALAR, payload, + "set_ff_scalar" + std::to_string(item)); } -LiteArmValue Arm::get_collision_config() { - return rpc("get_collision_config"); +Msg> Arm::get_ff_vec(int item, double timeout) { + if (!in_items(ff_vec_items(), item)) { + throw InvalidCommandError("get_ff_vec item∈1..15"); + } + Ack& a = require(); + const uint8_t p = uint8_t(item); + write_query(proto::CMD_GET_FF_VEC, &p, 1); + const auto r = a.expect(proto::RSP_FF_VEC, timeout, + "get_ff_vec item" + std::to_string(item), true, + proto::CMD_GET_FF_VEC); + if (r.payload.size() < 2 + 7 * 4) { + throw TransportError("RSP_FF_VEC 帧短"); + } + // [0]=RSP id, [1]=item, 之后 7xf32 + return wrap(proto::unpack_f32s(r.payload.data(), 2, 7), proto::RSP_FF_VEC); } -LiteArmValue Arm::set_collision_config(const LiteArmValue& config) { - return rpc("set_collision_config", {{"config", config}}); +Msg Arm::get_ff_scalar(int item, int sub, double timeout) { + if (!in_items(ff_scalar_items(), item) && !in_items(ff_scalar_ro_items(), item)) { + throw InvalidCommandError("get_ff_scalar item∈{1..18}"); + } + if (sub < 0 || sub > 2) throw InvalidCommandError("get_ff_scalar sub∈0..2"); + Ack& a = require(); + const uint8_t p[2] = {uint8_t(item), uint8_t(sub)}; + write_query(proto::CMD_GET_FF_SCALAR, p, 2); + const auto r = a.expect(proto::RSP_FF_SCALAR, timeout, + "get_ff_scalar item" + std::to_string(item), true, + proto::CMD_GET_FF_SCALAR); + if (r.payload.size() < 3 + 4) throw TransportError("RSP_FF_SCALAR 帧短"); + // [0]=RSP id, [1]=item, [2]=sub, 之后 f32 + const auto v = proto::unpack_f32s(r.payload.data(), 3, 1); + return wrap(v[0], proto::RSP_FF_SCALAR); } -LiteArmValue Arm::start_recording() { - return rpc("start_recording"); +int Arm::get_ff_mask(double timeout) { + return int(std::llround(get_ff_scalar(9, 0, timeout).value)); } -LiteArmValue Arm::stop_recording() { - return rpc("stop_recording"); +void Arm::set_gravity_scale(const std::vector& gs) { + if (gs.size() != 7) throw InvalidCommandError("gravity_scale 需 7 值"); + set_ff_vec(7, gs); } -LiteArmValue Arm::discard_recording() { - return rpc("discard_recording"); +void Arm::set_inertia_scale(const std::vector& isc) { + if (isc.size() != 7) throw InvalidCommandError("inertia_scale 需 7 值"); + set_ff_vec(8, isc); } -LiteArmValue Arm::get_recording_state() { - return rpc("get_recording_state"); +void Arm::set_payload(double mass, const std::array& com) { + set_ff_scalar(4, 0, mass); + for (int k = 0; k < 3; ++k) set_ff_scalar(5, k, com[size_t(k)]); } -LiteArmValue Arm::get_playback_state() { - return rpc("get_playback_state"); +void Arm::set_gravity_vector(const std::array& g) { + for (int k = 0; k < 3; ++k) set_ff_scalar(6, k, g[size_t(k)]); } -LiteArmValue Arm::list_trajectories() { - return rpc("list_trajectories"); +void Arm::save_params() { + cmd_expect_ack(proto::CMD_PARAM_SAVE, nullptr, 0, "save_params", 2.5); } -LiteArmValue Arm::save_trajectory(const std::string& id, - const std::string& name, - const std::vector>& points, - std::optional duration) -{ - std::map kwargs; - kwargs["id"] = LiteArmValue(id); - kwargs["name"] = LiteArmValue(name); - kwargs["points"] = LiteArmValue::from_mat(points); - kwargs["duration"] = duration ? LiteArmValue(*duration) : LiteArmValue(nullptr); - return rpc("save_trajectory", std::move(kwargs)); +// =========================================================================== +// IK / 授权 +// =========================================================================== + +std::vector Arm::ik(const std::array& pose, + const std::vector& q_seed, double timeout) { + Ack& a = require(); + std::vector seed; + if (q_seed.empty()) { + const auto st = get_state().value; + if (!st) throw TransportError("ik 需要 q_seed, 但当前取不到状态帧"); + if (st->n() == proto::KIN_N) { + seed = st->q(); + } else { + // 台架/非整臂: 关节数 != 模型轴数, 按模型轴映射构造 7 轴种子 + seed.assign(proto::KIN_N, 0.0); + const int ax = bench_model_axis; + if (ax >= 0 && ax < proto::KIN_N && st->n() > 0) { + seed[size_t(ax)] = st->joints[0].q; + } + } + } else { + seed = q_seed; + } + if (int(seed.size()) != proto::KIN_N) { + throw InvalidCommandError("q_seed 需 7 个 (固件 IK seed 恒为模型 7 轴)"); + } + std::vector all(pose.begin(), pose.end()); + all.insert(all.end(), seed.begin(), seed.end()); + write_query(proto::CMD_GET_IK, proto::pack_f32s(all)); + const auto r = a.expect(proto::RSP_IK, timeout, "ik", true, proto::CMD_GET_IK); + if (r.payload.size() < 7 * 4 + 1) throw TransportError("ik 应答帧短"); + const auto qs = proto::unpack_f32s(r.payload.data(), 0, 7); + if (r.payload[7 * 4] == 0) throw IKError("目标不可达/IK 失败"); + return qs; +} + +LicenseInfo Arm::license(double timeout) { + Ack& a = require(); + write_query(proto::CMD_GET_LICENSE); + const auto r = a.expect(proto::RSP_LICENSE, timeout, "get_license"); + return LicenseInfo::decode(r.payload); +} + +void Arm::activate(uint32_t cust_id, uint32_t issued, uint32_t flags, const uint8_t* mac, + size_t mac_len, double timeout) { + if (mac_len != 16) { + throw InvalidCommandError("mac 需 16 字节 (两个 SipHash-2-4 标签), 给的是 " + + std::to_string(mac_len)); + } + if ((flags & ~0x1u) != 0) { + // 保留位必须为 0 (固件会拒, 但那边折进 0x02 的聚合档 => 本地先说清楚) + throw InvalidCommandError("flags 只允许 bit0 (产线码)"); + } + std::vector payload = proto::pack_u32le(cust_id); + const auto a1 = proto::pack_u32le(issued); + const auto a2 = proto::pack_u32le(flags); + payload.insert(payload.end(), a1.begin(), a1.end()); + payload.insert(payload.end(), a2.begin(), a2.end()); + payload.insert(payload.end(), mac, mac + mac_len); + try { + cmd_expect_ack(proto::CMD_ACTIVATE, payload, "activate", timeout); + } catch (const CommandRejectedError& e) { + if (e.code != 0x02) throw; + // 0x02 聚合档 => 只有回读能定性 (见头文件): 设备可能其实已经解锁。 + if (license().activated()) return; + throw; + } } -LiteArmValue Arm::delete_trajectory(const std::string& id) { - return rpc("delete_trajectory", {{"id", LiteArmValue(id)}}); +// =========================================================================== +// 笛卡尔 (转发给 cart.cpp 的实现) +// =========================================================================== + +CartPlan Arm::move_l(const rot::PoseInput& pose, double speed, bool wait) { + return litearm::move_l(this, pose, speed, wait); } -LiteArmValue Arm::list_device_types() { - return rpc("list_device_types"); +CartPlan Arm::move_c(const rot::PoseInput& pose_start, const rot::PoseInput& pose_via, + const rot::PoseInput& pose_goal, double speed, bool wait) { + return litearm::move_c(this, pose_start, pose_via, pose_goal, speed, wait); } -LiteArmValue Arm::connect_device(const std::string& category, - const std::string& subtype, - const std::string& device_id, - const std::string& can_iface, - const LiteArmValue& config) -{ - return rpc("connect_device", { - {"category", LiteArmValue(category)}, - {"subtype", LiteArmValue(subtype)}, - {"device_id", LiteArmValue(device_id)}, - {"can_iface", LiteArmValue(can_iface)}, - {"config", config}, - }); +CartPlan Arm::move_path(const std::vector& poses, double speed, + bool wait) { + return litearm::move_path(this, poses, speed, wait); } -LiteArmValue Arm::disconnect_device(const std::string& device_id) { - return rpc("disconnect_device", {{"device_id", LiteArmValue(device_id)}}); +std::optional Arm::poll_cart() { + // 本方法不碰链路 —— 0x4E 由读线程直接交给收集器, 所以"认领"就只是看一眼待认领队列。 + require(); + const auto tok = cart_->claim_resolved(); + if (!tok || !tok->reply) return std::nullopt; + CartPlan plan = CartPlan::from_reply(*tok->reply); + raise_for_plan(plan); + return plan; } -LiteArmValue Arm::get_active_device(const std::string& device_id) { - return rpc("get_active_device", {{"device_id", LiteArmValue(device_id)}}); +// =========================================================================== +// DFU +// =========================================================================== + +bool Arm::wait_until_link_lost(double timeout) { + // 判据只有一条: reader_error 被置 —— 读线程从传输层收到异常就把它记在那里。 + // 设备被摘掉 / 跳进 ROM bootloader 时 read 会抛, 被包成 TransportError。 + // 正常关闭绝不能写成这一格 (close 必须先停读线程再关传输)。 + Ack& a = require(); + const double end = clock().now_s() + timeout; + Ack::MuLock lk(a.mu, a.dbg_mu_owner); + while (!a.reader_error) { + const double left = end - clock().now_s(); + if (left <= 0.0) return false; + a.cv.wait_for(lk.ul(), std::chrono::duration(left)); + } + return true; } -LiteArmValue Arm::enter_teleop( - const std::string& mode, - const std::map& params) -{ - std::map kwargs; - kwargs["mode"] = LiteArmValue(mode); - for (const auto& [key, value] : params) { - kwargs[key] = value; +void Arm::enter_dfu(double timeout) { + Ack& a = require(); // 未连接 => NotConnectedError + // 预检读一帧现取的状态 (refresh=true), 不用缓存: 拿一个十几秒前的 enabled 去判"能不能 + // 跳"两个方向都错。读不到状态帧时不拦 —— 门禁的权威在固件, 本地预检只为可读性。 + const auto st = get_state(true).value; + if (st && st->enabled()) { + throw InvalidCommandError(DFU_ENABLED_MESSAGE); } - return rpc("enter_teleop", std::move(kwargs)); + write_cmd(proto::CMD_ENTER_DFU); // 空载荷; guarded 保持默认 (零重力期同样拒) + a.expect(proto::RSP_ACK, DFU_ACK_TIMEOUT_S, "enter_dfu", true, proto::CMD_ENTER_DFU); + if (!wait_until_link_lost(timeout)) { + throw LiteArmError(DFU_REVOKED_MESSAGE); + } + try { + close(); // 与 Arm::close 同一套收尾 (含收保活线程) + } catch (...) { + dfu_entered_ = true; + throw; + } + // 置位放 finally-位置: 设备确实没了, 无论收尾是否出岔子, 本对象都不该再被当成一个活着 + // 的会话 (否则后续调用会从读路径抛 TransportError —— 归因指向"链路故障", 而真相是 + // "我们把它交出去了")。 + dfu_entered_ = true; } -LiteArmValue Arm::exit_teleop() { - return rpc("exit_teleop"); +// =========================================================================== +// 子模块 +// =========================================================================== + +JointParams& Arm::params() { + if (!params_) params_ = std::make_unique(this); + return *params_; } -LiteArmValue Arm::get_teleop_status() { - return rpc("get_teleop_status"); +ModelParams& Arm::model() { + if (!model_) model_ = std::make_unique(this); + return *model_; } -// ── Lifecycle ─────────────────────────────────────────────────────────────── +ArmLog& Arm::log() { + if (!log_) log_ = std::make_unique(this); + return *log_; +} -void Arm::close() { - if (tp_) { - tp_->close(); +Diagnostics& Arm::diag() { + if (!diag_) diag_ = std::make_unique(this); + return *diag_; +} + +// =========================================================================== +// CLI +// =========================================================================== + +namespace { + +void print_state(const Msg>& m) { + if (!m.value) { + std::cout << "(取不到状态帧)\n"; + return; + } + const RobotState& st = *m.value; + std::cout << "mode=" << st.mode_name << " seq=" << st.seq << " flags="; + if (st.flag_names.empty()) { + std::cout << "-"; + } else { + for (size_t i = 0; i < st.flag_names.size(); ++i) { + if (i) std::cout << ","; + std::cout << st.flag_names[i]; + } + } + std::cout << " hz=" << fnum(m.hz) << " age=" << fnum(now_s() - m.timestamp, 2) + << "s\n"; + for (size_t i = 0; i < st.joints.size(); ++i) { + const auto& j = st.joints[i]; + char buf[256]; + std::snprintf(buf, sizeof(buf), + " J%zu: q=% .3f dq=% .2f tau=% .2f T=% .0f/% .0fC err=%02X\n", + i + 1, j.q, j.dq, j.tau, j.t_mos, j.t_coil, j.err); + std::cout << buf; + } +} + +} // namespace + +int main_impl(const std::vector& argv) { + std::string action = "status"; + std::string port; + double speed = 0.3; + std::vector targets; + for (size_t i = 0; i < argv.size(); ++i) { + const std::string& a = argv[i]; + if (a == "--port" && i + 1 < argv.size()) { + port = argv[++i]; + } else if (a == "--speed" && i + 1 < argv.size()) { + speed = std::stod(argv[++i]); + } else if (a == "-h" || a == "--help") { + std::cout << "用法: litearm-cpp [--port PORT] [--speed S] " + "[status|fw|enable|disable|reset|emergency|movej|home|tcp] " + "[targets...]\n"; + return 0; + } else if (action == "status" && targets.empty() && + (a == "status" || a == "fw" || a == "enable" || a == "disable" || + a == "reset" || a == "emergency" || a == "movej" || a == "home" || + a == "tcp")) { + action = a; + } else { + targets.push_back(std::stod(a)); + } + } + + ArmOptions opts; + if (!port.empty()) opts.port = port; + Arm arm(opts); + arm.connect(); + try { + if (action == "fw") { + std::cout << "firmware: " << arm.firmware() << " | n = " << arm.n() << "\n"; + } else if (action == "status") { + const auto msg = arm.get_state(); + if (!msg.value) { + std::cerr << "取不到状态帧 (链路无上行)\n"; + arm.close(); + return 1; + } + print_state(msg); + } else if (action == "enable") { + arm.enable(); + std::cout << "enabled\n"; + } else if (action == "disable") { + arm.disable(); + std::cout << "disabled\n"; + } else if (action == "reset") { + arm.reset(); + std::cout << "reset\n"; + } else if (action == "emergency") { + arm.emergency_stop(); + std::cout << "emergency\n"; + } else if (action == "home") { + arm.home(); + std::cout << "home done\n"; + } else if (action == "movej") { + if (int(targets.size()) != arm.n()) { + std::cerr << "movej 需 " << arm.n() << " 个关节角\n"; + arm.close(); + return 1; + } + arm.movej(targets, speed); + std::cout << "movej done\n"; + } else if (action == "tcp") { + const auto msg = arm.get_tcp(); + std::cout << "tcp: "; + if (msg.value) { + for (size_t i = 0; i < 6; ++i) { + if (i) std::cout << ", "; + std::cout << fnum((*msg.value)[i], 4); + } + } else { + std::cout << "(取不到)"; + } + std::cout << " (hz=" << fnum(msg.hz) << ")\n"; + } + } catch (...) { + arm.close(); + throw; } + arm.close(); + return 0; } -} // namespace litearm +} // namespace litearm diff --git a/src/cart.cpp b/src/cart.cpp new file mode 100644 index 0000000..ac05c78 --- /dev/null +++ b/src/cart.cpp @@ -0,0 +1,699 @@ +#include "litearm/cart.hpp" + +#include +#include +#include +#include + +#include "litearm/ack.hpp" +#include "litearm/arm.hpp" + +namespace litearm { + +const std::set& cart_clears_upon() { + static const std::set kSet = { + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, // move_j/P/js/mit/mit_all/zero_g/j_sync + 0x11, 0x12, 0x13, 0x14, 0x2A, // disable/estop/clear_faults/reset/home + }; + return kSet; +} + +double cart_absorb_ttl(double move_timeout) { + return std::max(move_timeout, kAbsorbTtlFloor); +} + +// =========================================================================== +// CartPlan / raise_for_plan +// =========================================================================== + +CartPlan CartPlan::from_reply(const std::vector& payload) { + if (payload.size() < 8) { + throw TransportError("RSP_CART_PLAN 帧短: " + std::to_string(payload.size()) + + "B (应 8B) —— 帧布局漂移?"); + } + CartPlan p; + p.ok = payload[0] != 0; + p.err = payload[1]; + p.n_wp = proto::read_u16le(payload, 2); + p.plan_us = proto::read_u32le(payload, 4); + return p; +} + +void raise_for_plan(const CartPlan& plan) { + if (plan.ok) return; + const int err = plan.err; + if (err == CART_ERR_IK) { + throw IKError("固件笛卡尔规划失败: 路点 IK 无解或解跳变 (err=" + + std::to_string(err) + ")"); + } + if (err == CART_ERR_CANCELED) { + throw MotionSupersededError( + "这条笛卡尔请求被固件的另一条请求取代 (err=5 CANCELED) —— " + "预期内的接管, 不是失败"); + } + if (err == CART_ERR_BADARG) { + throw InvalidCommandError("固件拒绝这条笛卡尔命令: 入参非法 (err=" + + std::to_string(err) + ")"); + } + std::string detail; + switch (err) { + case CART_ERR_COLLINEAR: detail = "三点共线, 定不出圆 (move_c)"; break; + case CART_ERR_TOO_LONG: detail = "路点超容量 —— 整条拒绝, 臂一步没动"; break; + case CART_ERR_LIMIT: detail = "路点或段中插值越关节限位"; break; + default: detail = "未登记的 err=" + std::to_string(err); break; + } + throw CartesianPlanError("固件笛卡尔规划失败: " + detail + " (err=" + + std::to_string(err) + ")"); +} + +// =========================================================================== +// CartToken +// =========================================================================== + +void CartToken::resolve(const std::vector& payload) { + { + std::lock_guard lk(mu); + reply = payload; + done = true; + } + cv.notify_all(); +} + +void CartToken::fail(const std::string& reason) { + { + std::lock_guard lk(mu); + lost = reason; + done = true; + } + cv.notify_all(); +} + +// =========================================================================== +// CartPending +// =========================================================================== + +const Clock& CartPending::clk() const { + return arm_ != nullptr ? arm_->clock() : steady_clock_instance(); +} + +CartPending::CartPending(double absorb_ttl, int absorb, Arm* arm) + : arm_(arm), absorb_ttl_(absorb_ttl), absorb_(absorb) { + // 带进来的额度从建对象这一刻起算 absorb_ttl (与 clear_pending / drop_and_absorb 给 + // 额度时的口径一致: 额度总是"从现在起再活 absorb_ttl")。 + absorb_deadline_ = absorb ? (clk().now_s() + absorb_ttl) : 0.0; +} + +CartPending::~CartPending() = default; + +CartTokenPtr CartPending::register_token() { + // 必须在写之前调 —— 否则窗口内到来的 0x4E 会落空。 + auto tok = std::make_shared(); + std::lock_guard lk(lock_); + tok->absorbed_at = absorbed_replies; // 水位线: 见 drop_and_absorb + q_.push_back(tok); + return tok; +} + +CartTokenPtr CartPending::request(const std::function& write) { + auto tok = register_token(); + try { + write(); + } catch (const TransportError&) { + // 契约: write_frame 抛 TransportError => 整帧未送达 (残缺帧被固件按 CRC 丢) + // => 摘下自己那个 token 才是干净的 (在途条数 <-> 应答条数重新相等)。 + drop(tok); + throw; + } catch (...) { + // 其余异常证明不了帧没送达 (最现实的是中断落在 flush 之后, 那时整帧已在驱动里) + // => 留在队里: 最坏是后续请求报"结局未知" (安全方向), 而摘掉它会让那条应答配给 + // 后面的活 token = 假成功 (危险方向)。 + throw; + } + return tok; +} + +void CartPending::drop(const CartTokenPtr& token) { + // 摘除指定的那条 token (按对象身份, 不是队尾 —— 并发下队尾是别人的)。 + // 两个地方都要摘: 在途队列与"已收到应答但没人认领"那一列 —— 一条被判死的请求不该 + // 还能被 poll_cart 认领出结果。 + std::lock_guard lk(lock_); + unclaim_locked(token); +} + +void CartPending::unclaim_locked(const CartTokenPtr& token) { + for (auto it = q_.begin(); it != q_.end(); ++it) { + if (*it == token) { + q_.erase(it); + break; + } + } + for (auto it = unclaimed_.begin(); it != unclaimed_.end(); ++it) { + if (*it == token) { + unclaimed_.erase(it); + break; + } + } +} + +void CartPending::drop_and_absorb(const CartTokenPtr& token, bool timed_out) { + // 与 clear_pending 同构: 摘掉 token 的那一刻, 固件那条应答可能已经在路上 (它受理了 + // 这条命令, 只是我们等不到/读不到)。不留额度的话, 它一到就撞上"队列空"那条判据 + // => LiteArmError 从毫不相干的读路径炸出来。 + // + // 例外 (唯一一条, 只对超时支生效): 窗口里已经消费过额度 => 不补。见头文件。 + std::lock_guard lk(lock_); + unclaim_locked(token); + if (timed_out && absorbed_replies != token->absorbed_at) { + // 超时支 + 窗口里已经吸收过一条 —— 那条就是本条请求的应答: 固件不再欠, 故不补额度 + // (补了就是自持)。两个字段都不动。 + // pump 支不走这里: 它没等过窗口, "被吃掉的是自己那条"没有依据。 + return; + } + const double now = clk().now_s(); + absorb_ = absorbance_locked(now) + 1; + absorb_deadline_ = now + absorb_ttl_; +} + +CartTokenPtr CartPending::claim_resolved() { + std::lock_guard lk(lock_); + if (!unclaimed_.empty() && unclaimed_.front()->done) { + auto tok = unclaimed_.front(); + unclaimed_.pop_front(); + return tok; + } + return nullptr; +} + +void CartPending::on_reply(const std::vector& payload) { + CartTokenPtr tok; + { + std::lock_guard lk(lock_); + if (absorbance_locked(clk().now_s()) > 0) { + // 清队后的不可归属应答 —— 属于刚被清掉的那条请求, 与"真·脱同步"是两回事: + // 消费一格额度, 计数, 直接返回 (不配对、不报错、队列不动)。 + absorb_ -= 1; + absorbed_replies += 1; + return; + } + if (q_.empty()) { + extra_replies += 1; + // "多了一条"抛基类 LiteArmError 而不给它一个专门的异常类型: 那是 SDK 的配对 + // 模型与固件脱同步 (内部不变量被破坏), 没有任何调用方能据它做出正确决定, 也 + // 不该被专门 catch —— 给它一个专用类型反而在邀请后续代码去 except 它。 + throw LiteArmError( + "收到 0x4E (笛卡尔规划应答) 但队列为空 —— 应答比请求多, " + "固件与主机已错配 (已计数 extra_replies=" + + std::to_string(extra_replies) + "); 这不是可以忽略的噪声"); + } + tok = q_.front(); + q_.pop_front(); + // 先登记"待认领"再唤醒等待者: wait 醒来后会把它从这一列移走, 顺序反过来的话那次 + // 移除会扑空, 于是同一条结果被 poll_cart 再交付一次。 + unclaimed_.push_back(tok); + // 给那一列封顶: 无界的唯一现实来源是"调用方放弃 token" (ACK 超时路径)。 + while (unclaimed_.size() > kUnclaimedMax) { + unclaimed_.pop_front(); + evicted_unclaimed += 1; + } + } + tok->resolve(payload); +} + +int CartPending::clear_pending(const std::string& reason) { + std::deque tokens; + int n = 0; + { + std::lock_guard lk(lock_); + tokens.swap(q_); + n = int(tokens.size()); + if (n) { + const double now = clk().now_s(); + absorb_ = absorbance_locked(now) + n; + absorb_deadline_ = now + absorb_ttl_; + } + // n == 0 时两个字段都不动 —— 这不是优化, 是正确性: 零重力保活线程每 40ms 发一条 + // 清队 opcode, 若空清队也刷新截止, 额度就永远不过期。 + } + const std::string why = + (reason.empty() ? std::string("在途规划被其它命令作废") : reason) + + " —— 固件侧不会再发 0x4E, 结局未知 (只能回读状态判定)"; + for (const auto& t : tokens) t->fail(why); + return n; +} + +int CartPending::absorbance_locked(double now) { + // 当前有效吸收额度 —— 惰性过期: now > 截止 即视为 0。 + // 判据是严格大于: now == 截止 算未过期。 + if (absorb_ && now > absorb_deadline_) absorb_ = 0; + return absorb_; +} + +int CartPending::pending() { + std::lock_guard lk(lock_); + return int(q_.size()); +} + +void CartPending::unclaim(const CartTokenPtr& token) { + std::lock_guard lk(lock_); + for (auto it = unclaimed_.begin(); it != unclaimed_.end(); ++it) { + if (*it == token) { + unclaimed_.erase(it); + break; + } + } +} + +std::vector CartPending::wait(const CartTokenPtr& token, double timeout) { + const double end = clk().now_s() + timeout; + bool link_down = false; + std::string link_why; + while (true) { + { + std::unique_lock lk(token->mu); + if (token->reply || token->lost) break; + const double left = end - clk().now_s(); + if (left <= 0.0) break; + token->cv.wait_for(lk, std::chrono::duration(left)); + } + // 读线程死了要当场响亮: 等满 timeout 才报"结局未知"会把"链路断了"说成 + // "固件没回"。所以每一拍醒来看一眼读者死因。 + if (arm_ != nullptr) { + std::string why; + if (arm_->link_dead(&why)) { + link_down = true; + link_why = why; + break; + } + } + } + if (token->lost) { + throw CartReplyLostError(*token->lost); + } + if (link_down) { + // 把病因原样抛出去: 报 CartReplyLostError ("结局未知") 会把"链路断了"说成 + // "固件没回", 排查方向直接反。 + // timed_out=false: 这一支没等过窗口 => 照旧补额度。走成 true 会让固件真欠的那条 + // 应答被"例外段"吞掉 => 下一条命令假成功。 + drop_and_absorb(token, false); + token->fail("读线程已退出 (链路没了) —— 结局未知"); + throw TransportError("等待 0x4E 期间读线程已退出: " + link_why); + } + if (!token->reply) { + // 超时 —— "少了一条" + drop_and_absorb(token, true); + token->fail("超时 " + std::to_string(timeout) + + "s 没等到 0x4E 应答 (固件单槽 pending 在突发下会吞掉中段应答) —— " + "结局未知 (只能回读状态判定)"); + throw CartReplyLostError(*token->lost); + } + unclaim(token); // 结果已交付, 不许再被 poll_cart 认领 + return *token->reply; +} + +// =========================================================================== +// 三条入口的内部实现 +// =========================================================================== + +namespace { + +/// 位姿入参 -> [x,y,z,r,p,y] (固件 0x4E 家族的载荷形态)。 +/// +/// 不合法时 as_pose 抛的 InvalidCommandError 文案里带实际收到的形状 —— 这里只把 label +/// (哪条入口的哪个位姿) 前缀上去, 原文原样保留, 不重写、不换成笼统的"pose 非法"。 +std::array as_pose6_labeled(const rot::PoseInput& pose, const std::string& label) { + try { + return rot::as_pose6(pose); + } catch (const InvalidCommandError& e) { + throw InvalidCommandError(label + ": " + e.what()); + } catch (const std::exception& e) { + throw InvalidCommandError(label + ": " + e.what()); + } +} + +double as_speed(double speed, const std::string& label) { + if (!(speed >= 0.0 && speed <= 1.0)) { + throw InvalidCommandError(label + ": speed 需 0..1 (给的是 " + + std::to_string(speed) + ")"); + } + return speed; +} + +} // namespace + +struct CartImpl { + /// ② 能力检查 —— 排在 require 之后、零重力守卫之前。 + /// + /// 报错分两支措辞 —— 探测那一步把两种"不支持"分开了 (_cart_probe_silent), 在这里混成 + /// 一句话会让现场无法归因: 一支该换固件, 另一支该查链路。两支的处置方向相同 (都不发帧, + /// fail-closed), 分开的只是说法。 + static void require_cart_support(Arm* arm) { + if (arm->cart_supported_) return; + std::string why; + if (arm->cart_probe_silent_) { + why = "探测未获确认: connect() 时的空载荷 0x3A 探测 3 次都没等到应答 —— " + "固件的 default 分支保证任何固件都会回一条 ERR, 所以这只能是上行丢帧 " + "(本硬件 USB CDC 有已知丢帧), 不是'固件没有这条命令'"; + } else { + why = "固件确报不支持: connect() 时的空载荷 0x3A 探测回了 ERR{0x3A,0x00} " + "(default 分支 => 固件没编进 LITEARM_CART_PLAN)"; + } + throw UnsupportedByFirmwareError( + why + " —— 故不发帧 (未确认支持就不发能起规划的命令)。当前固件: " + + (arm->firmware_.empty() ? std::string("?") : arm->firmware_), + proto::CMD_MOVE_L, 0x00); + } + + /// ③ 零重力守卫 —— **必须在 request() 之前**跑完。 + /// + /// write_cmd 里有一道判据完全相同的守卫, 它在这里重复出现, 是为了让"零重力保活期间 + /// 不许发动作命令"这条判定发生在登记 token 之前 —— 否则 InvalidCommandError 会在写失败 + /// 的位置抛出, 而 CartPending::request 只对 TransportError 摘 token, 于是 token 留在 + /// 队里。 + static void reject_in_zero_g(Arm* arm) { + if (arm->zero_g_active()) throw InvalidCommandError(ZERO_G_GUARD_MESSAGE); + } + + /// 此刻已知的最高状态帧序号 —— 到位判据的新鲜度闸水位线。 + /// 还没见过任何状态帧时返回 nullopt (那次等待会先拿第一帧当起算点)。 + static std::optional seq_now(Arm* arm) { + const auto st = arm->require().state_copy(); + if (!st) return std::nullopt; + return st->seq; + } + + /// 到位判据满足之后, 回读一次实际 TCP 与本次请求的目标位姿比对。 + /// + /// 这是"假成功"方向唯一的一道防线: bit10 1->0 + q 静止 这个观察, 同样由"这条轨迹被别的 + /// 运动作废之后的收尾"产生 —— 固件 ctrl_accept_move_j 的第一条语句就是作废在途笛卡尔 + /// 规划, 于是下一帧 bit10=0, 臂转去执行那条命令; 它跑完也静止 => 只看 bit10 会把"中途 + /// 被扯断、TCP 根本不在目标上"报成到位。跨进程同样成立, SDK 侧拦不住。 + /// 取不到就报"没到位" (超时/断连/被别的 ERR 串台): 保守方向, 与"宁可报未知"同向。 + static bool tcp_reached(Arm* arm, const std::array& goal) { + std::optional> tcp; + try { + tcp = arm->get_tcp().value; + } catch (const LiteArmError&) { + return false; + } + if (!tcp) return false; + return arm->pose_near(*tcp, goal, CART_START_POS_TOL, CART_START_RPY_TOL); + } + + /// 等"臂真的停下来" —— 返回 (started_busy, q_final, settle_err_rad)。 + /// + /// 判据不能复用 Arm::arrive: 那个的 done 判据要目标关节向量, 而笛卡尔路径的目标 q 在 + /// PC 侧不存在 (规划在固件里) => 这里的 q_tol 语义随之从"与目标的差"改成"与上一帧的差" + /// (数值沿用 0.03)。上限沿用 move_timeout (不新造旋钮)。 + /// + /// 双判据缺一不可: 相邻两帧的 q 逐轴差 < q_tol 连续 arrive_frames 帧 且 dq 的 max-norm + /// < dq_tol。只抄 q_tol 一半会宽松得多 —— 静态保持下 dq 的抖动足以把"还在爬行"判成 + /// "停稳"。 + /// + /// 新鲜度闸 (seq0 + (st.seq - seq0) & 0xFFFF 属于 (0, 32768), u16 回绕安全): 只信 + /// "命令发出之后生成"的状态帧。陈旧帧直接丢弃: 既不判到位, 也不判故障 —— 故障是锁存的, + /// 真故障会在可信帧上重现, 用陈旧帧判故障只会造成假中止。 + /// + /// 必须能退出: 一旦臂在安全包络触发下锁存 (mode=EMERGENCY + enabled=false, 不动 cart + /// FSM), CART_BUSY 会常亮 —— 只等 bit10 落 0 会一直等到超时, 这就是上限存在的理由 + /// (另外 st.faulted 那条会立刻抛: EMERGENCY 置 mode=6)。 + /// + /// 断连时本循环在下一次取帧就拿到 NotConnectedError 退出, 不会挂到超时 —— 归因必须是 + /// "链路断了"而不是"没到位"。故取帧必须经 arm->require(): 直接摸 arm->a_ 在 close 之后 + /// 是空, 抛出来的会是别的异常。 + struct SettleInfo { + bool started_busy = false; + std::vector q_final; + double settle_err_rad = 0.0; + }; + + static SettleInfo wait_settled(Arm* arm, const std::string& label, + std::optional seq0) { + const double budget = arm->move_timeout; + const double end = arm->clock().now_s() + budget; + SettleInfo out; + std::optional> prev_q; + // 判到位的那几帧的 q (arrive_frames 帧为窗, 静止判据一破就清空重来) —— + // 收尾时用它算 settle_err_rad (最后几帧的抖动幅度)。 + std::vector> window; + // 上一帧的 seq —— 去重: 采样是靠"等 status_seq 前进"驱动的, 但等超时时 state 可能 + // 还是同一帧; 数两次就等于窗里少判了一帧。 + std::optional last_seq; + while (arm->clock().now_s() < end) { + Ack& a = arm->require(); + const uint64_t cur = a.status_seq_now(); + const double left = end - arm->clock().now_s(); + arm->pump_until(std::min(0.05, std::max(left, 0.0)), + [cur](Ack& a) { return a.status_seq_now() != cur; }, label); + const auto st_opt = arm->require().state_copy(); + if (!st_opt) continue; + const RobotState& st = *st_opt; + if (last_seq && st.seq == *last_seq) continue; // 同一帧: 不重复计数 + last_seq = st.seq; + if (!seq0) { + seq0 = st.seq; // 还没有水位线: 第一帧只当起算点 + continue; + } + const uint16_t delta = uint16_t((st.seq - *seq0) & 0xFFFF); + if (!(delta > 0 && delta < 32768)) continue; // 陈旧帧: 不判到位, 也不判故障 + if (st.faulted()) { + throw MotorFaultError(label + ": 未到位即故障 FAULT " + st.fault_detail()); + } + if (st.cart_busy()) out.started_busy = true; + const std::vector q = st.q(); + // 无前一帧时不能短路掉 dq 那半边 (起点那帧一样要过 dq_tol): 让一个 dq 超容差的 + // 帧当上静止窗的起点, 就等于窗里少判了一帧。 + bool still = true; + for (double d : st.dq()) { + if (!(std::fabs(d) < arm->dq_tol)) { + still = false; + break; + } + } + if (still && prev_q) { + if (prev_q->size() != q.size()) { + still = false; + } else { + for (size_t i = 0; i < q.size(); ++i) { + if (!(std::fabs(q[i] - (*prev_q)[i]) < arm->q_tol)) { + still = false; + break; + } + } + } + } + // 窗 = 本段连续静止的帧 (起点那帧没有"上一帧"可差, 但它不破坏连续性: 第一帧就算 + // 数); 一动就清空重来。 + if (still) { + window.push_back(q); + while (int(window.size()) > arm->arrive_frames) window.erase(window.begin()); + } else { + window.clear(); + } + prev_q = q; + // 主判据: 见过 bit10=1 => 要的正是 1->0 那一刻; 降级分支: 始终没见过 1 也收。 + if (int(window.size()) >= arm->arrive_frames && !st.cart_busy()) { + out.q_final = window.back(); + double err = 0.0; + for (const auto& w : window) { + for (size_t i = 0; i < w.size(); ++i) { + err = std::max(err, std::fabs(w[i] - out.q_final[i])); + } + } + out.settle_err_rad = err; + return out; + } + } + throw MotionTimeoutError(label + " 未到位, 超时 " + std::to_string(budget) + "s"); + } + + /// ⑤ 登记 token + 写帧, 然后等 0x4E 并映射结果。**全程持 cart_serial_。** + /// + /// request 的 callable 只包含那一次帧写 —— 用 write_cmd (而不是 raw_write): 它带零重力 + /// 守卫, 是既有约定; 绕开它会让"零重力中发笛卡尔"变成只能在固件侧被拒。 + /// + /// 串行是强制的 (见 cart.hpp 顶部): 持锁范围是登记 -> 等 ACK/ERR -> 等 0x4E —— 少一段 + /// 都不行, 因为假成功正是"两条在途交错"造成的。 + /// + /// 等 ACK/ERR 不是可选的: 固件受理后立刻回一条 RSP_ACK{cmd}, 被门禁或长度校验拒掉时回的是 + /// RSP_ERR{cmd,code} 且不会有 0x4E。不读这一条的话, 一次"臂未使能被拒"会表现成耗满 + /// move_timeout 之后的 CartReplyLostError (报"结局未知"), 把用户推向"臂可能正在动"的 + /// 错误结论。 + /// + /// 这一等必须按"受理必回 ACK、拒绝绝不回 ACK"的形状读 (err_waits_for_ack=true): 一条 + /// 匹配命令码的 ERR 证明不了"我这条被拒了" —— 0x3A/0x3B/0x3E 共用码空间, 而流里可以有 + /// 别人的 ERR (最现实的一条: 探测的迟到应答)。 + /// + /// 固件显式拒绝时摘掉自己那条 token: 受理前的每一道校验/门禁都在 cart_req_* 之前 break, + /// 所以"本命令的 ERR => 永远不会有 0x4E"。留着它会占住 FIFO 队首, 让下一条合法笛卡尔命令 + /// 的应答配给这条死 token。 + /// ⚠ 这里只 drop, 不给吸收额度 (clear_pending/超时那两处才给): 本命令确实没被受理 => + /// 固件一格应答都不欠 => 留额度只会去吞下一条命令自己的 0x4E。判"被拒"与"给额度"这两件 + /// 事不能同时做。 + /// + /// wait=true 时到位等待也在这把锁之内 —— 一条笛卡尔命令的"整条运动"才是串行的单位。 + /// goal 由调用方给 (它知道本次请求的终点): move_l/move_c 用终点, move_path 用最后一个 + /// 路点。目标位姿是必填的: 没有它就只剩"看 bit10"这一条判据 —— 那是本模块最防的假成功 + /// 方向。 + static CartPlan request_and_wait(Arm* arm, uint8_t cmd, + const std::vector& payload, + const std::string& label, + const std::array& goal, + std::optional seq0, bool wait) { + std::lock_guard lk(arm->cart_serial_); + const auto tok = arm->cart_->request([&]() { arm->write_cmd(cmd, payload); }); + try { + arm->require().expect(proto::RSP_ACK, 1.0, label, true, cmd, true); + } catch (const CommandRejectedError&) { + arm->cart_->drop(tok); + throw; + } + const auto reply = arm->cart_->wait(tok, arm->move_timeout); + CartPlan plan = CartPlan::from_reply(reply); + raise_for_plan(plan); + if (wait) { + const SettleInfo info = wait_settled(arm, label, seq0); + plan.started_busy = info.started_busy; + plan.q_final = info.q_final; + plan.settle_err_rad = info.settle_err_rad; + // 到位判据满足之后才回读 TCP: "停稳"不等于"停在了目标上"。 + plan.settled = tcp_reached(arm, goal); + } + return plan; + } +}; + +// =========================================================================== +// 能力探测 +// =========================================================================== + +namespace { +/// 探测的总尝试次数 (首次 + 重试)。见 probe 的说明: 读超时只可能是丢帧。 +constexpr int kCartProbeAttempts = 3; +} // namespace + +bool probe(Arm* arm) { + arm->cart_probe_silent_ = false; + for (int i = 0; i < kCartProbeAttempts; ++i) { + arm->write_query(proto::CMD_MOVE_L); + try { + const auto r = arm->require().expect( + proto::RSP_ERR, 1.0, "笛卡尔能力探测 (0x3A 空载荷)", false, + proto::CMD_MOVE_L); + // 判据就是能力判定那一条: 0x00 恒等于固件的 default 分支 (未实现该命令), + // 其余任何非 0 的错误码都说明这条命令在固件里存在。 + const uint8_t code = r.payload.size() > 1 ? r.payload[1] : 0; + return code != 0x00; + } catch (const MotionTimeoutError&) { + continue; // 安静 = 这一帧丢了, 再试一次 + } + } + arm->cart_probe_silent_ = true; // 3 次均无应答 —— 探测未获确认 (不是"确报不支持") + return false; +} + +// =========================================================================== +// 三条入口 +// =========================================================================== + +CartPlan move_l(Arm* arm, const rot::PoseInput& pose, double speed, bool wait) { + arm->require(); // ① 未连接 -> NotConnectedError (早于登记) + CartImpl::require_cart_support(arm); // ② + CartImpl::reject_in_zero_g(arm); // ③ + const auto vec = as_pose6_labeled(pose, "move_l"); // ④ (无副作用的参数校验) + const double sp = as_speed(speed, "move_l"); + std::vector payload = proto::pack_f32s(vec); + const auto spb = proto::pack_f32le(sp); + payload.insert(payload.end(), spb.begin(), spb.end()); + // 水位线取在那一次 0x3A 帧写之前 —— 按"只信命令发出之后生成的状态帧"取。 + const auto seq0 = CartImpl::seq_now(arm); + return CartImpl::request_and_wait(arm, proto::CMD_MOVE_L, payload, "move_l", vec, + seq0, wait); +} + +CartPlan move_c(Arm* arm, const rot::PoseInput& pose_start, const rot::PoseInput& pose_via, + const rot::PoseInput& pose_goal, double speed, bool wait) { + arm->require(); // ① + CartImpl::require_cart_support(arm); // ② + CartImpl::reject_in_zero_g(arm); // ③ + const auto start = as_pose6_labeled(pose_start, "move_c pose_start"); // ④ + const auto via = as_pose6_labeled(pose_via, "move_c pose_via"); + const auto goal = as_pose6_labeled(pose_goal, "move_c pose_goal"); + const double sp = as_speed(speed, "move_c"); + // ⑤ 起点校验 + 发帧 + 等应答 = 一个临界区。 + // 参数校验 (①②③④) 留在锁外: 它们不读帧、不写帧, 进锁只会平白拉长持锁时间。 + // + // 起点校验那次 get_tcp 在 cart_serial_ 之内 (不是"取锁之前"): 它读的就是"此刻 TCP", + // 与正在跑的那条运动本来就是同一个临界区。放在锁外实测会抢走在跑那条的 ACK。 + // 水位线 seq0 取在 get_tcp 之后: 那次读已经把 TCP 校验要用的状态帧吃进来了, 取在它 + // 之前只会把水位线白白放低 (闸更松), 不会有别的效果。 + std::lock_guard lk(arm->cart_serial_); + const auto tcp = arm->get_tcp().value; + if (!tcp) { + throw TransportError("move_c 需要校验起点, 但取不到当前 TCP (get_tcp 无应答)"); + } + if (!arm->pose_near(*tcp, start, CART_START_POS_TOL, CART_START_RPY_TOL)) { + std::ostringstream os; + os << "move_c: pose_start 与当前 TCP 不一致 —— 固件圆弧的起点恒为实测 TCP, 给的 " + "start=["; + for (int i = 0; i < 6; ++i) os << (i ? ", " : "") << start[size_t(i)]; + os << "], 实际 tcp=["; + for (int i = 0; i < 6; ++i) os << (i ? ", " : "") << (*tcp)[size_t(i)]; + os << "]"; + throw InvalidCommandError(os.str()); + } + std::vector payload = proto::pack_f32s(via); + const auto g = proto::pack_f32s(goal); + const auto s = proto::pack_f32le(sp); + payload.insert(payload.end(), g.begin(), g.end()); + payload.insert(payload.end(), s.begin(), s.end()); + const auto seq0 = CartImpl::seq_now(arm); + return CartImpl::request_and_wait(arm, proto::CMD_MOVE_C, payload, "move_c", goal, + seq0, wait); +} + +CartPlan move_path(Arm* arm, const std::vector& poses, double speed, + bool wait) { + arm->require(); // ① + CartImpl::require_cart_support(arm); // ② + CartImpl::reject_in_zero_g(arm); // ③ + if (poses.empty()) throw InvalidCommandError("move_path: 路径为空"); + if (poses.size() > 32) { // 固件 CART_MAX_GOAL = 32 + throw InvalidCommandError("move_path: 路点数 " + std::to_string(poses.size()) + + " 超固件上限 32 (CART_MAX_GOAL)"); + } + std::vector> pts; + pts.reserve(poses.size()); + for (const auto& p : poses) pts.push_back(as_pose6_labeled(p, "move_path")); // ④ + const double sp = as_speed(speed, "move_path"); + // 水位线取在 BEGIN 之前 —— 理由是窗口完整性: "命令"是这一整段 (BEGIN+ADD x n+RUN), + // 而闸只信 seq > seq0 的帧 => 水位线取得越晚、被排除的帧越多。 + const auto seq0 = CartImpl::seq_now(arm); + // token 只登记在 0x3E (RUN) 上: 只有 RUN 会起规划, 也只有它会产出 0x4E。 + // 0x3C/0x3D 既不在清队集合里, 也不会产生 0x4E。 + // + // BEGIN + ADD x n + RUN 整段持 cart_serial_: 只圈 RUN 是不够的 —— 固件的 RECV 收集态 + // 只从 IDLE 开, 两条并发 move_path 里后到的那条会在 BEGIN 就被拒。持锁的作用是把它变成 + // 排队 (两条都跑完), 并把 BEGIN->RUN 整段与其它笛卡尔入口互斥。 + // + // BEGIN/ADD 中途失败时不做任何"清状态"的收尾: 抛错即可 —— 固件侧 RECV 态带 2s 倒计时, + // 会自行收敛。但别拿"不占用臂"当论据 —— RECV 占用臂 (BEGIN/ADD 会当场作废在途的逐关节 + // 轨迹并把参考锚住), 不占用的是 CART_BUSY (bit10) 那一位。 + std::lock_guard lk(arm->cart_serial_); + { + std::vector begin_payload{uint8_t(pts.size())}; + const auto s = proto::pack_f32le(sp); + begin_payload.insert(begin_payload.end(), s.begin(), s.end()); + arm->cmd_expect_ack(proto::CMD_CART_BEGIN, begin_payload, "move_path BEGIN"); + } + for (size_t i = 0; i < pts.size(); ++i) { + std::vector add_payload{uint8_t(i)}; + const auto p = proto::pack_f32s(pts[i]); + add_payload.insert(add_payload.end(), p.begin(), p.end()); + arm->cmd_expect_ack(proto::CMD_CART_ADD, add_payload, + "move_path ADD " + std::to_string(i)); + } + return CartImpl::request_and_wait(arm, proto::CMD_CART_RUN, {}, "move_path RUN", + pts.back(), seq0, wait); +} + +} // namespace litearm diff --git a/src/clock.cpp b/src/clock.cpp new file mode 100644 index 0000000..eba641f --- /dev/null +++ b/src/clock.cpp @@ -0,0 +1,18 @@ +#include "litearm/clock.hpp" + +namespace litearm { + +double SteadyClock::now_s() const { + // ⚠ 与 `litearm::now_s()` (transport.cpp) **逐位一致** —— 后者保留是因为它是公开 API, + // 而本类是它的"可注入"版本。两处实现必须同口径 (同一个 steady_clock 的 + // `time_since_epoch`), 否则注入与不注入会给出不同的数值。 + using clk = std::chrono::steady_clock; + return std::chrono::duration(clk::now().time_since_epoch()).count(); +} + +const Clock& steady_clock_instance() { + static const SteadyClock kInstance; + return kInstance; +} + +} // namespace litearm diff --git a/src/codec.cpp b/src/codec.cpp deleted file mode 100644 index ccfb440..0000000 --- a/src/codec.cpp +++ /dev/null @@ -1,297 +0,0 @@ -#include "litearm/codec.hpp" -#include "litearm/exceptions.hpp" -#include "litearm.pb.h" - -#include - -namespace litearm { - -// ── Value conversion: LiteArmValue → protobuf ─────────────────────────────── - -litearm_proto::Value to_proto(const LiteArmValue& val) { - litearm_proto::Value pv; - - switch (val.kind()) { - case LiteArmValue::Kind::Null: - pv.mutable_none_val(); - break; - - case LiteArmValue::Kind::Bool: - pv.set_bool_val(val.as_bool()); - break; - - case LiteArmValue::Kind::Int: - pv.set_int_val(val.as_int()); - break; - - case LiteArmValue::Kind::Double: - pv.set_double_val(val.as_double()); - break; - - case LiteArmValue::Kind::String: - pv.set_string_val(val.as_string()); - break; - - case LiteArmValue::Kind::Bytes: { - const auto& b = val.as_bytes(); - pv.set_bytes_val(b.data(), b.size()); - break; - } - - case LiteArmValue::Kind::List: { - auto* lv = pv.mutable_list_val(); - for (const auto& item : val.as_list()) { - *lv->add_values() = to_proto(item); - } - break; - } - - case LiteArmValue::Kind::Map: { - auto* mv = pv.mutable_map_val(); - for (const auto& [k, v] : val.as_map()) { - (*mv->mutable_values())[k] = to_proto(v); - } - break; - } - } - - return pv; -} - -// ── Value conversion: protobuf → LiteArmValue ────────────────────────────── - -LiteArmValue from_proto(const litearm_proto::Value& pv) { - if (pv.has_none_val()) return LiteArmValue(nullptr); - if (pv.has_bool_val()) return LiteArmValue(pv.bool_val()); - if (pv.has_int_val()) return LiteArmValue(pv.int_val()); - if (pv.has_double_val()) return LiteArmValue(pv.double_val()); - if (pv.has_string_val()) return LiteArmValue(pv.string_val()); - - if (pv.has_bytes_val()) { - const auto& s = pv.bytes_val(); - LiteArmValue::BytesType bytes(s.begin(), s.end()); - return LiteArmValue(std::move(bytes)); - } - - if (pv.has_list_val()) { - LiteArmValue::ListType list; - for (const auto& item : pv.list_val().values()) { - list.push_back(from_proto(item)); - } - return LiteArmValue(std::move(list)); - } - - if (pv.has_map_val()) { - LiteArmValue::MapType map; - for (const auto& [k, v] : pv.map_val().values()) { - map[k] = from_proto(v); - } - return LiteArmValue(std::move(map)); - } - - return LiteArmValue(nullptr); -} - -// ── Request encoding/decoding ─────────────────────────────────────────────── - -std::string encode_request(const std::string& method, - const std::map& kwargs) -{ - litearm_proto::RpcRequest req; - req.set_method(method); - for (const auto& [k, v] : kwargs) { - (*req.mutable_kwargs())[k] = to_proto(v); - } - return req.SerializeAsString(); -} - -std::pair> -decode_request(const std::string& payload) -{ - litearm_proto::RpcRequest req; - if (!req.ParseFromString(payload)) { - throw std::runtime_error("Failed to parse RpcRequest"); - } - - std::map kwargs; - for (const auto& [k, v] : req.kwargs()) { - kwargs[k] = from_proto(v); - } - - return {req.method(), std::move(kwargs)}; -} - -// ── Reply encoding/decoding ───────────────────────────────────────────────── - -std::string encode_reply_ok(const LiteArmValue& result) { - litearm_proto::RpcReply reply; - reply.set_ok(true); - *reply.mutable_result() = to_proto(result); - return reply.SerializeAsString(); -} - -std::string encode_reply_error(const std::string& error_type, - const std::string& error_msg, - const std::map& details) -{ - litearm_proto::RpcReply reply; - reply.set_ok(false); - - auto* err = reply.mutable_error(); - err->set_type(error_type); - err->set_message(error_msg); - - for (const auto& [k, v] : details) { - litearm_proto::Value dv; - dv.set_string_val(v); - (*err->mutable_details())[k] = dv; - } - - return reply.SerializeAsString(); -} - -LiteArmValue decode_reply(const std::string& payload) { - litearm_proto::RpcReply reply; - if (!reply.ParseFromString(payload)) { - throw LiteArmError("Failed to parse RpcReply"); - } - - if (reply.ok()) { - return from_proto(reply.result()); - } - - // Reconstruct exception from error info - const auto& err = reply.error(); - std::unordered_map details; - for (const auto& [k, v] : err.details()) { - if (v.has_string_val()) { - details[k] = v.string_val(); - } else { - // Convert non-string details values to string representation - details[k] = "[non-string]"; - } - } - - throw_exception(err.type(), err.message(), std::move(details)); -} - -// ── State encoding/decoding ───────────────────────────────────────────────── - -std::string encode_state(const RobotState& state) { - litearm_proto::RobotState proto; - - for (double v : state.q) proto.add_q(v); - for (double v : state.dq) proto.add_dq(v); - for (double v : state.tau) proto.add_tau(v); - for (int v : state.errs) proto.add_errs(v); - - for (const auto& f : state.faults) { - auto* pf = proto.add_fault(); - pf->set_joint(f.joint); - pf->set_err_code(f.err_code); - } - - for (const auto& t : state.temps) { - auto* pt = proto.add_temps(); - pt->set_mos_temp(t.mos_temp); - pt->set_coil_temp(t.coil_temp); - } - - proto.set_state(state.state); - - // Feedback - auto* fb = proto.mutable_feedback(); - fb->set_max_age_s(state.feedback.max_age_s); - for (int j : state.feedback.stale_joints) { - fb->add_stale_joints(j); - } - for (const auto& jfb : state.feedback.joints) { - auto* pj = fb->add_joints(); - pj->set_joint(jfb.joint); - pj->set_received(jfb.received); - pj->set_age_s(jfb.age_s); - pj->set_fresh(jfb.fresh); - } - - // Watchdog - auto* wd = proto.mutable_watchdog(); - wd->set_enabled(state.watchdog.enabled); - wd->set_timeout_s(state.watchdog.timeout_s); - wd->set_mode(state.watchdog.mode); - wd->set_tripped(state.watchdog.tripped); - wd->set_last_kick_age_s(state.watchdog.last_kick_age_s); - - proto.set_robot_serial(state.robot_serial); - proto.set_config_checksum_sha256(state.config_checksum_sha256); - - return proto.SerializeAsString(); -} - -RobotState decode_state(const std::string& payload) { - litearm_proto::RobotState proto; - if (!proto.ParseFromString(payload)) { - throw std::runtime_error("Failed to parse RobotState"); - } - - RobotState state; - - state.q.assign(proto.q().begin(), proto.q().end()); - state.dq.assign(proto.dq().begin(), proto.dq().end()); - state.tau.assign(proto.tau().begin(), proto.tau().end()); - state.errs.assign(proto.errs().begin(), proto.errs().end()); - - for (const auto& pf : proto.fault()) { - state.faults.push_back({pf.joint(), pf.err_code()}); - } - - for (const auto& pt : proto.temps()) { - state.temps.push_back({pt.mos_temp(), pt.coil_temp()}); - } - - state.state = proto.state(); - - // Feedback - if (proto.has_feedback()) { - state.feedback.max_age_s = proto.feedback().max_age_s(); - state.feedback.stale_joints.assign( - proto.feedback().stale_joints().begin(), - proto.feedback().stale_joints().end()); - for (const auto& pj : proto.feedback().joints()) { - state.feedback.joints.push_back({ - pj.joint(), pj.received(), pj.age_s(), pj.fresh() - }); - } - } - - // Watchdog - if (proto.has_watchdog()) { - state.watchdog.enabled = proto.watchdog().enabled(); - state.watchdog.timeout_s = proto.watchdog().timeout_s(); - state.watchdog.mode = proto.watchdog().mode(); - state.watchdog.tripped = proto.watchdog().tripped(); - state.watchdog.last_kick_age_s = proto.watchdog().last_kick_age_s(); - } - - state.robot_serial = proto.robot_serial(); - state.config_checksum_sha256 = proto.config_checksum_sha256(); - - return state; -} - -// ── Estop encoding/decoding ───────────────────────────────────────────────── - -std::string encode_estop(bool trigger) { - litearm_proto::Estop estop; - estop.set_trigger(trigger); - return estop.SerializeAsString(); -} - -bool decode_estop(const std::string& payload) { - litearm_proto::Estop estop; - if (!estop.ParseFromString(payload)) { - return false; - } - return estop.trigger(); -} - -} // namespace litearm diff --git a/src/device.cpp b/src/device.cpp deleted file mode 100644 index 7a1ae1c..0000000 --- a/src/device.cpp +++ /dev/null @@ -1,133 +0,0 @@ -#include "litearm/device.hpp" - -namespace litearm { - -// ── RemoteDevice ───────────────────────────────────────────────────────────── - -RemoteDevice::RemoteDevice(std::string device_id, RpcFn rpc) - : device_id_(std::move(device_id)) - , rpc_(std::move(rpc)) -{ -} - -LiteArmValue RemoteDevice::call(const std::string& method, - std::map kwargs) -{ - return rpc_("device." + device_id_ + "." + method, std::move(kwargs)); -} - -// ── Common methods ─────────────────────────────────────────────────────────── - -LiteArmValue RemoteDevice::get_status() { - return call("get_status"); -} - -LiteArmValue RemoteDevice::get_info() { - return call("get_info"); -} - -bool RemoteDevice::connect() { - return call("connect").as_bool(); -} - -void RemoteDevice::disconnect() { - call("disconnect"); -} - -bool RemoteDevice::clear_faults() { - return call("clear_faults").as_bool(); -} - -// ── End-effector methods ───────────────────────────────────────────────────── - -bool RemoteDevice::open() { - return call("open").as_bool(); -} - -bool RemoteDevice::close() { - return call("close").as_bool(); -} - -bool RemoteDevice::set_force(double force) { - return call("set_force", {{"force", LiteArmValue(force)}}).as_bool(); -} - -// ── Dexterous hand methods ─────────────────────────────────────────────────── - -LiteArmValue RemoteDevice::get_state() { - return call("get_state"); -} - -bool RemoteDevice::set_gesture(const std::string& gesture) { - return call("set_gesture", {{"gesture", LiteArmValue(gesture)}}).as_bool(); -} - -LiteArmValue RemoteDevice::list_gestures() { - return call("list_gestures"); -} - -bool RemoteDevice::finger_move(const std::vector& pose) { - return call("finger_move", {{"pose", LiteArmValue::from_vec(pose)}}).as_bool(); -} - -bool RemoteDevice::set_speed(const std::vector& speed) { - return call("set_speed", {{"speed", LiteArmValue::from_vec(speed)}}).as_bool(); -} - -bool RemoteDevice::set_torque(const std::vector& torque) { - return call("set_torque", {{"torque", LiteArmValue::from_vec(torque)}}).as_bool(); -} - -// ── Gripper methods ────────────────────────────────────────────────────────── - -bool RemoteDevice::set_width(double width) { - return call("set_width", {{"width", LiteArmValue(width)}}).as_bool(); -} - -double RemoteDevice::get_width() { - return call("get_width").as_double(); -} - -// ── Teach pendant methods ──────────────────────────────────────────────────── - -LiteArmValue RemoteDevice::get_joints() { - return call("get_joints"); -} - -LiteArmValue RemoteDevice::get_buttons() { - return call("get_buttons"); -} - -// ── DeviceManager ──────────────────────────────────────────────────────────── - -DeviceManager::DeviceManager(RpcFn rpc) - : rpc_(std::move(rpc)) -{ -} - -RemoteDevice DeviceManager::get(const std::string& device_id) { - auto it = devices_.find(device_id); - if (it == devices_.end()) { - it = devices_.emplace(device_id, RemoteDevice(device_id, rpc_)).first; - } - return it->second; -} - -RemoteDevice DeviceManager::operator[](const std::string& device_id) { - return get(device_id); -} - -bool DeviceManager::contains(const std::string& device_id) const { - return devices_.count(device_id) > 0; -} - -std::vector DeviceManager::device_ids() const { - std::vector ids; - ids.reserve(devices_.size()); - for (const auto& [id, dev] : devices_) { - ids.push_back(id); - } - return ids; -} - -} // namespace litearm diff --git a/src/diagnostics.cpp b/src/diagnostics.cpp new file mode 100644 index 0000000..949089d --- /dev/null +++ b/src/diagnostics.cpp @@ -0,0 +1,205 @@ +#include "litearm/diagnostics.hpp" + +#include +#include + +#include "litearm/arm.hpp" + +namespace litearm { + +namespace { + +/// 固件 kin_bench_run 产出的计时段名 (长名在前, 避免前缀误配)。 +/// +/// ⚠ 固件用 txt(名字) + u32_cat(n) 拼接, 而 u32_cat 只在数字之后补空格 —— 所以名字与第一个 +/// 数字是相连的 (`FK200 108 240 ` / `LOOP14000 `), 不能用「名字 + 空白 + 数字」式正则去切。 +const char* const kBlockNames[] = {"LOOP1", "RNEA", "JAC", "IK", "FK", "SC", "G", "M", "LAW"}; + +bool ends_with_digits_only(const std::string& s) { + if (s.empty()) return false; + for (char c : s) { + if (!std::isdigit(static_cast(c)) && c != ' ') return false; + } + return true; +} + +/// 尝试把 `line` 解析成 `<块名><数字序列>`; 成功返回 true 并填 name/values。 +bool parse_timing(const std::string& line, std::string* name, + std::vector* values) { + for (const char* blk : kBlockNames) { + const std::string b(blk); + if (line.rfind(b, 0) != 0) continue; + const std::string rest = line.substr(b.size()); + if (!ends_with_digits_only(rest)) continue; + std::vector vals; + std::istringstream is(rest); + long long v = 0; + while (is >> v) vals.push_back(v); + if (vals.empty()) continue; + *name = b; + *values = vals; + return true; + } + return false; +} + +/// key=value 对。 +/// ⚠ 键名可以带数字 —— rxl0 / rxl1 就是 (它们正是"RX FIFO 溢出 = 静默丢反馈 = 可能整臂 +/// EMERGENCY"的那两个计数器)。从前写的是 [a-z_]+, 匹配不到带数字的键, 于是那两个静默丢失。 +void parse_kv(const std::string& body, std::map* out) { + size_t i = 0; + while (i < body.size()) { + // 键: [a-z_][a-z0-9_]* + const size_t start = i; + if (!(std::islower(static_cast(body[i])) || body[i] == '_')) { + ++i; + continue; + } + while (i < body.size() && + (std::islower(static_cast(body[i])) || + std::isdigit(static_cast(body[i])) || body[i] == '_')) { + ++i; + } + const std::string key = body.substr(start, i - start); + if (i >= body.size() || body[i] != '=') continue; + ++i; // skip '=' + const size_t dstart = i; + while (i < body.size() && std::isdigit(static_cast(body[i]))) ++i; + if (i == dstart) continue; + try { + (*out)[key] = std::stoll(body.substr(dstart, i - dstart)); + } catch (...) { + } + } +} + +/// `flt=<锁存拍> /<原因码>` —— 数字与 '/' 之间有一个尾随空格 (u32_cat 语义所致) +void parse_flt(const std::string& body, std::map* out, + std::string* stripped) { + const size_t pos = body.find("flt="); + if (pos == std::string::npos) { + *stripped = body; + return; + } + size_t i = pos + 4; + const size_t d0 = i; + while (i < body.size() && std::isdigit(static_cast(body[i]))) ++i; + if (i == d0) { + *stripped = body; + return; + } + const long long tick = std::stoll(body.substr(d0, i - d0)); + while (i < body.size() && (body[i] == ' ' || body[i] == '/')) ++i; + const size_t d1 = i; + while (i < body.size() && std::isdigit(static_cast(body[i]))) ++i; + if (i == d1) { + *stripped = body; + return; + } + const long long cause = std::stoll(body.substr(d1, i - d1)); + (*out)["fault_tick"] = tick; + (*out)["fault_cause"] = cause; + std::string s = body; + s.erase(pos, i - pos); + *stripped = s; +} + +std::vector split_lines(const std::string& text) { + std::vector out; + std::string cur; + for (char c : text) { + if (c == '\n') { + out.push_back(cur); + cur.clear(); + } else if (c != '\r') { + cur.push_back(c); + } + } + if (!cur.empty()) out.push_back(cur); + return out; +} + +std::string trim(const std::string& s) { + size_t a = 0, b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +long long get(const std::map& m, const char* k) { + const auto it = m.find(k); + return it == m.end() ? 0 : it->second; +} + +} // namespace + +long long KinBenchResult::crc_errors() const { return get(link, "crc"); } +long long KinBenchResult::reply_dropped() const { return get(link, "rfd"); } +long long KinBenchResult::can_tx_fail() const { return get(link, "txf"); } +long long KinBenchResult::loop_max_kcycle() const { return get(link, "loop_k"); } +long long KinBenchResult::loop_overruns() const { return get(link, "ovr"); } +long long KinBenchResult::rx_fifo_lost_motor() const { return get(link, "rxl0"); } +long long KinBenchResult::rx_fifo_lost_bridge() const { return get(link, "rxl1"); } +long long KinBenchResult::gsusb_ring_drops() const { return get(link, "rbd"); } + +KinBenchResult parse_kin_bench(const std::string& text) { + if (trim(text).empty()) { + // 空文本 = 异常回执, 拒绝当成全 0 返回。 + throw TransportError("KIN_BENCH 回执为空 —— 非正常响应, 拒绝当成全 0 返回"); + } + KinBenchResult out; + out.raw = text; + for (const std::string& raw_line : split_lines(text)) { + const std::string line = trim(raw_line); + if (line.empty()) continue; + if (line.rfind("LINK", 0) == 0) { + std::string body = line.substr(4); + std::string stripped; + parse_flt(body, &out.link, &stripped); + std::map kv; + parse_kv(stripped, &kv); + for (const auto& item : kv) { + if (item.first == "st") { + // 语义化的布尔: 1 = 从 flash 装载 + out.link["store_from_flash"] = item.second ? 1 : 0; + } else { + out.link[item.first] = item.second; + } + } + continue; + } + std::string name; + std::vector vals; + if (parse_timing(line, &name, &vals)) { + out.timings[name] = vals; + } + } + return out; +} + +Msg Diagnostics::kin_bench(double timeout) { + Arm* arm = arm_; + arm->write_query(proto::CMD_KIN_BENCH); // 查询/自检类: 不受零重力守卫 + // 第 1 帧 (各项耗时) —— 等满 timeout + const auto r1 = arm->require().expect(proto::RSP_KIN_BENCH, timeout, "kin_bench", true, + proto::CMD_KIN_BENCH); + std::string all(r1.payload.begin(), r1.payload.end()); + // 第 2 帧 (LINK 诊断行) —— 只等一个短窗口: 新固件它紧跟第 1 帧到, 旧固件不发。 + // 超时不是错误 (旧固件只有一帧, 那是兼容情形); 但也不能因为"拿不到就算了"而把窗口设 + // 成 0 —— 那等于没改, 又回到静默 0。 + for (int i = 1; i < KIN_BENCH_FRAMES; ++i) { + try { + const auto r2 = arm->require().expect(proto::RSP_KIN_BENCH, KIN_BENCH_MORE_S, + "kin_bench(LINK 帧)", false, + proto::CMD_KIN_BENCH); + all.append(r2.payload.begin(), r2.payload.end()); + } catch (const MotionTimeoutError&) { + break; + } catch (const TransportError&) { + break; + } + } + return arm->wrap(parse_kin_bench(all), proto::RSP_KIN_BENCH); +} + +} // namespace litearm diff --git a/src/errors.cpp b/src/errors.cpp new file mode 100644 index 0000000..686d953 --- /dev/null +++ b/src/errors.cpp @@ -0,0 +1,268 @@ +#include "litearm/errors.hpp" + +#include + +namespace litearm { + +namespace { + +// 具体档: (cmd, code) -> 语义。逐条取自固件原注释, 不自行起名。 +// code == 0x00 一律不在此表 (见头文件说明)。 +const std::map, std::string>& build_err_text() { + static const std::map, std::string> kTable = { + // ---- 0x01 MOVE_J (每轴一条独立 S 曲线; 0x07 是同步版, 同码同语义) ---- + {{0x01, 0x01}, "载荷长度不足 (需 4xN+4 字节)"}, + {{0x01, 0x02}, "含非有限值, 或 sp 越界 —— sp 是 0..1 的小数倍率 (传 30 想表达 " + "30% 属单位陷阱, 固件显式拒而不静默 clamp 成 100%)"}, + {{0x01, 0x03}, "未使能, 或 EMERGENCY 锁存 (ctrl_accept_move_j)"}, + {{0x01, 0x04}, "零重力(拖动示教)进行中 —— 须显式 zero_g off 退出, 固件不隐式退出"}, + {{0x01, 0x06}, "掉线刚性持位锁存 (drop_hold) 中 —— 须先 reset/clear_faults"}, + + // ---- 0x07 MOVE_J_SYNC (载荷与 0x01 逐字节相同, 只换轨迹形状) ---- + {{0x07, 0x01}, "载荷长度不足 (需 4xN+4 字节)"}, + {{0x07, 0x02}, "含非有限值, 或 sp 越界 (同 0x01 的 0..1 小数倍率约定)"}, + {{0x07, 0x03}, "未使能, 或 EMERGENCY 锁存 (ctrl_accept_move_j_sync)"}, + {{0x07, 0x04}, "零重力(拖动示教)进行中"}, + {{0x07, 0x06}, "掉线刚性持位锁存 (drop_hold) 中 —— 须先 reset/clear_faults"}, + + // ---- 0x02 MOVE_P (固件后台 IK + 走 S 曲线) ---- + {{0x02, 0x01}, "载荷长度不足 (需 28 字节: pose[6] + sp)"}, + {{0x02, 0x02}, "位姿含非有限值, 或 sp 越界 (0..1 小数倍率)"}, + {{0x02, 0x03}, "**二义码, 两处发射点语义不同**: 1) dispatch 的「未使能 / " + "EMERGENCY 锁存」 2) 后台 IK 失败 —— 原注释「不可达 / 解非法」。" + "上位机无法从本码区分两者, 只能按「这条 move_p 没成立」处置 " + "(不要据此判失能)"}, + {{0x02, 0x04}, "零重力(拖动示教)进行中"}, + {{0x02, 0x05}, "IK 后台通道忙 (已有一次后台求解在途)"}, + + // ---- 0x3A / 0x3B MOVE_L / MOVE_C (固件原生笛卡尔) ---- + // 门禁三档由 cart_gate_ok() 统一发: !enabled(0x03) -> drop_hold(0x06) -> + // 零重力(0x04); 收口码由 cart_reply() 透传 cart_req_* 的 rc。 + {{0x3A, 0x01}, "载荷长度不足 (需 28 字节)"}, + {{0x3A, 0x02}, "位姿含非有限值, 或 sp 越界 (0..1 小数倍率)"}, + {{0x3A, 0x03}, "未使能 (笛卡尔门禁 cart_gate_ok)"}, + {{0x3A, 0x04}, "零重力中 (门禁) 或 状态机不允许 —— RECV 会话未收尾时不许插队"}, + {{0x3A, 0x06}, "掉线刚性持位锁存 (drop_hold) 中 —— 须先 reset/clear_faults"}, + {{0x3B, 0x01}, "载荷长度不足 (需 52 字节: 途经点 + 终点 + sp)"}, + {{0x3B, 0x02}, "位姿含非有限值, 或 sp 越界 (0..1 小数倍率)"}, + {{0x3B, 0x03}, "未使能 (笛卡尔门禁)"}, + {{0x3B, 0x04}, "零重力中 (门禁) 或 状态机不允许 —— RECV 会话未收尾时不许插队"}, + {{0x3B, 0x06}, "掉线刚性持位锁存 (drop_hold) 中"}, + + // ---- 0x3C / 0x3D / 0x3E CART_BEGIN / ADD / RUN (多路点三段式) ---- + // BEGIN/ADD 刻意不走 cart_gate_ok (RECV 态不占用臂), 但三档判据与档序逐档一致。 + {{0x3C, 0x01}, "载荷长度不足 (需 5 字节: 点数 n + sp)"}, + {{0x3C, 0x02}, "sp 非有限/越界, 或点数 n 越界 (须 1..CART_MAX_GOAL=32)"}, + {{0x3C, 0x03}, "未使能"}, + {{0x3C, 0x04}, "零重力中 或 状态机不允许 —— 只有 IDLE 能开新会话 (含 RECV 重入)"}, + {{0x3C, 0x06}, "掉线刚性持位锁存 (drop_hold) 中"}, + {{0x3D, 0x01}, "载荷长度不足 (需 25 字节: idx + pose[6])"}, + {{0x3D, 0x02}, "位姿含非有限值 (dispatch), 或 idx 越界 (cart_req_add)"}, + {{0x3D, 0x03}, "未使能"}, + {{0x3D, 0x04}, "零重力中, 或 非 RECV 态, 或该 idx 已 ADD 过 (去重)"}, + {{0x3D, 0x06}, "掉线刚性持位锁存 (drop_hold) 中"}, + {{0x3E, 0x03}, "未使能 (笛卡尔门禁)"}, + {{0x3E, 0x04}, "零重力中 (门禁) 或 状态机不允许 —— 非 RECV 态, 或点名不满"}, + {{0x3E, 0x06}, "掉线刚性持位锁存 (drop_hold) 中"}, + + // ---- 0x03 MOVE_JS (流式关节透传) ---- + {{0x03, 0x01}, "载荷长度不足 (需 8xN 字节; 带 tau_ff 时 12xN)"}, + {{0x03, 0x02}, "q/dq(/tau_ff) 含非有限值"}, + {{0x03, 0x03}, "未使能, 或 EMERGENCY 锁存 (ctrl_accept_move_js)"}, + {{0x03, 0x04}, "零重力(拖动示教)进行中"}, + {{0x03, 0x06}, "掉线刚性持位锁存 (drop_hold) 中"}, + + // ---- 0x04 MOVE_MIT (单关节透传) ---- + {{0x04, 0x01}, "载荷长度不足 (需 21 字节)"}, + {{0x04, 0x02}, "idx 越界, 或 q/dq/kp/kd/tau 含非有限值"}, + {{0x04, 0x03}, "未使能, 或 EMERGENCY 锁存 (ctrl_accept_move_mit)"}, + {{0x04, 0x04}, "零重力(拖动示教)进行中"}, + {{0x04, 0x06}, "掉线刚性持位锁存 (drop_hold) 中"}, + + // ---- 0x05 MOVE_MIT_ALL (全臂单帧透传) ---- + {{0x05, 0x01}, "载荷长度不足 (需 20xN 字节)"}, + {{0x05, 0x02}, "q/dq/kp/kd/tau 含非有限值"}, + {{0x05, 0x03}, "未使能, 或 EMERGENCY 锁存 (ctrl_accept_move_mit_all)"}, + {{0x05, 0x04}, "零重力(拖动示教)进行中"}, + {{0x05, 0x06}, "掉线刚性持位锁存 (drop_hold) 中"}, + + // ---- 0x06 ZERO_G (进入/退出拖动示教; 重发即保活) ---- + {{0x06, 0x01}, "载荷长度不足 (需 1 字节: on)"}, + {{0x06, 0x02}, "拒绝: EMERGENCY 锁存 (进入与退出都拒), 或进入时未使能 " + "(退出 on=0 幂等, 未使能也收)"}, + + // ---- 0x2A HOME (回零位舒展姿; 内部复用 ctrl_accept_move_j) ---- + {{0x2A, 0x03}, "未使能 (dispatch 前置) 或 EMERGENCY 锁存 (ctrl_accept_move_j)"}, + {{0x2A, 0x04}, "零重力(拖动示教)进行中"}, + {{0x2A, 0x06}, "掉线刚性持位锁存 (drop_hold) 中"}, + + // ---- 0x10 ENABLE (使能全关节; 码位决定是否值得重试) ---- + {{0x10, 0x08}, "**未激活(未授权)—— 重发无用, 无旁路**。固件 ctrl_enable() 的" + "第一条判据就是 !license_is_activated(), 故本码说的是" + "「这台臂有没有被授权」, 而不是「现在能不能使能」—— 后者优先于" + "其余所有码。补救: 用 Arm.license() 查 state, 再拿厂商签发的凭据" + "走 Arm.activate()"}, + {{0x10, 0x03}, "可重试 —— 电机反馈未齐 / CMODE 首写 (含 CMODE 回读不良已补写, " + "重发即可)"}, + {{0x10, 0x06}, "**锁存, 须先 RESET** —— EMERGENCY 或 joint_fault 非 0 " + "(重发无用)。码值虽与运动命令的 0x06 相同, 但判据不同 —— " + "本档只看 EMERGENCY / joint_fault, 不判掉线刚性持位锁存"}, + {{0x10, 0x07}, "部分轴 CMODE 补写预算耗尽仍未进 MIT —— **重发无用**, 须现场排查"}, + + // ---- 0x15 ENTER_DFU (SDK 里唯一的终端态操作: 进 ROM bootloader) ---- + {{0x15, 0x01}, "载荷必须为空 (本命令不吃参数)"}, + {{0x15, 0x02}, "ROM 系统 bootloader 向量表无效 —— 跳过去也起不来, 故当场拒"}, + {{0x15, 0x03}, "使能中, 或使能在途 (enable_pending) —— 跳转会停 TIM3, " + "电机 100ms 后松开, 有重力负载则下垂"}, + + // ---- 0x3F ACTIVATE (提交授权凭据; 固件 1.8.0+) ---- + // 线上码除长度沿用 0x01 外, 其余一律折成 0x02, 故 0x02 不等于"你的码不对"。 + {{0x3F, 0x01}, "载荷长度不足 (需 >=28 字节) —— 注意判据是 < 28, 更长的载荷会被" + "接受并静默忽略尾部"}, + {{0x3F, 0x02}, "**聚合档, 必须回查 0x2F 才能定性** —— flags 保留位非 0 / 已经" + "激活过 / MAC 不符 / 固件编译进来的密钥非法 / 写或读回失败, 全折叠" + "成本码。其中「已经激活过」意味着设备可能其实已经解锁 (例如上一条 " + "ACK 被丢掉后重发) —— 把它当失败会让产线重复返工, 故不要据本码判" + "机器没激活"}, + {{0x3F, 0x04}, "已武装 (使能中或使能在途) 须先失能 —— 与 0x25 同语义: 写 flash " + "期间电机不得在无监督下保持使能"}, + {{0x3F, 0x05}, "flash 忙 (main 正在整扇区保存参数) —— 稍后重试即可"}, + + // ---- 0x20 / 0x21 运行期旋钮 (都只判长度) ---- + {{0x20, 0x01}, "载荷长度不足 (需 1 字节: mode)"}, + {{0x21, 0x01}, "载荷长度不足 (需 1 字节: 百分比)"}, + + // ---- 0x42 GET_IK / 0x49 KIN_BENCH (后台求解通道) ---- + {{0x42, 0x01}, "载荷长度不足 (需 52 字节: pose[6] + seed[7])"}, + {{0x42, 0x02}, "pose 或 seed 含非有限值"}, + {{0x42, 0x05}, "忙 —— 已有一次后台 IK 在途 (成功登记时不即时应答, 等 RSP_IK)"}, + {{0x49, 0x05}, "忙 —— 已有一次 kin_bench 在途"}, + + // ---- 0x22 / 0x23 / 0x24 关节参数 ---- + {{0x22, 0x01}, "载荷长度不足 (需 13 字节: idx + kp + kd + tau_max)"}, + {{0x22, 0x02}, "idx 越界, 或 kp/kd/tau_max 非法 (NaN 或 kp<0 / kd<0 / tau_max<=0)"}, + {{0x23, 0x01}, "载荷长度不足 (需 9 字节: idx + q_min + q_max)"}, + {{0x23, 0x02}, "数值非法 (NaN / q_min>=q_max), 或 idx 越界, 或放宽请求 " + "(固件只许收窄, 照编译期默认值取交集)"}, + {{0x23, 0x03}, "新限位关不住在途目标 —— 武装中且该轴的当前参考/在途轨迹目标落在" + "新区间之外; 笛卡尔在途 (state != IDLE) 或同步轨迹在途时一律回本码"}, + {{0x24, 0x01}, "载荷长度不足 (需 1 字节: idx)"}, + {{0x24, 0x02}, "idx 越界"}, + + // ---- 0x25 / 0x36 参数固化与恢复出厂 ---- + {{0x25, 0x04}, "武装中 (enabled 或 enable_pending 在途) —— 须先失能。" + "两处都会发本码: 受理时, 以及异步擦写执行前对武装态的复查"}, + {{0x25, 0x03}, "**异步保存失败** —— flash_store_save() 返回 false。" + "与 0x04 不同, 它在受理之后才到达 (main 循环里执行)"}, + {{0x36, 0x04}, "武装中 (enabled 或 enable_pending 在途) —— 须先失能"}, + {{0x36, 0x05}, "flash 参数保存进行中 —— 忙, 稍后重试 (交错会毁参数)"}, + {{0x36, 0x03}, "flash 失效化失败 —— 恢复出厂未生效 (RAM 与 flash 都保持旧值)"}, + + // ---- 0x26 / 0x27 / 0x28 / 0x2B / 0x2C / 0x31 前馈与动力学旋钮 ---- + {{0x26, 0x01}, "载荷长度不足 (需 1 + 4xDOF 字节)"}, + {{0x26, 0x02}, "item 越界 (1..15), 或值非法 (NaN / item>=9 的符号契约不符: " + "fv>=0, fc0>=0, fc1<=0)"}, + {{0x26, 0x04}, "[仅 feat/hyy-model-import 分支] item 7 (gravity_scale) 武装态拒 " + "—— 须先失能"}, + {{0x27, 0x01}, "载荷长度不足 (需 4 字节: ff_mask)"}, + {{0x28, 0x01}, "载荷长度不足 (需 6 字节: item + sub + f32)"}, + {{0x28, 0x02}, "item 越界 (合法区间是 1..8 与 10..18; item 9 已保留给 0x2C 读 " + "ff_mask, 写它走 default 同样回本码), 或值非法 —— 只有三类: " + "NaN、item 5/6 的 sub>2、item 7 的 v 不属于 {0,1,2}。" + "幅值一律静默钳制只对有 clamp 的那些 item 成立: 它们过 " + "ff_clamp() 后照样 return true 回 ACK。例: payload_mass 钳 [0,20] " + "=> set_payload(-5) 回 ACK 并把质量静默钳成 0, 不是被拒"}, + {{0x28, 0x04}, "[仅 feat/hyy-model-import 分支] item 6 (gravity 向量) 武装态拒"}, + {{0x2B, 0x01}, "载荷长度不足 (需 1 字节: item)"}, + {{0x2B, 0x02}, "item 越界 (须 1..15)"}, + {{0x2C, 0x01}, "载荷长度不足 (需 2 字节: item + sub)"}, + {{0x2C, 0x02}, "item 越界 (须 1..18)"}, + {{0x31, 0x02}, "preset 非法 (须 0..2), 或载荷长度不足 (两者共用本码)"}, + + // ---- 0x30 / 0x32 / 0x33 / 0x34 / 0x37 / 0x39 动力学模型在线导入 ---- + {{0x30, 0x01}, "载荷长度不足 (需 1 + 4xBODY_PARAMS 字节)"}, + {{0x30, 0x02}, "body_idx 越界, 或 staging 失败 (数值非法)"}, + {{0x32, 0x01}, "载荷长度不足 (需 2 字节: expected_mask)"}, + {{0x32, 0x04}, "武装中 —— 须先失能 (门控排在掩码校验之前)"}, + {{0x32, 0x07}, "**模型掩码不符** —— 产线最常见故障, 与「数值非法」完全不同"}, + {{0x32, 0x02}, "dyn_model_commit 失败"}, + {{0x33, 0x01}, "载荷长度不足 (需 4xJM_N 字节)"}, + {{0x33, 0x02}, "staging 失败 (数值非法)"}, + {{0x34, 0x01}, "载荷长度不足 (需 1 字节: body_idx)"}, + {{0x34, 0x02}, "body_idx 越界"}, + {{0x37, 0x04}, "武装中 —— 须先失能"}, + {{0x39, 0x01}, "载荷长度不足 (需 4xDOF 字节: q[7])"}, + + // ---- 0x2D / 0x2E 300Hz 控制拍采集 ---- + {{0x2D, 0x01}, "载荷长度不足 (需 4 字节: n_ticks)"}, + {{0x2D, 0x02}, "超容量 —— 请求的拍数超过 LOG_MAX_SAMPLES"}, + {{0x2E, 0x01}, "载荷长度不足 (需 4 字节: offset)"}, + }; + return kTable; +} + +// 通用档: code -> 语义 (跨命令)。具体档未命中时用。 +// 通用档不是"可以随便猜"的意思 —— 每个 code 在固件里都有稳定的骨架含义。 +const std::map& build_err_code_text() { + static const std::map kTable = { + {0x00, "固件没有实现这条命令 (default 分支, 无任何副作用)"}, + {0x01, "载荷长度不足"}, + {0x02, "字段非法 (非有限值 / 越界 / idx 或 item 非法)"}, + {0x03, "被拒 —— 逐命令而异 (常见: 未使能 / EMERGENCY 锁存 / 新限位关不住在途目标)"}, + {0x04, "被拒 —— 逐命令而异 (常见: 须先失能 / 零重力中 / 状态机不允许)"}, + {0x05, "忙 —— 通道被占或参数保存中, 稍后重试"}, + {0x06, "锁存 —— 须先 reset / clear_faults"}, + {0x07, "掩码不符, 或 CMODE 补写预算耗尽 (重发无用)"}, + }; + return kTable; +} + +std::string hex2(int v) { + char buf[8]; + std::snprintf(buf, sizeof(buf), "%02X", v & 0xFF); + return buf; +} + +} // namespace + +const std::map, std::string>& err_text() { return build_err_text(); } +const std::map& err_code_text() { return build_err_code_text(); } + +std::string err_reason(uint8_t cmd, uint8_t code) { + // 三级查找 (照认不出也要把原始码带出来的口径, 不许吞): + // 1. 具体档命中 -> 用它; + // 2. 否则通用档命中 -> 用它, 并附上原始码; + // 3. 都没有 -> 明说"未登记", 带上原始 cmd/code。 + // 第 2/3 档之所以必须带原始码: 固件新增一档错误码时, 这条路径就是唯一会让上位机 + // 看见"这是我没见过的码"的地方 —— 折叠成一句通用文本等于把它藏起来。 + const auto& table = err_text(); + const auto it = table.find({int(cmd), int(code)}); + if (it != table.end()) return it->second; + const auto& gen = err_code_text(); + const auto git = gen.find(int(code)); + if (git != gen.end()) { + return git->second + " [本命令未登记此码: cmd=0x" + hex2(cmd) + + ", code=0x" + hex2(code) + "]"; + } + return "**未登记的固件错误码** cmd=0x" + hex2(cmd) + ", code=0x" + hex2(code) + + " —— 子版本可能比本 SDK 新, 请对照固件 usb_cmd.c 的 " + "usb_cmd_reply(RSP_ERR...)"; +} + +const std::set& enable_retryable_codes() { + static const std::set kCodes = {0x03}; + return kCodes; +} + +void raise_firmware_error(const std::vector& payload, const std::string& prefix) { + const uint8_t cmd = payload.size() > 0 ? payload[0] : 0; + const uint8_t code = payload.size() > 1 ? payload[1] : 0; + const std::string base = prefix + "ERR [" + hex2(cmd) + "," + hex2(code) + "]"; + if (code == 0x00) { + throw UnsupportedByFirmwareError( + base + " —— 固件没有实现这条命令 (需烧入引入该命令的固件版本)", cmd, code); + } + throw CommandRejectedError(base + " —— " + err_reason(cmd, code), cmd, code); +} + +} // namespace litearm diff --git a/src/exceptions.cpp b/src/exceptions.cpp deleted file mode 100644 index 8a9eb3f..0000000 --- a/src/exceptions.cpp +++ /dev/null @@ -1,73 +0,0 @@ -#include "litearm/exceptions.hpp" -#include - -namespace litearm { - -namespace { - -const std::unordered_set SAFETY_VIOLATION_TYPES = { - "SafetyViolationError", - "FeedbackTimeoutError", - "FollowingError", - "MotionTimeoutError", - "MotorFaultError", - "ArmFault", - "WatchdogError", -}; - -} // anonymous namespace - -bool is_safety_violation_type(const std::string& type_name) { - return SAFETY_VIOLATION_TYPES.count(type_name) > 0; -} - -[[noreturn]] void throw_exception( - const std::string& type_name, - const std::string& message, - std::unordered_map details) -{ - // Safety violation subclasses — throw with details - if (type_name == "FeedbackTimeoutError") - throw FeedbackTimeoutError(message, std::move(details)); - if (type_name == "FollowingError") - throw FollowingError(message, std::move(details)); - if (type_name == "MotionTimeoutError") - throw MotionTimeoutError(message, std::move(details)); - if (type_name == "MotorFaultError") - throw MotorFaultError(message, std::move(details)); - if (type_name == "ArmFault") - throw ArmFault(message, std::move(details)); - if (type_name == "WatchdogError") - throw WatchdogError(message, std::move(details)); - if (type_name == "SafetyViolationError") - throw SafetyViolationError(message, std::move(details)); - - // CartesianPlanError - if (type_name == "CartesianPlanError") - throw CartesianPlanError(message); - - // Other known types - if (type_name == "ConfigurationError") - throw ConfigurationError(message); - if (type_name == "StateTransitionError") - throw StateTransitionError(message); - if (type_name == "NotConnectedError") - throw NotConnectedError(message); - if (type_name == "InvalidCommandError") - throw InvalidCommandError(message); - if (type_name == "TransportError") - throw TransportError(message); - if (type_name == "CanModeMismatch") - throw CanModeMismatch(message); - if (type_name == "CanMotorNotRegistered") - throw CanMotorNotRegistered(message); - if (type_name == "CanWriteTimeout") - throw CanWriteTimeout(message); - if (type_name == "MotionCancelled") - throw MotionCancelled(message); - - // Fallback - throw LiteArmError(message); -} - -} // namespace litearm diff --git a/src/framing.cpp b/src/framing.cpp new file mode 100644 index 0000000..29261ca --- /dev/null +++ b/src/framing.cpp @@ -0,0 +1,65 @@ +#include "litearm/framing.hpp" + +namespace litearm { + +std::optional FrameReader::try_assemble() { + while (!buf_.empty()) { + if (buf_[0] != proto::SOF) { + // 不是帧头 ⇒ 当噪声记一笔再丢掉。**必须记**: 固件开机横幅就是走这条路的, + // 而它是"板子上跑的是哪一版 / 那次是不是看门狗复位"的唯一来源。 + note(buf_[0]); + buf_.erase(buf_.begin()); + continue; + } + if (buf_.size() < 3) return std::nullopt; // CMD/LEN 还没到齐 + const size_t ln = buf_[2]; + const size_t total = 3 + ln + 2; // SOF + CMD + LEN + 载荷 + CRC2 + if (buf_.size() < total) return std::nullopt; // 帧体没齐 —— **保留**(含 SOF) + auto got = proto::unpack_frame(buf_.data(), total); + if (got) { + buf_.erase(buf_.begin(), buf_.begin() + long(total)); + partial_since_ = -1.0; + return got; + } + // CRC 或长度坏 —— 丢掉**这个** SOF 重找, 而不是丢掉整段缓冲: + // 后面很可能紧跟一条真帧, 而它的 SOF 就在不远处。 + buf_.erase(buf_.begin()); + } + return std::nullopt; +} + +std::optional FrameReader::feed(const uint8_t* data, size_t len) { + if (data != nullptr && len > 0) buf_.insert(buf_.end(), data, data + len); + return try_assemble(); +} + +bool FrameReader::tick(double now) { + bool changed = false; + // ⚠ 循环: 一次可能连续丢掉多个假帧头 (缓冲里可能积了好几段收不齐的残片)。 + while (!buf_.empty() && buf_[0] == proto::SOF) { + if (partial_since_ < 0.0) { + partial_since_ = now; // 这一拍起算 + break; + } + if (now - partial_since_ <= kPartialMaxS) break; // 还没超时, 再等等 + buf_.erase(buf_.begin()); // 收不齐 ⇒ 假帧头, 丢掉重扫 + partial_since_ = -1.0; + changed = true; + } + // 头部不再是 SOF (或缓冲空了) ⇒ 没有"在途的残帧"这回事 + if (buf_.empty() || buf_[0] != proto::SOF) partial_since_ = -1.0; + return changed; +} + +void FrameReader::reset() { + buf_.clear(); + noise_.clear(); + partial_since_ = -1.0; +} + +void FrameReader::note(uint8_t b) { + noise_.push_back(static_cast(b)); + while (noise_.size() > kNoiseMax) noise_.pop_front(); +} + +} // namespace litearm diff --git a/src/log.cpp b/src/log.cpp new file mode 100644 index 0000000..ecc46dc --- /dev/null +++ b/src/log.cpp @@ -0,0 +1,176 @@ +#include "litearm/log.hpp" + +#include +#include + +#include "litearm/arm.hpp" + +namespace litearm { + +namespace { +/// 单块 RSP_LOG_DATA 的头长度: u32 total + u32 next + u8 n +constexpr size_t kLogHdr = 9; +/// 轮询已记录量的间隔 (秒) +constexpr double kPollS = 0.05; +} // namespace + +int sample_size(int n_joints) { return 4 + 12 * n_joints; } + +std::vector parse_samples(const std::vector& blob, int n_joints) { + const int sz = sample_size(n_joints); + if (sz <= 0) { + throw InvalidCommandError("关节数非法: " + std::to_string(n_joints)); + } + if (blob.size() % size_t(sz)) { + throw TransportError("采集字节数 " + std::to_string(blob.size()) + + " 不是单拍 " + std::to_string(sz) + + "B 的整数倍 —— 数据截断/错位, 拒绝解析出半截样本"); + } + std::vector out; + out.reserve(blob.size() / size_t(sz)); + for (size_t off = 0; off + size_t(sz) <= blob.size(); off += size_t(sz)) { + LogSample s; + s.tick = proto::read_u32le(blob, off); + s.q_ref = proto::unpack_f32s(blob.data(), off + 4, n_joints); + s.dq = proto::unpack_f32s(blob.data(), off + 4 + 4 * size_t(n_joints), n_joints); + s.tau = proto::unpack_f32s(blob.data(), off + 4 + 8 * size_t(n_joints), n_joints); + out.push_back(std::move(s)); + } + return out; +} + +const Clock& LogReader::clk() const { + return arm_ != nullptr ? arm_->clock() : steady_clock_instance(); +} + +std::vector LogReader::read_chunk(uint32_t offset) { + std::exception_ptr last; + for (int i = 0; i <= retries_; ++i) { + try { + arm_->write_query(proto::CMD_LOG_READ, proto::pack_u32le(offset)); + const auto r = arm_->require().expect( + proto::RSP_LOG_DATA, timeout_, + "log_read@" + std::to_string(offset), true, proto::CMD_LOG_READ); + const auto& p = r.payload; + if (p.size() < kLogHdr) { + throw TransportError("RSP_LOG_DATA 帧短 (" + std::to_string(p.size()) + "B)"); + } + const uint32_t total = proto::read_u32le(p, 0); + const uint32_t nxt = proto::read_u32le(p, 4); + const uint8_t n = p[8]; + if (n > LOG_READ_CHUNK || p.size() < kLogHdr + n) { + throw TransportError("RSP_LOG_DATA 载荷异常: n=" + std::to_string(n) + + ", 实到 " + std::to_string(p.size() - kLogHdr) + "B"); + } + total_bytes_ = total; + next_ = nxt; + return std::vector(p.begin() + long(kLogHdr), + p.begin() + long(kLogHdr) + n); + } catch (const MotionTimeoutError& e) { + // 掉帧: 同一游标重试 + last = std::current_exception(); + continue; + } + } + if (last) std::rethrow_exception(last); + throw TransportError("log_read 失败"); +} + +uint32_t LogReader::total() { + read_chunk(0); + return total_bytes_; +} + +uint32_t LogReader::wait_for(int n_ticks, double timeout, double poll) { + const int sz = sample_size(arm_->n()); + const uint32_t want = uint32_t(n_ticks) * uint32_t(sz); + if (want == 0) return 0; + if (timeout < 0.0) { + // 按 n_ticks / CTRL_HZ * 1.5 + 3s 推算 (300Hz 逐拍记录) + timeout = double(n_ticks) / double(CTRL_HZ) * 1.5 + 3.0; + } + const double end = clk().now_s() + timeout; + while (true) { + const uint32_t tot = total(); + if (tot >= want) return tot; + if (clk().now_s() >= end) { + throw MotionTimeoutError("采集未在 " + std::to_string(timeout) + + "s 内录满 " + std::to_string(n_ticks) + " 拍 (只录到 " + + std::to_string(tot / uint32_t(sz)) + " 拍 / " + + std::to_string(tot) + "B) —— 可调大 record_timeout"); + } + std::this_thread::sleep_for(std::chrono::duration(poll)); + } +} + +std::vector> LogReader::iter_chunks() { + // 必须自检游标是否前进: 固件游标若因异常/干扰回了一个不大于当前 offset 的非零值, + // 天真的实现会在同一位置无限次重读 (死循环)。这里直接报错。 + std::vector> chunks; + uint32_t off = 0; + while (true) { + auto chunk = read_chunk(off); + if (!chunk.empty()) chunks.push_back(std::move(chunk)); + if (next_ == 0) return chunks; + if (next_ <= off) { + throw TransportError("LOG_READ 游标未前进 (offset=" + std::to_string(off) + + " -> next_byte=" + std::to_string(next_) + + "), 拒绝在同一游标上重复读回 (死循环保护)"); + } + off = next_; + } +} + +std::vector LogReader::read_all() { + std::vector out; + for (const auto& chunk : iter_chunks()) { + out.insert(out.end(), chunk.begin(), chunk.end()); + } + return out; +} + +std::vector LogReader::samples() { + return parse_samples(read_all(), arm_->n()); +} + +void ArmLog::start(int n_ticks) { + if (n_ticks < 0) { + throw InvalidCommandError("n_ticks 需 >=0 (给的是 " + std::to_string(n_ticks) + ")"); + } + arm_->cmd_expect_ack(proto::CMD_LOG_CTRL, proto::pack_u32le(uint32_t(n_ticks)), + "log_start"); + last_target_ = n_ticks; +} + +void ArmLog::stop() { + arm_->cmd_expect_ack(proto::CMD_LOG_CTRL, proto::pack_u32le(0), "log_stop"); + last_target_ = 0; +} + +LogReader ArmLog::reader(double timeout, int retries) { + return LogReader(arm_, timeout, retries); +} + +std::vector ArmLog::capture(int n_ticks, double timeout, int retries, + double record_timeout) { + start(n_ticks); + LogReader r = reader(timeout, retries); + r.wait_for(n_ticks, record_timeout); + return r.samples(); +} + +size_t ArmLog::dump(const std::string& path, bool wait, double timeout, int retries, + double record_timeout) { + LogReader r = reader(timeout, retries); + if (wait && last_target_ > 0) { + r.wait_for(last_target_, record_timeout); + } + const auto blob = r.read_all(); + FILE* f = std::fopen(path.c_str(), "wb"); + if (f == nullptr) throw TransportError("无法写入 " + path); + const size_t n = std::fwrite(blob.data(), 1, blob.size(), f); + std::fclose(f); + return n; +} + +} // namespace litearm diff --git a/src/model.cpp b/src/model.cpp new file mode 100644 index 0000000..14439ae --- /dev/null +++ b/src/model.cpp @@ -0,0 +1,150 @@ +#include "litearm/model.hpp" + +#include "litearm/arm.hpp" + +namespace litearm { + +ModelStatus ModelStatus::decode(const std::vector& payload) { + // payload 含首字节 RSP id (与 RSP_FF_VEC/RSP_FF_SCALAR 同口径)。 + if (payload.size() != 5) { + throw TransportError("RSP_MODEL_STATUS 帧长 " + std::to_string(payload.size()) + + "B, 期望 5B"); + } + if (payload[0] != proto::RSP_MODEL_STATUS) { + throw TransportError("RSP_MODEL_STATUS 帧 id 与期望不符"); + } + ModelStatus s; + s.override_level = int(payload[1]); + s.staged_mask = proto::read_u16le(payload, 2); // u16 LE + s.dirty = int(payload[4]); + return s; +} + +int ModelParams::check_body_idx(int idx) const { + if (idx < 0 || idx >= MODEL_NBODY) { + throw InvalidCommandError("刚体索引越界: " + std::to_string(idx) + " (有效 0.." + + std::to_string(MODEL_NBODY - 1) + ")"); + } + return idx; +} + +bool ModelParams::probe() { + // 用 0x34 探测: 旧固件没有这条命令的 case => 落 default => ERR{0x34,0x00} + // => 捕获 UnsupportedByFirmwareError 并回 false。 + // 不能用 0x30/0x33 探测 —— 旧固件对它们有显式 case 回 ERR{cmd,0x02} (与"数值非法" + // 同码), 无法区分「无此命令」与「参数非法」。 + try { + get_body(0); + return true; + } catch (const UnsupportedByFirmwareError&) { + return false; + } +} + +Msg> ModelParams::get_body(int idx, double timeout) { + idx = check_body_idx(idx); + const uint8_t p = uint8_t(idx); + arm_->write_query(proto::CMD_GET_MODEL_PARAM, &p, 1); + const auto r = arm_->require().expect( + proto::RSP_MODEL_PARAM, timeout, "model.get_body(" + std::to_string(idx) + ")", + true, proto::CMD_GET_MODEL_PARAM); + const size_t want = 2 + 4 * size_t(MODEL_BODY_PARAMS); + if (r.payload.size() != want) { + throw TransportError("RSP_MODEL_PARAM 帧长 " + std::to_string(r.payload.size()) + + "B, 期望 " + std::to_string(want) + "B"); + } + if (r.payload[0] != proto::RSP_MODEL_PARAM) { + throw TransportError("RSP_MODEL_PARAM 帧 id 与期望不符"); + } + if (r.payload[1] != uint8_t(idx)) { + throw TransportError("RSP_MODEL_PARAM 回显 body_idx=" + + std::to_string(r.payload[1]) + ", 期望 " + + std::to_string(idx)); + } + return arm_->wrap(proto::unpack_f32s(r.payload.data(), 2, MODEL_BODY_PARAMS), + proto::RSP_MODEL_PARAM); +} + +void ModelParams::set_body(int idx, const std::vector& vals) { + idx = check_body_idx(idx); + if (int(vals.size()) != MODEL_BODY_PARAMS) { + throw InvalidCommandError( + "body 参数须为 " + std::to_string(MODEL_BODY_PARAMS) + + " 个 (m, cmx, cmy, cmz, ixx, ixy, ixz, iyy, iyz, izz), 给的是 " + + std::to_string(vals.size()) + " 个"); + } + std::vector payload{uint8_t(idx)}; + const auto v = proto::pack_f32s(vals); + payload.insert(payload.end(), v.begin(), v.end()); + arm_->cmd_expect_ack(proto::CMD_SET_MODEL_PARAM, payload, + "model.set_body(" + std::to_string(idx) + ")"); +} + +Msg> ModelParams::get_jm(double timeout) { + arm_->write_query(proto::CMD_GET_MODEL_JM); + const auto r = arm_->require().expect(proto::RSP_MODEL_JM, timeout, "model.get_jm", + true, proto::CMD_GET_MODEL_JM); + const size_t want = 1 + 4 * size_t(MODEL_JM_N); + if (r.payload.size() != want) { + throw TransportError("RSP_MODEL_JM 帧长 " + std::to_string(r.payload.size()) + + "B, 期望 " + std::to_string(want) + "B"); + } + if (r.payload[0] != proto::RSP_MODEL_JM) { + throw TransportError("RSP_MODEL_JM 帧 id 与期望不符"); + } + return arm_->wrap(proto::unpack_f32s(r.payload.data(), 1, MODEL_JM_N), + proto::RSP_MODEL_JM); +} + +void ModelParams::set_jm(const std::vector& vals) { + if (int(vals.size()) != MODEL_JM_N) { + throw InvalidCommandError("jm 须为 " + std::to_string(MODEL_JM_N) + " 个, 给的是 " + + std::to_string(vals.size()) + " 个"); + } + arm_->cmd_expect_ack(proto::CMD_SET_MODEL_JM, proto::pack_f32s(vals), "model.set_jm"); +} + +void ModelParams::commit(uint16_t expected_mask) { + // ⚠ u16 LE —— 没有 u16 pack helper 时写成单字节会截断 (0x2FE & 0xFF = 0xFE) 而固件 + // len < 2 直接拒。 + arm_->cmd_expect_ack(proto::CMD_MODEL_COMMIT, proto::pack_u16le(expected_mask), + "model.commit", 2.5); +} + +void ModelParams::revert() { + arm_->cmd_expect_ack(proto::CMD_REVERT_MODEL, nullptr, 0, "model.revert", 2.5); +} + +Msg ModelParams::status(double timeout) { + arm_->write_query(proto::CMD_GET_MODEL_STATUS); + const auto r = arm_->require().expect(proto::RSP_MODEL_STATUS, timeout, "model.status", + true, proto::CMD_GET_MODEL_STATUS); + return arm_->wrap(ModelStatus::decode(r.payload), proto::RSP_MODEL_STATUS); +} + +Msg> ModelParams::get_gravity(const std::vector& q, + double timeout) { + // 给定关节角算重力项 G(q) (纯读, 无门控, 不改任何状态)。 + // 产线的「静态重力核查」用它: 到位静止后比对实测 tau 与 G(q) —— 这是唯一能抓住 + // 「错台 yaml / 重力符号错 / 模型没生效」的判据 (读回比对只能证明字节落位)。 + if (int(q.size()) != MODEL_JM_N) { + throw InvalidCommandError("q 须为 " + std::to_string(MODEL_JM_N) + " 个, 给的是 " + + std::to_string(q.size()) + " 个"); + } + arm_->write_query(proto::CMD_GET_GRAVITY, proto::pack_f32s(q)); + const auto r = arm_->require().expect(proto::RSP_GRAVITY, timeout, + "model.get_gravity", true, + proto::CMD_GET_GRAVITY); + const size_t want = 1 + 4 * size_t(MODEL_JM_N); + if (r.payload.size() != want) { + throw TransportError("RSP_GRAVITY 帧长 " + std::to_string(r.payload.size()) + + "B, 期望 " + std::to_string(want) + "B"); + } + if (r.payload[0] != proto::RSP_GRAVITY) { + throw TransportError("RSP_GRAVITY 帧 id 与期望不符"); + } + return arm_->wrap(proto::unpack_f32s(r.payload.data(), 1, MODEL_JM_N), + proto::RSP_GRAVITY); +} + +} // namespace litearm diff --git a/src/params.cpp b/src/params.cpp new file mode 100644 index 0000000..4ecee03 --- /dev/null +++ b/src/params.cpp @@ -0,0 +1,80 @@ +#include "litearm/params.hpp" + +#include "litearm/arm.hpp" + +namespace litearm { + +JointParam JointParam::decode(const std::vector& payload) { + if (payload.size() < 21) { + throw TransportError("RSP_JOINT_PARAM 帧短 (" + std::to_string(payload.size()) + + "B, 期望 21B)"); + } + JointParam p; + p.idx = payload[0]; + const auto v = proto::unpack_f32s(payload.data(), 1, 5); + p.kp = v[0]; + p.kd = v[1]; + p.tau_max = v[2]; + p.q_min = v[3]; + p.q_max = v[4]; + return p; +} + +int JointParams::check_idx(int idx) const { + const int n = arm_->n(); + const int upper = n > 0 ? n : 1; + if (idx < 0 || idx >= upper) { + throw InvalidCommandError("关节索引越界: " + std::to_string(idx) + " (有效 0.." + + std::to_string(n - 1) + ")"); + } + return idx; +} + +void JointParams::set_joint_param(int idx, double kp, double kd, double tau_max) { + idx = check_idx(idx); + std::vector payload{uint8_t(idx)}; + const auto v = proto::pack_f32s(std::vector{kp, kd, tau_max}); + payload.insert(payload.end(), v.begin(), v.end()); + arm_->cmd_expect_ack(proto::CMD_SET_JOINT_PARAM, payload, + "set_joint_param(J" + std::to_string(idx + 1) + ")"); +} + +void JointParams::set_joint_limits(int idx, double q_min, double q_max) { + idx = check_idx(idx); + if (!(q_min < q_max)) { + throw InvalidCommandError("软限位需 q_min < q_max (给的是 " + + std::to_string(q_min) + ", " + std::to_string(q_max) + + ")"); + } + std::vector payload{uint8_t(idx)}; + const auto v = proto::pack_f32s(std::vector{q_min, q_max}); + payload.insert(payload.end(), v.begin(), v.end()); + arm_->cmd_expect_ack(proto::CMD_SET_JOINT_LIMITS, payload, + "set_joint_limits(J" + std::to_string(idx + 1) + ")"); +} + +Msg JointParams::get_joint_param(int idx, double timeout) { + idx = check_idx(idx); + const uint8_t p = uint8_t(idx); + arm_->write_query(proto::CMD_GET_JOINT_PARAM, &p, 1); // 查询类: 不受守卫限制 + const auto r = arm_->require().expect( + proto::RSP_JOINT_PARAM, timeout, "get_joint_param(J" + std::to_string(idx + 1) + ")", + true, proto::CMD_GET_JOINT_PARAM); + return arm_->wrap(JointParam::decode(r.payload), proto::RSP_JOINT_PARAM); +} + +std::vector JointParams::all_joint_params() { + std::vector out; + const int n = arm_->n(); + out.reserve(size_t(n)); + for (int i = 0; i < n; ++i) out.push_back(get_joint_param(i).value); + return out; +} + +void JointParams::reset_factory() { + // 固件要求失能态 (擦写窗口 CPU 停顿, 电机不能在无监督下保持使能), 已武装时回 + // ERR{0x36,0x04}; 本方法不代劳 disable(), 以免替调用方做安全决策。 + arm_->cmd_expect_ack(proto::CMD_PARAM_RESET, nullptr, 0, "reset_factory", 2.5); +} + +} // namespace litearm diff --git a/src/protocol.cpp b/src/protocol.cpp new file mode 100644 index 0000000..6518b42 --- /dev/null +++ b/src/protocol.cpp @@ -0,0 +1,301 @@ +#include "litearm/protocol.hpp" + +#include +#include +#include + +namespace litearm { +namespace proto { + +namespace { + +const char* const kModeNames[] = {"INIT", "MOVE_J", "MOVE_P", "MOVE_JS", + "MOVE_MIT", "MIT_ALL", "EMERGENCY", "ZERO_G"}; +// flags bit0..bit5 才是安全 flag (6..8 是 mode, 9 是 enabled, 10 是 cart_busy)。 +const char* const kFlagNames[] = {"FAULT", "WD_TRIPPED", "FB_STALE", + "TEMP_WARN", "POS_VIOL", "OVERSPEED"}; + +// 命令覆盖契约 —— 与 Python 侧 _protocol.COMMAND_COVERAGE 逐条一致。 +const std::vector kCoverage = { + {0x01, "Arm.movej"}, + {0x07, "Arm.movej_sync"}, + {0x02, "Arm.move_p"}, + {0x03, "Arm.move_js"}, + {0x04, "Arm.send_mit"}, + {0x05, "Arm.send_mit_all"}, + {0x06, "Arm.zero_g"}, + {0x10, "Arm.enable"}, + {0x11, "Arm.disable"}, + {0x12, "Arm.emergency_stop"}, + {0x13, "Arm.clear_faults"}, + {0x14, "Arm.reset"}, + {0x15, "Arm.enter_dfu"}, + {0x20, "Arm.set_motion_mode"}, + {0x21, "Arm.set_speed"}, + {0x22, "Arm.params.set_joint_param"}, + {0x23, "Arm.params.set_joint_limits"}, + {0x24, "Arm.params.get_joint_param"}, + {0x25, "Arm.save_params"}, + {0x26, "Arm.set_ff_vec"}, + {0x27, "Arm.set_ff_mask"}, + {0x28, "Arm.set_ff_scalar"}, + {0x2A, "Arm.home"}, + {0x2B, "Arm.get_ff_vec"}, + {0x2F, "Arm.license"}, + {0x3F, "Arm.activate"}, + {0x2C, "Arm.get_ff_scalar"}, + {0x2D, "Arm.log.start"}, + {0x2E, "Arm.log.reader"}, + {0x30, "Arm.model.set_body"}, + {0x31, "Arm.ff_preset"}, + {0x32, "Arm.model.commit"}, + {0x33, "Arm.model.set_jm"}, + {0x34, "Arm.model.get_body"}, + {0x35, "Arm.model.get_jm"}, + {0x37, "Arm.model.revert"}, + {0x38, "Arm.model.status"}, + {0x39, "Arm.model.get_gravity"}, + {0x36, "Arm.params.reset_factory"}, + {0x40, "Arm.get_status_now"}, + {0x41, "Arm.connect"}, + {0x42, "Arm.ik"}, + {0x43, "Arm.get_tcp"}, + {0x49, "Arm.diag.kin_bench"}, + // [笛卡尔] 0x3C/0x3D/0x3E 是同一条入口的三个阶段 (BEGIN -> ADD x n -> RUN), + // 三者的帧都由 Arm::move_path 发出, 故三条都指向它。 + {0x3A, "Arm.move_l"}, + {0x3B, "Arm.move_c"}, + {0x3C, "Arm.move_path"}, + {0x3D, "Arm.move_path"}, + {0x3E, "Arm.move_path"}, +}; + +const std::vector> kRspOfCmd = { + {0x24, 0x49}, // GET_JOINT_PARAM -> RSP_JOINT_PARAM + {0x2B, 0x4B}, // GET_FF_VEC -> RSP_FF_VEC + {0x2C, 0x4C}, // GET_FF_SCALAR -> RSP_FF_SCALAR + {0x2E, 0x4D}, // LOG_READ -> RSP_LOG_DATA + {0x34, 0x54}, // GET_MODEL_PARAM -> RSP_MODEL_PARAM + {0x35, 0x55}, // GET_MODEL_JM -> RSP_MODEL_JM + {0x38, 0x56}, // GET_MODEL_STATUS -> RSP_MODEL_STATUS + {0x39, 0x57}, // GET_GRAVITY -> RSP_GRAVITY + {0x41, 0x44}, // GET_FIRMWARE -> RSP_FIRMWARE + {0x42, 0x47}, // GET_IK -> RSP_IK + {0x43, 0x48}, // GET_TCP -> RSP_TCP + {0x49, 0x4A}, // KIN_BENCH -> RSP_KIN_BENCH +}; + +float f32_at(const uint8_t* b, size_t off) { + float v; + std::memcpy(&v, b + off, 4); + return v; +} + +} // namespace + +const char* mode_name(int mode) { + if (mode >= 0 && mode < 8) return kModeNames[mode]; + return "UNKNOWN"; +} + +const char* flag_name(int bit) { + if (bit >= 0 && bit < 6) return kFlagNames[bit]; + return "UNKNOWN"; +} + +// 纯位循环实现 —— 与 Python 侧测试用的独立参考实现同式, 便于交叉验证。 +uint16_t crc16_ccitt_false(const uint8_t* data, size_t len) { + uint16_t crc = 0xFFFF; + for (size_t i = 0; i < len; ++i) { + crc ^= uint16_t(data[i]) << 8; + for (int b = 0; b < 8; ++b) { + if (crc & 0x8000) { + crc = uint16_t((crc << 1) ^ 0x1021); + } else { + crc = uint16_t(crc << 1); + } + } + } + return crc; +} + +std::vector pack_frame(uint8_t cmd, const uint8_t* payload, size_t len) { + if (len > 255) { + throw std::invalid_argument("payload >255B"); + } + std::vector body; + body.reserve(3 + len); + body.push_back(SOF); + body.push_back(cmd); + body.push_back(uint8_t(len)); + if (payload != nullptr && len > 0) { + body.insert(body.end(), payload, payload + len); + } + uint16_t crc = crc16_ccitt_false(body.data(), body.size()); + body.push_back(uint8_t(crc & 0xFF)); + body.push_back(uint8_t(crc >> 8)); + return body; +} + +std::optional unpack_frame(const uint8_t* frame, size_t len) { + if (len < 5 || frame[0] != SOF) return std::nullopt; + const size_t ln = frame[2]; + if (len != ln + 5) return std::nullopt; + const uint16_t crc = uint16_t(frame[3 + ln] | (frame[4 + ln] << 8)); + if (crc16_ccitt_false(frame, 3 + ln) != crc) return std::nullopt; + Frame f; + f.cmd = frame[1]; + f.payload.assign(frame + 3, frame + 3 + ln); + return f; +} + +std::vector pack_f32s(const double* values, size_t count) { + std::vector out; + out.reserve(count * 4); + for (size_t i = 0; i < count; ++i) { + const float f = float(values[i]); + uint8_t b[4]; + std::memcpy(b, &f, 4); + out.insert(out.end(), b, b + 4); + } + return out; +} + +std::vector unpack_f32s(const uint8_t* b, size_t off, int count) { + std::vector out; + out.reserve(size_t(count)); + for (int i = 0; i < count; ++i) { + out.push_back(double(f32_at(b, off + size_t(i) * 4))); + } + return out; +} + +uint32_t read_u32le(const uint8_t* b) { + return uint32_t(b[0]) | (uint32_t(b[1]) << 8) | (uint32_t(b[2]) << 16) | + (uint32_t(b[3]) << 24); +} +uint16_t read_u16le(const uint8_t* b) { return uint16_t(b[0] | (b[1] << 8)); } +uint32_t read_u32le(const std::vector& b, size_t off) { + return read_u32le(b.data() + off); +} +uint16_t read_u16le(const std::vector& b, size_t off) { + return read_u16le(b.data() + off); +} + +std::optional parse_firmware_version(const std::string& ver) { + // 'Litearm1.5.2-7J' -> (1,5,2,'7J'); 旧 'A1.x-...-USB' 不符合新约定。 + size_t i = 0; + while (i < ver.size() && std::isspace(static_cast(ver[i]))) ++i; + std::string s = ver.substr(i); + const std::string prefix = "Litearm"; + if (s.rfind(prefix, 0) == 0) { + s = s.substr(prefix.size()); + } else { + return std::nullopt; // 含旧 'A' 开头形态 + } + std::string ver_part = s; + std::string variant; + const size_t dash = s.find('-'); + if (dash != std::string::npos) { + ver_part = s.substr(0, dash); + variant = s.substr(dash + 1); + } + // 三段必须全是数字 + std::vector parts; + size_t start = 0; + while (true) { + const size_t p = ver_part.find('.', start); + if (p == std::string::npos) { + parts.push_back(ver_part.substr(start)); + break; + } + parts.push_back(ver_part.substr(start, p - start)); + start = p + 1; + } + if (parts.size() != 3) return std::nullopt; + for (const auto& p : parts) { + if (p.empty()) return std::nullopt; + for (char c : p) { + if (!std::isdigit(static_cast(c))) return std::nullopt; + } + } + FirmwareVersion v; + v.major = std::stoi(parts[0]); + v.minor = std::stoi(parts[1]); + v.patch = std::stoi(parts[2]); + v.variant = variant; + return v; +} + +std::optional parse_boot_banner(const std::string& text) { + // 固件唯一能区分「独立看门狗复位过」的地方 (banner 带 ", iwdg-rst")。 + // 重连/多次枚举会重复出现, 取最后一次。 + static const std::regex re(R"(\[litearm-usbcdc\]\s+ready\s*\(([^)]*)\))"); + std::optional last; + for (auto it = std::sregex_iterator(text.begin(), text.end(), re); + it != std::sregex_iterator(); ++it) { + last = (*it)[1].str(); + } + if (!last) return std::nullopt; + return (last->find("iwdg-rst") != std::string::npos) ? std::string("iwdg-rst") + : std::string("normal"); +} + +std::optional decode_status(const uint8_t* payload, size_t len) { + if (len < 4) return std::nullopt; + StatusDecoded out; + out.flags = read_u16le(payload); + out.seq = read_u16le(payload + 2); + const size_t n = (len - 4) / 21; + if (n != 1 && n != 7) return std::nullopt; + const size_t tail = len - 4 - n * 21; + if (tail != 0 && tail != 2) return std::nullopt; // 0 = 旧布局, 2 = joint_fault u16 + out.joint_fault = (tail == 2) ? read_u16le(payload + 4 + n * 21) : 0; + out.mode = (out.flags >> 6) & 0x7; + for (int k = 0; k < 6; ++k) { + if (out.flags & (1u << k)) out.flag_names.push_back(kFlagNames[k]); + } + out.joints.reserve(n); + for (size_t i = 0; i < n; ++i) { + const size_t off = 4 + i * 21; + JointRaw j; + j.q = double(f32_at(payload, off)); + j.dq = double(f32_at(payload, off + 4)); + j.tau = double(f32_at(payload, off + 8)); + j.t_mos = double(f32_at(payload, off + 12)); + j.t_coil = double(f32_at(payload, off + 16)); + j.err = payload[off + 20]; + out.joints.push_back(j); + } + return out; +} + +const std::map& unimplemented_cmds() { + static const std::map kEmpty; + return kEmpty; +} +const std::map& firmware_only_cmds() { + static const std::map kEmpty; + return kEmpty; +} +const std::map& firmware_only_rsps() { + static const std::map kEmpty; + return kEmpty; +} +const std::map& preexisting_gaps() { + static const std::map kEmpty; + return kEmpty; +} + +const std::vector& command_coverage() { return kCoverage; } +const std::vector>& rsp_of_cmd() { return kRspOfCmd; } + +std::optional rsp_of(uint8_t cmd) { + for (const auto& kv : kRspOfCmd) { + if (kv.first == cmd) return kv.second; + } + return std::nullopt; +} + +} // namespace proto +} // namespace litearm diff --git a/src/rot.cpp b/src/rot.cpp new file mode 100644 index 0000000..b6b0a61 --- /dev/null +++ b/src/rot.cpp @@ -0,0 +1,211 @@ +#include "litearm/rot.hpp" + +#include +#include + +#include "litearm/errors.hpp" + +namespace litearm { +namespace rot { + +Mat3 eye3() { + return {{{1.0, 0.0, 0.0}, {0.0, 1.0, 0.0}, {0.0, 0.0, 1.0}}}; +} + +Mat3 mat_mul(const Mat3& A, const Mat3& B) { + Mat3 out{}; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + out[i][j] = A[i][0] * B[0][j] + A[i][1] * B[1][j] + A[i][2] * B[2][j]; + } + } + return out; +} + +Mat3 mat_T(const Mat3& A) { + Mat3 out{}; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) out[i][j] = A[j][i]; + } + return out; +} + +Vec3 mat_vec(const Mat3& A, const Vec3& v) { + return {A[0][0] * v[0] + A[0][1] * v[1] + A[0][2] * v[2], + A[1][0] * v[0] + A[1][1] * v[1] + A[1][2] * v[2], + A[2][0] * v[0] + A[2][1] * v[1] + A[2][2] * v[2]}; +} + +double dot(const Vec3& a, const Vec3& b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +Vec3 cross(const Vec3& a, const Vec3& b) { + return {a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0]}; +} + +double norm(const Vec3& v) { return std::sqrt(dot(v, v)); } + +Vec3 sub(const Vec3& a, const Vec3& b) { return {a[0] - b[0], a[1] - b[1], a[2] - b[2]}; } + +Vec3 unit(const Vec3& v) { + const double n = norm(v); + if (n < kEps) throw InvalidCommandError("零向量无法归一化"); + return {v[0] / n, v[1] / n, v[2] / n}; +} + +double det3(const Mat3& R) { + return R[0][0] * (R[1][1] * R[2][2] - R[1][2] * R[2][1]) - + R[0][1] * (R[1][0] * R[2][2] - R[1][2] * R[2][0]) + + R[0][2] * (R[1][0] * R[2][1] - R[1][1] * R[2][0]); +} + +Mat3 rpy_to_mat(const Vec3& rpy) { + // 与固件 kin_rpy_to_rot 逐元素同式。 + const double r = rpy[0], p = rpy[1], y = rpy[2]; + const double cr = std::cos(r), sr = std::sin(r); + const double cp = std::cos(p), sp = std::sin(p); + const double cy = std::cos(y), sy = std::sin(y); + return {{{cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr}, + {sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr}, + {-sp, cp * sr, cp * cr}}}; +} + +Vec3 mat_to_rpy(const Mat3& R) { + const double p = std::atan2(-R[2][0], std::hypot(R[0][0], R[1][0])); + if (std::fabs(std::fabs(p) - M_PI / 2.0) < 1e-6) { + return {std::atan2(-R[1][2], R[1][1]), p, 0.0}; + } + return {std::atan2(R[2][1], R[2][2]), p, std::atan2(R[1][0], R[0][0])}; +} + +Vec3 rot_log(const Mat3& R) { + const double tr = R[0][0] + R[1][1] + R[2][2]; + const double c = std::max(-1.0, std::min(1.0, (tr - 1.0) / 2.0)); + const double ang = std::acos(c); + if (ang < 1e-9) return {0.0, 0.0, 0.0}; + const Vec3 v{R[2][1] - R[1][2], R[0][2] - R[2][0], R[1][0] - R[0][1]}; + const double k = ang / (2.0 * std::sin(ang)); + return {v[0] * k, v[1] * k, v[2] * k}; +} + +Mat3 rot_exp(const Vec3& w) { + const double ang = norm(w); + if (ang < 1e-12) return eye3(); + const Vec3 k{w[0] / ang, w[1] / ang, w[2] / ang}; + const Mat3 K{{{0.0, -k[2], k[1]}, {k[2], 0.0, -k[0]}, {-k[1], k[0], 0.0}}}; + const double s = std::sin(ang), c = 1.0 - std::cos(ang); + const Mat3 KK = mat_mul(K, K); + Mat3 out{}; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + out[i][j] = (i == j ? 1.0 : 0.0) + s * K[i][j] + c * KK[i][j]; + } + } + return out; +} + +Mat3 rot_slerp(const Mat3& R0, const Mat3& R1, double t) { + const Vec3 w = rot_log(mat_mul(mat_T(R0), R1)); + if (norm(w) < 1e-12) return R0; + return mat_mul(R0, rot_exp({w[0] * t, w[1] * t, w[2] * t})); +} + +double rot_angle(const Mat3& R) { return norm(rot_log(R)); } + +std::pair pose_err(const Vec3& pos, const Mat3& R, const Vec3& pos_d, + const Mat3& R_d) { + return {norm(sub(pos_d, pos)), rot_angle(mat_mul(R_d, mat_T(R)))}; +} + +bool is_rotation(const Mat3& R, double atol) { + const Mat3 Rt = mat_T(R); + const Mat3 I3 = eye3(); + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + double s = 0.0; + for (int k = 0; k < 3; ++k) s += Rt[i][k] * R[k][j]; + if (std::fabs(s - I3[i][j]) > atol) return false; + } + } + return det3(R) > 0.999; +} + +double orient_angle(const Vec3& a, const Vec3& b) { + const Mat3 Ra = rpy_to_mat(a), Rb = rpy_to_mat(b); + double c = 0.0; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) c += Ra[j][i] * Rb[j][i]; // trace(Ra^T * Rb) + } + return std::acos(std::max(-1.0, std::min(1.0, (c - 1.0) / 2.0))); +} + +// ---------------------------------------------------------------- PoseInput + +PoseInput PoseInput::from_vec6(const Vec3& xyz, const Vec3& rpy) { + PoseInput p; + p.kind = Kind::Pose6; + p.pos = xyz; + p.rpy = rpy; + return p; +} + +PoseInput PoseInput::from_vec6(const std::array& v) { + return from_vec6(Vec3{v[0], v[1], v[2]}, Vec3{v[3], v[4], v[5]}); +} + +PoseInput PoseInput::from_vec6(const std::vector& v) { + if (v.size() != 6) { + std::ostringstream os; + os << "pose 6 向量需 6 个分量, 收到 " << v.size(); + throw InvalidCommandError(os.str()); + } + return from_vec6(std::array{v[0], v[1], v[2], v[3], v[4], v[5]}); +} + +PoseInput PoseInput::from_pos_rot(const Vec3& pos, const Mat3& R) { + if (!is_rotation(R)) { + throw InvalidCommandError("pose 旋转矩阵不是有效 SO(3) (正交且 det~=+1)"); + } + PoseInput p; + p.kind = Kind::PosRot; + p.pos = pos; + p.R = R; + return p; +} + +PoseInput PoseInput::from_homogeneous(const Mat4& M) { + Mat3 R{}; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) R[i][j] = M[i][j]; + } + if (!is_rotation(R)) { + throw InvalidCommandError("pose 4x4 的旋转块不是有效 SO(3)"); + } + PoseInput p; + p.kind = Kind::Homogeneous; + p.pos = {M[0][3], M[1][3], M[2][3]}; + p.R = R; + return p; +} + +Pose as_pose(const PoseInput& in) { + switch (in.kind) { + case PoseInput::Kind::Pose6: + return {in.pos, rpy_to_mat(in.rpy)}; + case PoseInput::Kind::PosRot: + case PoseInput::Kind::Homogeneous: + default: + return {in.pos, in.R}; + } +} + +std::array as_pose6(const PoseInput& in) { + const Pose p = as_pose(in); + const Vec3 rpy = mat_to_rpy(p.second); + return {p.first[0], p.first[1], p.first[2], rpy[0], rpy[1], rpy[2]}; +} + +} // namespace rot +} // namespace litearm diff --git a/src/state.cpp b/src/state.cpp new file mode 100644 index 0000000..a33d7c4 --- /dev/null +++ b/src/state.cpp @@ -0,0 +1,91 @@ +#include "litearm/state.hpp" + +#include + +#include "litearm/errors.hpp" + +namespace litearm { + +std::vector RobotState::fault_axes() const { + std::vector out; + for (int i = 0; i < proto::MAX_JOINTS; ++i) { + if (joint_fault & (1u << i)) out.push_back(i); + } + return out; +} + +std::vector RobotState::q() const { + std::vector out; + out.reserve(joints.size()); + for (const auto& j : joints) out.push_back(j.q); + return out; +} + +std::vector RobotState::dq() const { + std::vector out; + out.reserve(joints.size()); + for (const auto& j : joints) out.push_back(j.dq); + return out; +} + +std::vector RobotState::tau() const { + std::vector out; + out.reserve(joints.size()); + for (const auto& j : joints) out.push_back(j.tau); + return out; +} + +std::string RobotState::fault_detail() const { + std::vector parts; + if (!flag_names.empty()) { + std::string s = "flags="; + for (size_t i = 0; i < flag_names.size(); ++i) { + if (i) s += ","; + s += flag_names[i]; + } + parts.push_back(s); + } + if (joint_fault) { + std::string s = "断轴="; + bool first = true; + for (int a : fault_axes()) { + if (!first) s += ","; + first = false; + s += "J" + std::to_string(a + 1); + } + parts.push_back(s); + } + if (parts.empty()) return "无故障位"; + std::string out; + for (size_t i = 0; i < parts.size(); ++i) { + if (i) out += " "; + out += parts[i]; + } + return out; +} + +RobotState decode_state(const std::vector& payload) { + const auto dec = proto::decode_status(payload); + if (!dec) throw TransportError("非法状态帧"); + RobotState st; + st.mode = dec->mode; + st.mode_name = proto::mode_name(dec->mode); + st.flags = dec->flags; + for (const char* nm : dec->flag_names) st.flag_names.emplace_back(nm); + st.seq = dec->seq; + st.joint_fault = dec->joint_fault; + st.joints.reserve(dec->joints.size()); + for (const auto& j : dec->joints) { + JointState js; + js.q = j.q; + js.dq = j.dq; + js.tau = j.tau; + js.t_mos = j.t_mos; + js.t_coil = j.t_coil; + js.err = j.err; + st.joints.push_back(js); + } + return st; +} + +} // namespace litearm diff --git a/src/testing.cpp b/src/testing.cpp new file mode 100644 index 0000000..37a83d4 --- /dev/null +++ b/src/testing.cpp @@ -0,0 +1,768 @@ +#include "litearm/testing.hpp" + +#include +#include +#include +#include + +#include "litearm/errors.hpp" + +namespace litearm { +namespace testing { + +const std::map CART_MIN_LEN = { + {0x3A, 28}, {0x3B, 52}, {0x3C, 5}, {0x3D, 25}, {0x3E, 0}}; + +const char* const KIN_BENCH_TEXT = + "FK200 108 240 \n" + "JAC200 220 610 \n" + "IK100 2600 5200 \n" + "LOOP14000 \n" + "SC200 60 120 \n" + "G200 300 700 \n" + "RNEA200 800 1500 \n" + "M200 900 2200 \n" + "LAW200 50 90 \n" + "LINK crc=3 ovf=11 rfd=1 txf=7 rxlen=5 rxl0=13 rxl1=17 rbd=19 " + "cmode=127 cmb=23 tfe=1 tfd=2 tfc=3 tfs=0 tfm=1 tec=29 rec=31 psr=8 " + "ebo=0 eep=0 eew=0 epa=0 epd=0 " + " loop_k=2 ovr=4 flt=12345 /6 st=1 \n"; + +std::vector split_kin_bench(const std::string& text, bool one_frame) { + const size_t pos = text.find("LINK"); + if (pos == std::string::npos) return {text}; + const std::string head = text.substr(0, pos); + const std::string tail = text.substr(pos); + if (one_frame) return {head}; + return {head, tail}; +} + +std::vector make_log(int n_ticks, int n) { + std::vector out; + for (int i = 0; i < n_ticks; ++i) { + const auto t = proto::pack_u32le(uint32_t(i)); + out.insert(out.end(), t.begin(), t.end()); + std::vector qref, dq, tau; + for (int j = 0; j < n; ++j) { + qref.push_back(double(10 * j + 1)); + dq.push_back(0.5); + tau.push_back(-0.25); + } + const auto a = proto::pack_f32s(qref); + const auto b = proto::pack_f32s(dq); + const auto c = proto::pack_f32s(tau); + out.insert(out.end(), a.begin(), a.end()); + out.insert(out.end(), b.begin(), b.end()); + out.insert(out.end(), c.begin(), c.end()); + } + return out; +} + +std::vector make_status(const std::vector& q, + const std::vector& dq, int mode, uint16_t flags, + uint16_t seq, uint16_t joint_fault, int n) { + // 按固件 1.5.x 真实布局 (6+21N) 造状态帧 —— 尾部带 joint_fault u16。 + // 旧桩只造 4+21N, 导致"SDK 解不了真固件状态帧"这一 P0 在离线测试里全绿被掩盖。 + std::vector body; + const auto hdr = proto::pack_u16le(uint16_t(flags | (mode << 6))); + body.insert(body.end(), hdr.begin(), hdr.end()); + const auto s = proto::pack_u16le(seq); + body.insert(body.end(), s.begin(), s.end()); + for (int i = 0; i < n; ++i) { + const double qv = i < int(q.size()) ? q[size_t(i)] : 0.0; + const double dv = i < int(dq.size()) ? dq[size_t(i)] : 0.0; + const auto v = proto::pack_f32s(std::vector{qv, dv, 0.5, 30.0, 25.0}); + body.insert(body.end(), v.begin(), v.end()); + body.push_back(0); + } + const auto jf = proto::pack_u16le(joint_fault); + body.insert(body.end(), jf.begin(), jf.end()); + return proto::pack_frame(proto::RSP_STATUS, body); +} + +FakeTransport::FakeTransport(const std::string& port_in, double timeout_in, + const std::string& fw_in, int n_in) + : port(port_in), timeout(timeout_in), fw(fw_in), n(n_in) { + q.assign(size_t(n), 0.0); + pose = {0.300, 0.0, 0.350, 0.0, 0.0, 0.0}; + path_last_pose = pose; + model_body.assign(size_t(model_nbody), + std::vector(10, 0.0)); + for (auto& b : model_body) b[0] = 1.0; // "编译期常量": 全 1 质量 + model_body[size_t(model_nbody - 1)][0] = 0.0; // body8 质量恒 0 + model_jm.assign(7, 0.0); + for (int i = 0; i < n; ++i) { + joint_params.push_back({{"kp", jp_kp}, + {"kd", jp_kd}, + {"tau_max", jp_tau_max}, + {"q_min", jp_q_min}, + {"q_max", jp_q_max}}); + } + license_uid = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15, + 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B}; + kin_bench_text = KIN_BENCH_TEXT; +} + +void FakeTransport::close() { closed = true; } + +// 读线程会读这几个字段, 故写入必须持 `state_lock` (见 header 里的说明)。 +void FakeTransport::set_q(std::vector v) { + std::lock_guard sl(state_lock); + q = std::move(v); +} +void FakeTransport::set_joint_limits(double q_min, double q_max) { + std::lock_guard sl(state_lock); + for (auto& jp : joint_params) { + jp["q_min"] = q_min; + jp["q_max"] = q_max; + } +} +void FakeTransport::set_pose(std::vector v) { + std::lock_guard sl(state_lock); + pose = std::move(v); +} +void FakeTransport::set_cart_busy(int seq) { + std::lock_guard sl(state_lock); + cart_busy_seq = seq; +} +void FakeTransport::clear_cart_busy() { + std::lock_guard sl(state_lock); + cart_busy_seq.reset(); +} +bool FakeTransport::is_open() const { return !closed; } + +// ---------------------------------------------------------------- FakeClock +std::shared_ptr FakeClock::make() { return std::make_shared(); } + +double FakeClock::now_s() const { + std::lock_guard lk(mu_); + return now_; +} + +void FakeClock::advance(double s) { + std::lock_guard lk(mu_); + now_ += s; +} + +std::string FakeTransport::text_log() const { + std::lock_guard sl(state_lock); + return noise_text; +} + +void FakeTransport::set_text_log(std::string s) { + std::lock_guard sl(state_lock); + noise_text = std::move(s); +} + +void FakeTransport::set_flush_failures(uint64_t n) { flush_failures_.store(n); } + +void FakeTransport::push(const std::vector& frame) { resp_.push_back(frame); } + +void FakeTransport::push_frame(uint8_t cmd, const std::vector& payload) { + std::lock_guard sl(state_lock); + push(proto::pack_frame(cmd, payload)); +} + +std::vector>> FakeTransport::tx_snapshot() const { + std::lock_guard lk(tx_lock); + return tx_log; +} + +size_t FakeTransport::tx_count() const { + std::lock_guard lk(tx_lock); + return tx_log.size(); +} + +std::vector FakeTransport::stamps_of(uint8_t cmd) const { + std::lock_guard lk(tx_lock); + std::vector out; + for (size_t i = 0; i < tx_log.size() && i < tx_stamps.size(); ++i) { + if (tx_log[i].first == cmd) out.push_back(tx_stamps[i]); + } + return out; +} + +int FakeTransport::log_recorded() const { + if (log_hz <= 0.0 || log_t0 < 0.0) return log_target; + const int by_time = int((now_s() - log_t0) * log_hz); + return std::min(log_target, by_time); +} + +bool FakeTransport::axis_outside(int idx, double qmin, double qmax) const { + // 逐条镜像固件 ctrl_axis_outside 的可复现部分 (①②⑤)。 + if (idx < 0 || idx >= n) return true; // ① + if (!(qmin < qmax)) return true; // ② + return q[idx] < qmin || q[idx] > qmax; // ⑤ (q_ref 的代理) +} + +std::vector FakeTransport::cart_plan_frame() const { + if (cart_err_override) { + // ok=0 时 n_wp / plan_us 一律 0 (固件同款: 失败时那两个数没有意义) + std::vector body{0, uint8_t(*cart_err_override)}; + const auto a = proto::pack_u16le(0); + const auto b = proto::pack_u32le(0); + body.insert(body.end(), a.begin(), a.end()); + body.insert(body.end(), b.begin(), b.end()); + return proto::pack_frame(proto::RSP_CART_PLAN, body); + } + std::vector body{1, 0}; + const auto a = proto::pack_u16le(uint16_t(cart_n_wp)); + const auto b = proto::pack_u32le(cart_plan_us); + body.insert(body.end(), a.begin(), a.end()); + body.insert(body.end(), b.begin(), b.end()); + return proto::pack_frame(proto::RSP_CART_PLAN, body); +} + +std::vector FakeTransport::cart_goal(uint8_t cmd, + const std::vector& payload) const { + // 规划成功时桩把 pose 直接置成该命令的目标: 到位之后回读的 TCP 必须与目标对得上 + // (Arm::pose_near 那条判据), 否则每条 wait=true 的用例都会拿到 settled=false。 + // 桩不建模运动过程 (没有中间位姿), 只保证"收尾时 TCP 在目标上"。 + if (cmd == proto::CMD_MOVE_L) { + return proto::unpack_f32s(payload.data(), 0, 6); + } + if (cmd == proto::CMD_MOVE_C) { + return proto::unpack_f32s(payload.data(), 24, 6); + } + return path_last_pose; // 0x3E RUN +} + +void FakeTransport::cart_cmd(uint8_t cmd, const std::vector& payload) { + // 桩不建模使能/零重力/掉线锁存三道门禁: 要在离线用例里造"被门禁拒", 用 err_override 注入。 + if (!cart_supported) { + // 固件关掉 LITEARM_CART_PLAN 时这 5 条整段不存在 -> default 分支 + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x00})); + return; + } + const auto it = CART_MIN_LEN.find(cmd); + if (it != CART_MIN_LEN.end() && payload.size() < it->second) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + return; + } + if (cmd == proto::CMD_CART_ADD) { + path_last_pose = proto::unpack_f32s(payload.data(), 1, 6); + } + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + if (cmd == proto::CMD_MOVE_L || cmd == proto::CMD_MOVE_C || cmd == proto::CMD_CART_RUN) { + if (!cart_err_override) { + pose = cart_goal(cmd, payload); + } + cart_busy_seq = 0; // 状态帧开始按写死的帧序带 bit10 + push(cart_plan_frame()); + } +} + +void FakeTransport::write_frame(uint8_t cmd, const uint8_t* payload, size_t len) { + const std::vector p(payload, payload + len); + if (dfu_gone) { + // 设备已进 ROM bootloader: 帧发不出去 (不记账 —— 现实里它根本没进 CDC) + throw TransportError("桩: 设备已进 ROM bootloader, CDC 上的写路径失败"); + } + { + std::lock_guard lk(tx_lock); + tx_log.emplace_back(cmd, p); + tx_stamps.push_back(now_s()); + } + // 注入的"写阻塞"要**先睡再取锁** —— 持着 state_lock 睡会把读线程一起卡住。 + if (cmd == proto::CMD_ZERO_G && !p.empty() && p[0] == 0x01) { + zg_writes.fetch_add(1); + if (zg_slow_write_s > 0.0 && zg_writes.load() > 1) { + std::this_thread::sleep_for(std::chrono::duration(zg_slow_write_s)); + } + } + // 本函数会改共享状态 (`resp_` / `q` / `pose` / 各表), 而读线程同时在 read_frame + // => 必须整体互斥 (见 `state_lock` 的说明)。 + std::lock_guard sl(state_lock); + if (unknown_cmds.count(cmd)) { + // 固件 usb_cmd.c 的 default 分支: 未实现命令只回 ERR{cmd,0x00}, 无任何副作用 + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x00})); + return; + } + const auto eo = err_override.find(cmd); + if (eo != err_override.end()) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, eo->second})); + return; + } + + if (cmd == proto::CMD_ZERO_G) { + if (zg_fail_after >= 0 && zg_writes.load() > zg_fail_after) { + throw TransportError("桩: CDC 写失败 (注入)"); + } + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } else if (cmd == proto::CMD_GET_FIRMWARE) { // 握手 + const std::vector b(fw.begin(), fw.end()); + push(proto::pack_frame(proto::RSP_FIRMWARE, b)); + auto_status = true; + push(make_status(q, {}, 0, 0, 0, 0, n)); + } else if (cmd == proto::CMD_GET_STATUS) { + push(make_status(q, {}, 1, 0, 0, 0, n)); + } else if (cmd == proto::CMD_SET_FF_VEC) { // 存下来供 0x2B 读回 + if (!p.empty()) ff_vec[int(p[0])] = proto::unpack_f32s(p.data(), 1, n); + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } else if (cmd == proto::CMD_SET_FF_SCALAR) { // 存下来供 0x2C 读回 + if (p.size() >= 6) { + const auto v = proto::unpack_f32s(p.data(), 2, 1); + ff_scalar[{int(p[0]), int(p[1])}] = v[0]; + } + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } else if (cmd == proto::CMD_SET_FF_FLAGS) { + ff_mask = p.size() >= 4 ? proto::read_u32le(p, 0) : 0; + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } else if (cmd == proto::CMD_GET_FF_VEC) { // 载荷首字节 = RSP id + const int item = p.empty() ? 0 : int(p[0]); + std::vector vals; + const auto it = ff_vec.find(item); + if (it != ff_vec.end()) { + vals = it->second; + } else { + vals.assign(size_t(n), 0.0); + } + std::vector body{proto::RSP_FF_VEC, uint8_t(item)}; + const auto f = proto::pack_f32s(vals); + body.insert(body.end(), f.begin(), f.end()); + push(proto::pack_frame(proto::RSP_FF_VEC, body)); + } else if (cmd == proto::CMD_GET_FF_SCALAR) { // item 9 = ff_mask (只读) + const int item = p.size() > 0 ? int(p[0]) : 0; + const int sub = p.size() > 1 ? int(p[1]) : 0; + double val = 0.0; + if (item == 9) { + val = double(ff_mask); + } else { + const auto it = ff_scalar.find({item, sub}); + if (it != ff_scalar.end()) val = it->second; + } + std::vector body{proto::RSP_FF_SCALAR, uint8_t(item), uint8_t(sub)}; + const auto f = proto::pack_f32le(val); + body.insert(body.end(), f.begin(), f.end()); + push(proto::pack_frame(proto::RSP_FF_SCALAR, body)); + } else if (cmd == proto::CMD_ENABLE) { // 跟踪使能态 (0x36 门禁用) + if (!activated) { + // 照固件: 未激活是 ctrl_enable 的第一条判据 (优先于 EMERGENCY/joint_fault), + // 重发无用、无旁路。 + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x08})); + } else { + enabled = true; + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } + } else if (cmd == proto::CMD_GET_LICENSE) { // 0x2F -> RSP_LICENSE(0x4F) 26B + // 请求不校验长度 (有意放宽); 未激活也回 UID, 而 cust_id/issued/flags 保持 0; + // 载荷首字节是 state, 不是冗余帧 id。 + int st = 0; + if (activated) st = (license_flags & 0x1) ? 2 : 1; + std::vector body{uint8_t(st), uint8_t(license_ver)}; + body.insert(body.end(), license_uid.begin(), license_uid.end()); + const uint32_t cid = activated ? license_cust_id : 0; + const uint32_t iss = activated ? license_issued : 0; + const uint32_t flg = activated ? license_flags : 0; + const auto a = proto::pack_u32le(cid); + const auto b = proto::pack_u32le(iss); + const auto c = proto::pack_u32le(flg); + body.insert(body.end(), a.begin(), a.end()); + body.insert(body.end(), b.begin(), b.end()); + body.insert(body.end(), c.begin(), c.end()); + push(proto::pack_frame(proto::RSP_LICENSE, body)); + } else if (cmd == proto::CMD_ACTIVATE) { // 0x3F: 28B -> ACK / ERR + if (p.size() < 28) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else if (armed()) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x04})); + } else if (license_fail_next || activated) { + // 0x02 是聚合档 —— "已经激活过"与"MAC 不符/写失败"混在一起。 + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else { + license_cust_id = proto::read_u32le(p, 0); + license_issued = proto::read_u32le(p, 4); + license_flags = proto::read_u32le(p, 8); + activated = true; + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } + } else if (cmd == proto::CMD_ENTER_DFU) { + // 长度优先, 再两道门禁 (使能 / ROM 向量表), 最后只登记。 + // 桩不模拟跳转本身; 它模拟主机看得见的那一半: ACK 之后设备是否还在 CDC 上。 + if (!p.empty()) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else if (armed()) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x03})); + } else if (dfu_rom_table_invalid) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else { + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + for (const auto& extra : dfu_post_ack_frames) { + push(proto::pack_frame(extra.first, extra.second)); + } + if (dfu_vanishes) dfu_gone = true; + } + } else if (cmd == proto::CMD_DISABLE) { + enabled = false; + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } else if (cmd == proto::CMD_EMERGENCY_STOP || cmd == proto::CMD_CLEAR_FAULTS || + cmd == proto::CMD_RESET || cmd == proto::CMD_SET_SPEED_PERCENT || + cmd == proto::CMD_SET_MOTION_MODE || cmd == proto::CMD_MOVE_JS || + cmd == proto::CMD_MOVE_MIT || cmd == proto::CMD_MOVE_MIT_ALL || + cmd == proto::CMD_FF_PRESET) { + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } else if (cmd == proto::CMD_SET_JOINT_PARAM) { // 0x22: idx + kp,kd,tau_max + if (p.size() < 13) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else if (int(p[0]) >= n) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else { + const auto v = proto::unpack_f32s(p.data(), 1, 3); + auto& jp = joint_params[size_t(p[0])]; + jp["kp"] = v[0]; + jp["kd"] = v[1]; + jp["tau_max"] = v[2]; + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } + } else if (cmd == proto::CMD_SET_JOINT_LIMITS) { // 0x23: idx + q_min,q_max + // 分支顺序逐条照固件: 长度(0x01) -> 门禁(0x03) -> idx/空区间(0x02)。 + if (p.size() < 9) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else { + const int idx = int(p[0]); + const auto v = proto::unpack_f32s(p.data(), 1, 2); + if (armed() && axis_outside(idx, v[0], v[1])) { + // 武装中该轴的当前参考必须落在新区间内。 + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x03})); + } else if (idx >= n || !(v[0] < v[1])) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else { + auto& jp = joint_params[size_t(idx)]; + jp["q_min"] = v[0]; + jp["q_max"] = v[1]; + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } + } + } else if (cmd == proto::CMD_GET_JOINT_PARAM) { // 0x24 -> RSP_JOINT_PARAM(0x49) + if (p.size() < 1) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else if (int(p[0]) >= n) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else { + const auto& jp = joint_params[size_t(p[0])]; + std::vector body{uint8_t(p[0])}; + const auto v = proto::pack_f32s(std::vector{ + jp.at("kp"), jp.at("kd"), jp.at("tau_max"), jp.at("q_min"), + jp.at("q_max")}); + body.insert(body.end(), v.begin(), v.end()); + push(proto::pack_frame(proto::RSP_JOINT_PARAM, body)); + } + } else if (cmd == proto::CMD_PARAM_SAVE) { // 0x25: 须失能 + if (armed()) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x04})); + } else { + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } + } else if (cmd == proto::CMD_PARAM_RESET) { // 0x36: 须失能 + if (armed()) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x04})); + } else { + for (auto& jp : joint_params) { + jp = {{"kp", jp_kp}, {"kd", jp_kd}, {"tau_max", jp_tau_max}, + {"q_min", jp_q_min}, {"q_max", jp_q_max}}; + } + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } + } else if (cmd == proto::CMD_SET_MODEL_PARAM) { // 0x30: body_idx + f32[10] -> staging + if (p.size() < 1 + 4 * 10) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else if (int(p[0]) >= model_nbody) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else { + const auto vals = proto::unpack_f32s(p.data(), 1, 10); + bool bad = false; + for (double v : vals) { + if (std::isnan(v) || std::fabs(v) > 1e6) bad = true; + } + if (bad) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else if (int(p[0]) == model_nbody - 1 && vals[0] != 0.0) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else { + model_stage_body[int(p[0])] = vals; + model_dirty = true; + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } + } + } else if (cmd == proto::CMD_SET_MODEL_JM) { // 0x33: f32[7] -> staging + if (p.size() < 4 * 7) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else { + const auto vals = proto::unpack_f32s(p.data(), 0, 7); + bool bad = false; + for (double v : vals) { + if (std::isnan(v) || std::fabs(v) > 1e6) bad = true; + } + if (bad) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else { + model_stage_jm = vals; + model_dirty = true; + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } + } + } else if (cmd == proto::CMD_MODEL_COMMIT) { // 0x32: u16 mask LE + if (p.size() < 2) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else if (armed()) { + // 与固件同序: 门控优先于掩码检查 + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x04})); + } else { + const uint16_t mask = proto::read_u16le(p, 0); + uint16_t staged = 0; + for (const auto& kv : model_stage_body) staged |= uint16_t(1u << kv.first); + if (model_stage_jm) staged |= uint16_t(1u << 9); + if (mask != staged) { + // 掩码不符 -> 0x07 (与"数值非法"分开的码) + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x07})); + } else if (mask & uint16_t(~0x3FF)) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else { + for (const auto& kv : model_stage_body) { + model_body[size_t(kv.first)] = kv.second; + } + if (model_stage_jm) model_jm = *model_stage_jm; + model_override = true; + model_stage_body.clear(); + model_stage_jm.reset(); + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } + } + } else if (cmd == proto::CMD_GET_MODEL_PARAM) { // 0x34 -> RSP_MODEL_PARAM(0x54) + if (p.size() < 1) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else if (int(p[0]) >= model_nbody) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else { + const int idx = int(p[0]); + const int echo = model_body_echo_idx >= 0 ? model_body_echo_idx : idx; + std::vector body{proto::RSP_MODEL_PARAM, uint8_t(echo)}; + const auto v = proto::pack_f32s(model_body[size_t(idx)]); + body.insert(body.end(), v.begin(), v.end()); + push(proto::pack_frame(proto::RSP_MODEL_PARAM, body)); + } + } else if (cmd == proto::CMD_GET_MODEL_JM) { // 0x35 -> RSP_MODEL_JM(0x55) + std::vector body{proto::RSP_MODEL_JM}; + const auto v = proto::pack_f32s(model_jm); + body.insert(body.end(), v.begin(), v.end()); + push(proto::pack_frame(proto::RSP_MODEL_JM, body)); + } else if (cmd == proto::CMD_REVERT_MODEL) { // 0x37: 只回退模型 + if (armed()) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x04})); + } else { + model_override = false; + model_stage_body.clear(); + model_stage_jm.reset(); + model_dirty = true; + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } + } else if (cmd == proto::CMD_GET_MODEL_STATUS) { // 0x38 -> RSP_MODEL_STATUS(0x56) + uint16_t staged = 0; + for (const auto& kv : model_stage_body) staged |= uint16_t(1u << kv.first); + if (model_stage_jm) staged |= uint16_t(1u << 9); + std::vector body{proto::RSP_MODEL_STATUS, + uint8_t(model_override ? 1 : 0)}; + const auto m = proto::pack_u16le(staged); + body.insert(body.end(), m.begin(), m.end()); + body.push_back(uint8_t(model_dirty ? 1 : 0)); + push(proto::pack_frame(proto::RSP_MODEL_STATUS, body)); + } else if (cmd == proto::CMD_GET_GRAVITY) { // 0x39 -> RSP_GRAVITY(0x57) + if (p.size() < 4 * 7) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else { + // 假固件: 回一个确定性可预测的 G, 只为断言"帧格式对、值能被 SDK 解出来"。 + const auto qv = proto::unpack_f32s(p.data(), 0, 7); + double msum = 0.0; + for (size_t i = 1; i < model_body.size(); ++i) msum += model_body[i][0]; + std::vector g(7, 0.0); + for (int i = 0; i < 7; ++i) g[size_t(i)] = msum * 9.81 * 0.001 * (i + 1); + g[0] = msum * 9.81 * qv[1] * 0.1; + std::vector body{proto::RSP_GRAVITY}; + const auto f = proto::pack_f32s(g); + body.insert(body.end(), f.begin(), f.end()); + push(proto::pack_frame(proto::RSP_GRAVITY, body)); + } + } else if (cmd == proto::CMD_KIN_BENCH) { // 0x49 -> RSP_KIN_BENCH(0x4A) + // 真机是连续两帧, 不是一帧: 单帧载荷上限 255B 而全文约 306B, 原单帧会在 253B 处 + // 截断, 截断点恰好落在 LINK 行的 loop_k= 之后 => 链路计数全丢。 + for (const auto& part : split_kin_bench(kin_bench_text, kin_bench_one_frame)) { + const std::vector b(part.begin(), part.end()); + push(proto::pack_frame(proto::RSP_KIN_BENCH, b)); + } + } else if (cmd == proto::CMD_LOG_CTRL) { // 0x2D: u32 n_ticks (0=停/清) + if (p.size() < 4) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else { + const uint32_t nv = proto::read_u32le(p, 0); + if (int(nv) > log_max) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x02})); + } else { + log_target = int(nv); + log_active = nv != 0; + if (log_hz > 0.0 && nv) { + log_t0 = now_s(); // 逐拍记录: 此刻还没有数据 + log_bytes.clear(); + log_recorded_ticks = 0; + } else { + log_t0 = -1.0; + log_bytes = nv ? make_log(int(nv), n) : std::vector{}; + log_recorded_ticks = int(nv); + } + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } + } + } else if (cmd == proto::CMD_LOG_READ) { // 0x2E: u32 offset -> RSP_LOG_DATA + if (log_reply_short) { + push(proto::pack_frame(proto::RSP_LOG_DATA, std::vector{1, 2})); + } else if (p.size() < 4) { + push(proto::pack_frame(proto::RSP_ERR, std::vector{cmd, 0x01})); + } else if (log_read_fail_once) { + log_read_fail_once = false; // 掉帧: 什么都不回 + } else { + log_read_calls += 1; + const uint32_t off = proto::read_u32le(p, 0); + if (log_cursor_stuck) { + const uint32_t nxt = off == 0 ? 245u : off; + const uint32_t cnt = off == 0 ? 245u : 0u; + std::vector body = proto::pack_u32le(999999); + const auto b2 = proto::pack_u32le(nxt); + body.insert(body.end(), b2.begin(), b2.end()); + body.push_back(uint8_t(cnt)); + body.insert(body.end(), cnt, 0); + push(proto::pack_frame(proto::RSP_LOG_DATA, body)); + return; + } + if (log_hz > 0.0 && log_t0 >= 0.0) { + // 逐拍记录: 按经过的时间推进 (与本次读回次数无关)。 + const int cur = log_recorded(); + if (cur != log_recorded_ticks) { + log_recorded_ticks = cur; + log_bytes = make_log(cur, n); + } + } + const uint32_t total = uint32_t(log_bytes.size()); + uint32_t cnt = 0; + if (off < total) { + cnt = uint32_t(std::min(total - off, 245)); + } + const uint32_t nxt = (off + cnt) < total ? (off + cnt) : 0u; + std::vector body = proto::pack_u32le(total); + const auto b2 = proto::pack_u32le(nxt); + body.insert(body.end(), b2.begin(), b2.end()); + body.push_back(uint8_t(cnt)); + body.insert(body.end(), log_bytes.begin() + off, log_bytes.begin() + off + cnt); + push(proto::pack_frame(proto::RSP_LOG_DATA, body)); + } + } else if (cmd == proto::CMD_HOME) { // 固件: 各轴低速回 URDF 零位 + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + q.assign(size_t(n), 0.0); + for (int i = 0; i < 3; ++i) { + push(make_status(q, std::vector(size_t(n), 1.0), 1, 0, 0, 0, n)); + } + for (int i = 0; i < 6; ++i) { + push(make_status(q, std::vector(size_t(n), 0.0), 1, 0, 0, 0, n)); + } + } else if (cmd == proto::CMD_MOVE_J || cmd == proto::CMD_MOVE_J_SYNC) { + // 0x07 的载荷与 0x01 逐字节相同, 语义差异只在轨迹形状 (同步 vs 每轴独立 S 曲线), + // 桩不建模轨迹形状, 故两者走同一条分支。 + // ⚠ Python 侧的桩**没有** 0x07 分支 (落到 else 只回 ACK), 于是 movej_sync 到不了位; + // 那是它自己注释里记下的"代理失真"局限 —— C++ 侧直接补上, 不是行为分歧。 + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + q = proto::unpack_f32s(p.data(), 0, n); + // 先 3 拍"运动中"(dq=1) 再 6 拍到位(dq=0) —— 检验 Arm 到位等待 + for (int i = 0; i < 3; ++i) { + push(make_status(q, std::vector(size_t(n), 1.0), 1, 0, 0, 0, n)); + } + for (int i = 0; i < 6; ++i) { + push(make_status(q, std::vector(size_t(n), 0.0), 1, 0, 0, 0, n)); + } + } else if (cmd == proto::CMD_MOVE_P) { + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + pose = proto::unpack_f32s(p.data(), 0, 6); + push(make_status(q, std::vector(size_t(n), 0.0), 1, 0, 0, 0, n)); + } else if (cmd == proto::CMD_GET_TCP) { + if (tcp_payload_override) { + push(proto::pack_frame(proto::RSP_TCP, *tcp_payload_override)); + return; + } + std::vector out = pose; + if (pos_override && pos_override->size() >= 3) { + for (int i = 0; i < 3; ++i) out[size_t(i)] = (*pos_override)[size_t(i)]; + } + if (rpy_override && rpy_override->size() >= 3) { + for (int i = 0; i < 3; ++i) out[size_t(3 + i)] = (*rpy_override)[size_t(i)]; + } + push(proto::pack_frame(proto::RSP_TCP, proto::pack_f32s(out))); + } else if (cmd == proto::CMD_GET_IK) { + const std::vector qs = {0.1, -0.2, 0.3, -0.1, 0.2, -0.05, 0.15}; + auto body = proto::pack_f32s(qs); + body.push_back(uint8_t(ik_ok ? 0x01 : 0x00)); + push(proto::pack_frame(proto::RSP_IK, body)); + } else if (CART_MIN_LEN.count(cmd) != 0) { + cart_cmd(cmd, p); + } else { + push(proto::pack_frame(proto::RSP_ACK, std::vector{cmd})); + } +} + +std::optional FakeTransport::stamp_status(std::optional fr) { + // 给交付给 SDK 的状态帧盖三样固件属性: 帧序号 seq、enabled (bit9)、CART_BUSY (bit10)。 + // + // seq 每交付一帧 +1 —— 到位判据的新鲜度闸判的就是它。CART_BUSY 帧序写死: 收到 0x4E + // 之后的第 2 帧置 1、第 8 帧落 0 (即第 2..7 帧为 1)。enabled (bit9) 跟着自己的 + // self.enabled 走 —— 这一位不是可选的: Arm::enter_dfu 的本地使能态预检读的就是它。 + // + // 三样都在交付点 (而不是 push) 盖: 这里盖的才是 SDK 真正看到的那一帧。 + if (!fr || fr->cmd != proto::RSP_STATUS) return fr; + status_seq.store(uint16_t((status_seq.load() + 1) & 0xFFFF)); + auto& payload = fr->payload; + if (payload.size() < 4) return fr; + const auto s = proto::pack_u16le(status_seq.load()); + payload[2] = s[0]; + payload[3] = s[1]; + uint16_t flags = proto::read_u16le(payload, 0); + if (enabled.load()) { + flags |= uint16_t(1u << proto::FLAG_ENABLED_BIT); + } else { + flags &= uint16_t(~(1u << proto::FLAG_ENABLED_BIT)); + } + if (cart_busy_seq) { + *cart_busy_seq += 1; + const int cnt = *cart_busy_seq; + if (cnt > 8) { + cart_busy_seq.reset(); // 第 8 帧已落 0 -> 这次规划结束, 停止计数 + } else if (cnt >= 2 && cnt <= 7) { + flags |= uint16_t(1u << proto::FLAG_CART_BUSY_BIT); + } + } + const auto f = proto::pack_u16le(flags); + payload[0] = f[0]; + payload[1] = f[1]; + return fr; +} + +std::optional FakeTransport::read_frame(double timeout_in) { + (void)timeout_in; + std::lock_guard sl(state_lock); + if (dfu_gone && resp_.empty()) { + // 队列先投完再抛: 现实里登记 ACK 必须在跳转之前出 USB, 桩若先把 ACK 吞掉, SDK 那条 + // ACK 等待会超时 —— 形状就错了。 + throw TransportError("桩: 设备已进 ROM bootloader (CDC 上的读路径抛错)"); + } + std::optional fr; + if (!resp_.empty()) { + fr = proto::unpack_frame(resp_.front()); + resp_.pop_front(); + } else if (auto_status) { + // 模拟 100Hz 主动状态流 (空闲帧) —— 按 auto_status_period 节流: 不节流的话读线程会 + // 以桩的最高速率空转。 + const double now = now_s(); + if (now - auto_status_last_ < auto_status_period) return std::nullopt; + auto_status_last_ = now; + fr = proto::unpack_frame(make_status(q, {}, 1, 0, 0, 0, n)); + } + return stamp_status(fr); +} + +} // namespace testing +} // namespace litearm diff --git a/src/transport.cpp b/src/transport.cpp index 77d3e5b..71e7c32 100644 --- a/src/transport.cpp +++ b/src/transport.cpp @@ -1,119 +1,550 @@ #include "litearm/transport.hpp" -#include + +#include +#include +#include +#include +#include +#include +#include + +#include "litearm/errors.hpp" + +#ifdef _WIN32 +# include +#else +# include +# include +# include +# include +# include +# include +# include +# include +#endif namespace litearm { -// ── Sub base class ────────────────────────────────────────────────────────── +namespace { -std::optional Sub::drain_latest() { - std::optional latest; - while (true) { - auto msg = try_recv(); - if (!msg.has_value()) break; - latest = std::move(msg); +constexpr uint32_t kCdcVid = 0x1D50; +constexpr uint32_t kCdcPid = 0x606F; + +// (半帧窗口的常量 `kPartialMaxS` 已随分帧状态机搬进 `FrameReader` —— 见 +// `include/litearm/framing.hpp`。那里的注释保留了同一条"为什么必须有这个窗口"的理由。) + +/// 读超时之后那段"非阻塞补读"的时间上限 (秒)。 +/// 超时已到仍要把已经到达的字节读进来 (否则 timeout=0 一个字节都不碰), 但数据持续到达时 +/// 不能因此变成没有界的循环 —— 取 20ms。 +constexpr double kNonblockExtraS = 0.02; + +/// 写超时 —— 必须有界。一次卡住的保活写会长时间占着写锁, 把 emergency_stop()/close() +/// 一起拖住; 而"急停必须永远可达"是本包的安全底线。921600 baud 下 260B 帧约 2.8ms。 +constexpr double kWriteTimeoutS = 0.5; + +/// 一次最多向串口索取的字节数。 +/// +/// 由来: 真机空闲时状态流就有 ~14 kB/s (100Hz x 79B), 逐字节 read 是每字节一次 +/// syscall。必须按"已经到达的字节数"要 (in_waiting), 第一轮就满足 size => 立即返回、 +/// 不等待。 +constexpr size_t kReadChunkMax = 4096; + +/// 读线程每一拍允许阻塞的时长 —— 真正的读者 (Ack) 用同一档。 +constexpr double kReadSliceS = 0.1; + +std::mutex g_owners_mu; +std::map& owners() { + static std::map m; + return m; +} + +} // namespace + +double now_s() { + using clock = std::chrono::steady_clock; + return std::chrono::duration(clock::now().time_since_epoch()).count(); +} + +void claim_port(const std::string& port, const Transport* owner) { + std::lock_guard lk(g_owners_mu); + auto& m = owners(); + const auto it = m.find(port); + if (it != m.end() && it->second != nullptr && it->second != owner) { + throw TransportError( + "端口 " + port + + " 已被本进程内另一个传输占用 —— 同一端口同时只能有一条链路 " + "(跨进程那半由打开时的 flock 保证)。先把它关掉 " + "(持有者是 Arm 的话是 arm.close() / arm.disconnect()), 或改用另一个端口。"); } - return latest; + m[port] = owner; } -// ── InProcSub ─────────────────────────────────────────────────────────────── +void release_port(const std::string& port, const Transport* owner) { + // 只撤掉属于 owner 的那条登记 (不是无条件 erase): 本函数的契约应当是自明的 + // ("只撤自己的")。 + std::lock_guard lk(g_owners_mu); + auto& m = owners(); + const auto it = m.find(port); + if (it != m.end() && it->second == owner) m.erase(it); +} + +std::vector> port_owners_snapshot() { + std::lock_guard lk(g_owners_mu); + std::vector> out; + for (const auto& kv : owners()) out.emplace_back(kv.first, kv.second); + return out; +} -class InProcTransport::InProcSub : public Sub { -public: - explicit InProcSub(size_t max_depth) : max_depth_(max_depth) {} +// =========================================================================== +// 端口自动发现 +// =========================================================================== +#ifndef _WIN32 - std::optional try_recv() override { - std::lock_guard lock(mutex_); - if (queue_.empty()) return std::nullopt; - std::string msg = std::move(queue_.front()); - queue_.pop_front(); - return msg; +namespace { + +std::optional read_file_trim(const std::string& path) { + FILE* f = std::fopen(path.c_str(), "r"); + if (f == nullptr) return std::nullopt; + char buf[256] = {0}; + const size_t n = std::fread(buf, 1, sizeof(buf) - 1, f); + std::fclose(f); + if (n == 0) return std::nullopt; + std::string s(buf, n); + while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) { + s.pop_back(); } + return s; +} + +/// 从 /sys/class/tty//device 出发向上找 idVendor/idProduct。 +std::optional> usb_ids_of(const std::string& tty_name) { + const std::string dev = "/sys/class/tty/" + tty_name + "/device"; + char resolved[4096] = {0}; + if (::realpath(dev.c_str(), resolved) == nullptr) return std::nullopt; + std::string dir(resolved); + for (int up = 0; up < 6; ++up) { + const auto vid = read_file_trim(dir + "/idVendor"); + const auto pid = read_file_trim(dir + "/idProduct"); + if (vid && pid) { + try { + return std::make_pair(uint32_t(std::stoul(*vid, nullptr, 16)), + uint32_t(std::stoul(*pid, nullptr, 16))); + } catch (...) { + return std::nullopt; + } + } + const size_t slash = dir.find_last_of('/'); + if (slash == std::string::npos || slash == 0) break; + dir = dir.substr(0, slash); + } + return std::nullopt; +} + +} // namespace + +std::optional find_cdc_port() { +# ifdef __linux__ + DIR* d = ::opendir("/sys/class/tty"); + if (d == nullptr) return std::nullopt; + std::vector names; + while (dirent* e = ::readdir(d)) { + const std::string nm = e->d_name; + if (nm == "." || nm == "..") continue; + names.push_back(nm); + } + ::closedir(d); + std::sort(names.begin(), names.end()); + for (const auto& nm : names) { + const auto ids = usb_ids_of(nm); + if (ids && ids->first == kCdcVid && ids->second == kCdcPid) { + const std::string path = "/dev/" + nm; + if (::access(path.c_str(), F_OK) == 0) return path; + } + } + return std::nullopt; +# else + // macOS: 没有 sysfs, 逐个探 /dev/tty.usbmodem* 会在没有权限时给出假阳性, 故不猜 —— + // 让调用方显式给 port。这是刻意的取舍, 不是遗漏。 + return std::nullopt; +# endif +} + +// =========================================================================== +// POSIX 串口 +// =========================================================================== +namespace { + +int open_posix(const std::string& port) { + const int fd = ::open(port.c_str(), O_RDWR | O_NOCTTY | O_NONBLOCK); + if (fd < 0) { + throw TransportError("打开串口 " + port + " 失败: " + std::strerror(errno)); + } + return fd; +} + +void apply_termios(int fd, const std::string& port) { + termios tio{}; + if (::tcgetattr(fd, &tio) != 0) { + const int e = errno; + ::close(fd); + throw TransportError("串口 " + port + " tcgetattr 失败: " + std::strerror(e)); + } + ::cfmakeraw(&tio); + tio.c_cflag |= (CLOCAL | CREAD); + tio.c_cflag &= ~CRTSCTS; + tio.c_cc[VMIN] = 0; + tio.c_cc[VTIME] = 0; + // 921600 —— 与 Python 侧同档。 + const speed_t baud = B921600; + ::cfsetispeed(&tio, baud); + ::cfsetospeed(&tio, baud); + if (::tcsetattr(fd, TCSANOW, &tio) != 0) { + const int e = errno; + ::close(fd); + throw TransportError("串口 " + port + " tcsetattr 失败: " + std::strerror(e)); + } +} + +} // namespace - void push(const std::string& payload) { - std::lock_guard lock(mutex_); - queue_.push_back(payload); - while (queue_.size() > max_depth_) { - queue_.pop_front(); +SerialTransport::SerialTransport(const std::string& port, double timeout) + : port_(port), timeout_(timeout) { + // 进程内那道门排在 open 之前 (见模块顶部): 那一刻还没有任何副作用。 + claim_port(port_, this); + try { + fd_ = open_posix(port_); + // 跨进程独占: 内核的 flock。它比的是 open file description, 故同进程内两次 + // open 也会互斥。 + if (::flock(fd_, LOCK_EX | LOCK_NB) != 0) { + const int e = errno; + ::close(fd_); + fd_ = -1; + throw TransportError("串口 " + port + " 已被占用 (flock 失败): " + + std::strerror(e)); } + apply_termios(fd_, port_); + } catch (...) { + // 那一格进程内登记也要撤 —— 否则这次失败会把端口占死。 + release_port(port_, this); + throw; } +} + +SerialTransport::~SerialTransport() { close(); } - void clear() { - std::lock_guard lock(mutex_); - queue_.clear(); +void SerialTransport::close() { + // 幂等: 重复调用是 no-op, 不抛。与在途读/写互斥 —— 否则 os.close(fd) 之后 fd 号被 + // 复用, 落在这个窗口的读者会对已被释放的 fd 号做 termios 配置。 + // + // 幂等的判据是 closed_ 那一格, 不是"关两次恰好也没事": 上层有多条收尾路径 + // (Arm::close / Arm::disconnect / Arm 析构), 它们全落在这里。 + { + std::lock_guard lr(rlock_); + std::lock_guard lw(wlock_); + if (closed_) return; + closed_ = true; + } + stop_flag_.store(true); + if (fd_ >= 0) { + ::close(fd_); + fd_ = -1; } + // 放在实例锁外: 它取的是模块级那把锁, 而全仓没有第二处会持着模块锁再去取实例锁 + // => 两把锁不嵌套, 就不必论证"没有谁按相反顺序取它们"。 + release_port(port_, this); +} + +bool SerialTransport::is_open() const { + if (closed_) return false; + return fd_ >= 0; +} + +std::string SerialTransport::text_log() const { + std::lock_guard lk(const_cast(rlock_)); + // 噪声留痕现在由 `FrameReader` 持有 (两条后端共用同一份) —— 见 framing.hpp 里 + // "存在的理由只有一个: 消灭重复"那段。 + return reader_.noise(); +} -private: - size_t max_depth_; - std::mutex mutex_; - std::deque queue_; -}; +void SerialTransport::write_frame(uint8_t cmd, const uint8_t* payload, size_t len) { + const std::vector frame = proto::pack_frame(cmd, payload, len); + std::lock_guard lk(wlock_); + if (closed_ || fd_ < 0) throw TransportError("写失败: 链路已关闭"); + const double deadline = now_s() + kWriteTimeoutS; + size_t off = 0; + while (off < frame.size()) { + const double left = deadline - now_s(); + if (left <= 0.0) { + // write() 抛 => 帧没送达 (残缺帧被固件按 CRC 丢) => 抛 TransportError。 + throw TransportError("写失败: 写超时 (整帧未送达)"); + } + pollfd p{}; + p.fd = fd_; + p.events = POLLOUT; + const int ms = int(std::max(0.0, std::min(left, 0.05)) * 1000.0); + const int pr = ::poll(&p, 1, ms); + if (pr < 0) { + if (errno == EINTR) continue; + throw TransportError(std::string("写失败: ") + std::strerror(errno)); + } + if (pr == 0) continue; // 还没可写, 回到上面判截止 + const ssize_t n = ::write(fd_, frame.data() + off, frame.size() - off); + if (n < 0) { + if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) continue; + throw TransportError(std::string("写失败: ") + std::strerror(errno)); + } + off += size_t(n); + } + // flush (tcdrain) 抛 => 整帧已经交进驱动、会被送达, 失败收不回来 => 不算发送失败, + // 只计进 flush_failures (静默是另一条底线)。 + if (::tcdrain(fd_) != 0) { + flush_failures_.fetch_add(1); + } +} -// ── InProcTransport ───────────────────────────────────────────────────────── +std::optional> SerialTransport::read_chunk(double end_steady) { + const double remain = std::max(0.0, end_steady - now_s()); + pollfd p{}; + p.fd = fd_; + p.events = POLLIN; + const int ms = int(std::min(remain, 0.05) * 1000.0); + const int pr = ::poll(&p, 1, ms); + if (pr < 0) { + if (errno == EINTR) return std::nullopt; + throw TransportError(std::string("读失败: ") + std::strerror(errno)); + } + if (pr == 0) return std::nullopt; + if (p.revents & (POLLERR | POLLHUP | POLLNVAL)) { + // 设备被摘掉 / 跳进 ROM bootloader 时 Linux 上的典型形状。 + throw TransportError("读失败: 设备已从总线上消失 (POLLERR/POLLHUP)"); + } + const size_t want = want_bytes(); + std::vector chunk(want); + const ssize_t n = ::read(fd_, chunk.data(), want); + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return std::nullopt; + throw TransportError(std::string("读失败: ") + std::strerror(errno)); + } + if (n == 0) return std::nullopt; // select 报就绪而 read 返回空 = 设备断了 + chunk.resize(size_t(n)); + return chunk; +} -InProcTransport::InProcTransport(size_t fifo_depth) - : depth_(fifo_depth) {} +size_t SerialTransport::want_bytes() const { + // 这次 read 该要几个字节 = 已经到达的字节数 (封顶 kReadChunkMax)。 + // in_waiting > 1 => 按它要 —— 一次把这批取走, 这是本改动的全部收益所在; + // 否则 (0/1/拿不到) => 要 1: 阻塞等第一个字节。 + // 拿不到就必须退化成 1, 不能抛 —— 退化成 1 只慢不错。 + int n = 0; + if (::ioctl(fd_, TIOCINQ, &n) != 0 || n <= 1) return 1; + return std::min(size_t(n), kReadChunkMax); +} -InProcTransport::~InProcTransport() { - close(); +std::optional SerialTransport::read_frame(double timeout) { + // timeout=0 是"有就给我", 不是"一个字节都不准碰": 超时已到时仍要把已经到达的字节 + // 非阻塞地读进来, 否则"数据早已躺在驱动缓冲里"这一支永远取不到。 + const double end = now_s() + (timeout >= 0.0 ? timeout : timeout_); + std::lock_guard lk(rlock_); + if (closed_ || fd_ < 0) throw TransportError("读失败: 链路已关闭"); + return read_frame_locked(end); } -void InProcTransport::pub(const std::string& topic, const std::string& payload) { - std::lock_guard lock(lock_); - if (closed_) return; - auto it = subs_.find(topic); - if (it == subs_.end()) return; - for (auto& sub : it->second) { - sub->push(payload); +std::optional SerialTransport::read_frame_locked(double end) { + double nb_end = -1.0; // 超时之后那段非阻塞取字节的截止 + while (true) { + // ① 装配 —— 分帧状态机在 `FrameReader` 里 (两条后端共用同一份)。 + // 这里传 `nullptr, 0`: 只把**缓冲里已经攒下的**帧取空。一次 `read()` 常带回 + // 20+ 条 153B 状态帧, 所以这一步必须循环取到空为止。 + if (auto f = reader_.feed(nullptr, 0)) return f; + const double now = now_s(); + // ② 半帧窗口 —— 一个收不齐的假帧头必须在超时后被丢掉, 否则它会永远堵在缓冲头部。 + // ⚠ 它返回"缓冲有没有被改动": 改动过就要**立刻重试装配**, 因为刚丢掉的假帧头 + // 后面很可能就跟着一条真帧。 + if (reader_.tick(now)) continue; + if (now >= end) { + // 窗口已经用尽 —— 但仍要把已经到达的字节读进来。这一步不会等新数据: + // 传进去的 end 就是此刻 => poll 的超时被夹到 0 => 没数据立刻没结果。 + // 必须有时间上限 (kNonblockExtraS): 数据持续到达时, 光靠"读到凑出一帧"是 + // 没有界的。 + if (nb_end < 0.0) { + nb_end = now + kNonblockExtraS; + } else if (now > nb_end) { + return std::nullopt; + } + auto chunk = read_chunk(now); + if (chunk && !chunk->empty()) { + // ⚠⚠ **必须接住返回值**: 这一次 `feed` 很可能**当场就装配出一条帧** + // (字节是整帧进来的) —— 丢掉它, 下一轮的 `feed(nullptr,0)` 就再也拿不到 + // (帧已经被取走了), 症状是"链路有数据但一帧都读不出来"。实测踩过。 + if (auto f = reader_.feed(chunk->data(), chunk->size())) return f; + continue; // 没凑出整帧 -> 回上面重试装配 + } + return std::nullopt; + } + auto chunk = read_chunk(end); + if (chunk && !chunk->empty()) { + // ⚠ 同上: 接住返回值 (这一次 feed 很可能就装配出了帧) + if (auto f = reader_.feed(chunk->data(), chunk->size())) return f; + } } } -std::shared_ptr InProcTransport::sub(const std::string& topic) { - std::lock_guard lock(lock_); - auto sub = std::make_shared(depth_); - subs_[topic].push_back(sub); - return sub; +// =========================================================================== +// Windows 串口 (与本机 Linux 上的 POSIX 后端同契约; 未在 Windows 上实机验证) +// =========================================================================== +#else // _WIN32 + +namespace { + +std::string win_path(const std::string& port) { + if (port.rfind("\\\\.\\", 0) == 0) return port; + return "\\\\.\\" + port; } -std::string InProcTransport::query(const std::string& topic, const std::string& payload) { - std::lock_guard lock(lock_); - auto it = queryables_.find(topic); - if (it == queryables_.end()) { - throw std::runtime_error("No queryable registered for topic: " + topic); +} // namespace + +SerialTransport::SerialTransport(const std::string& port, double timeout) + : port_(port), timeout_(timeout) { + claim_port(port_, this); + const std::string path = win_path(port); + HANDLE h = ::CreateFileA(path.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, + OPEN_EXISTING, 0, nullptr); + if (h == INVALID_HANDLE_VALUE) { + release_port(port_, this); + throw TransportError("打开串口 " + port + " 失败: CreateFile err=" + + std::to_string(::GetLastError())); } - // Call handler while holding the lock (matches Python behavior for tests) - return it->second(payload); + DCB dcb{}; + dcb.DCBlength = sizeof(dcb); + if (!::GetCommState(h, &dcb)) { + ::CloseHandle(h); + release_port(port_, this); + throw TransportError("串口 " + port + " GetCommState 失败"); + } + dcb.BaudRate = 921600; + dcb.ByteSize = 8; + dcb.Parity = NOPARITY; + dcb.StopBits = ONESTOPBIT; + dcb.fBinary = TRUE; + dcb.fParity = FALSE; + dcb.fOutxCtsFlow = FALSE; + dcb.fOutxDsrFlow = FALSE; + dcb.fDtrControl = DTR_CONTROL_ENABLE; + dcb.fRtsControl = RTS_CONTROL_ENABLE; + if (!::SetCommState(h, &dcb)) { + ::CloseHandle(h); + release_port(port_, this); + throw TransportError("串口 " + port + " SetCommState 失败"); + } + COMMTIMEOUTS tmo{}; + tmo.ReadIntervalTimeout = MAXDWORD; + tmo.ReadTotalTimeoutMultiplier = 0; + tmo.ReadTotalTimeoutConstant = 0; + tmo.WriteTotalTimeoutConstant = DWORD(kWriteTimeoutS * 1000); + ::SetCommTimeouts(h, &tmo); + fd_ = int(reinterpret_cast(h)); } -void InProcTransport::declare_queryable(const std::string& topic, Handler handler) { - std::lock_guard lock(lock_); - if (queryables_.count(topic)) { - throw std::runtime_error("Queryable already registered for topic: " + topic); +SerialTransport::~SerialTransport() { close(); } + +void SerialTransport::close() { + { + std::lock_guard lr(rlock_); + std::lock_guard lw(wlock_); + if (closed_) return; + closed_ = true; } - queryables_[topic] = std::move(handler); + stop_flag_.store(true); + if (fd_ >= 0) { + ::CloseHandle(reinterpret_cast(intptr_t(fd_))); + fd_ = -1; + } + release_port(port_, this); } -void InProcTransport::close() { - std::lock_guard lock(lock_); - if (closed_) return; - closed_ = true; - for (auto& [topic, subs] : subs_) { - for (auto& sub : subs) { - sub->clear(); +bool SerialTransport::is_open() const { return !closed_ && fd_ >= 0; } + +std::string SerialTransport::text_log() const { + std::lock_guard lk(const_cast(rlock_)); + // 噪声留痕现在由 `FrameReader` 持有 (两条后端共用同一份) —— 见 framing.hpp 里 + // "存在的理由只有一个: 消灭重复"那段。 + return reader_.noise(); +} + +void SerialTransport::write_frame(uint8_t cmd, const uint8_t* payload, size_t len) { + const std::vector frame = proto::pack_frame(cmd, payload, len); + std::lock_guard lk(wlock_); + if (closed_ || fd_ < 0) throw TransportError("写失败: 链路已关闭"); + HANDLE h = reinterpret_cast(intptr_t(fd_)); + DWORD off = 0; + while (off < frame.size()) { + DWORD wrote = 0; + if (!::WriteFile(h, frame.data() + off, DWORD(frame.size() - off), &wrote, + nullptr)) { + throw TransportError("写失败: WriteFile err=" + + std::to_string(::GetLastError())); } + if (wrote == 0) throw TransportError("写失败: 写超时 (整帧未送达)"); + off += wrote; } - subs_.clear(); - queryables_.clear(); + if (!::FlushFileBuffers(h)) flush_failures_.fetch_add(1); } -// ── Factory ───────────────────────────────────────────────────────────────── +std::optional> SerialTransport::read_chunk(double end_steady) { + const double remain = std::max(0.0, end_steady - now_s()); + if (remain <= 0.0) return std::nullopt; // 非阻塞档: 交给下面 want_bytes + std::this_thread::sleep_for(std::chrono::milliseconds( + long(std::min(remain, 0.05) * 1000.0))); + return std::nullopt; +} -std::unique_ptr make_transport(const std::string& kind) { - if (kind == "inproc") { - return std::make_unique(); +size_t SerialTransport::want_bytes() const { return 1; } + +std::optional SerialTransport::read_frame(double timeout) { + const double end = now_s() + (timeout >= 0.0 ? timeout : timeout_); + std::lock_guard lk(rlock_); + if (closed_ || fd_ < 0) throw TransportError("读失败: 链路已关闭"); + HANDLE h = reinterpret_cast(intptr_t(fd_)); + for (;;) { + DWORD errors = 0; + COMSTAT st{}; + ::ClearCommError(h, &errors, &st); + if (errors & (CE_FRAME | CE_RXOVER | CE_OVERRUN)) { + // 线路噪声/溢出: 与 POSIX 侧"丢弃坏帧重扫"同口径, 不抛。 + } + if (st.cbInQue > 0) { + const DWORD want = DWORD(std::min(want_bytes() == 1 ? st.cbInQue + : want_bytes(), + kReadChunkMax)); + std::vector chunk(want); + DWORD got = 0; + if (!::ReadFile(h, chunk.data(), want, &got, nullptr)) { + throw TransportError("读失败: ReadFile err=" + + std::to_string(::GetLastError())); + } + if (got > 0) { + chunk.resize(got); + // ⚠ 同上: 必须接住返回值, 否则整帧会被就地丢掉 + if (auto f = reader_.feed(chunk.data(), chunk.size())) return f; + } + } + // ⚠⚠ 这一段从前是**与 POSIX 侧逐字重复**的一份分帧状态机 (代码里自称"同一段 + // 逻辑")。那是会漂的拷贝, 而本分支在 Linux 上**编不了** ⇒ 漂了也不会当场发现。 + // 现在两边都调 `FrameReader` —— 同样的代码在 POSIX 侧被编译、被测试。 + const double now = now_s(); + if (auto f = reader_.feed(nullptr, 0)) return f; + if (reader_.tick(now)) continue; // 丢掉了假帧头 ⇒ 立刻重试装配 + if (now >= end) return std::nullopt; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); } - throw std::runtime_error( - "Unknown transport kind: " + kind + - ". Available: 'inproc'. Zenoh transport requires zenoh-c (not linked)."); } -} // namespace litearm +std::optional find_cdc_port() { + // 逐个打开 COM1..COM32 读描述符无法可靠拿到 VID:PID (需要 SetupAPI), 故不猜 —— + // 让调用方显式给 port。与 macOS 分支同一个取舍。 + return std::nullopt; +} + +#endif // _WIN32 + +} // namespace litearm diff --git a/src/types.cpp b/src/types.cpp deleted file mode 100644 index bda0bd7..0000000 --- a/src/types.cpp +++ /dev/null @@ -1,312 +0,0 @@ -#include "litearm/types.hpp" -#include -#include -#include - -namespace litearm { - -// ── LiteArmValue ──────────────────────────────────────────────────────────── - -LiteArmValue::LiteArmValue(ListType v) - : kind_(Kind::List), list_val_(std::make_shared(std::move(v))) {} - -LiteArmValue::LiteArmValue(MapType v) - : kind_(Kind::Map), map_val_(std::make_shared(std::move(v))) {} - -bool LiteArmValue::as_bool() const { - if (kind_ != Kind::Bool) throw std::bad_cast(); - return bool_val_; -} - -int64_t LiteArmValue::as_int() const { - if (kind_ != Kind::Int) throw std::bad_cast(); - return int_val_; -} - -double LiteArmValue::as_double() const { - if (kind_ != Kind::Double) throw std::bad_cast(); - return double_val_; -} - -const std::string& LiteArmValue::as_string() const { - if (kind_ != Kind::String) throw std::bad_cast(); - return str_val_; -} - -const LiteArmValue::BytesType& LiteArmValue::as_bytes() const { - if (kind_ != Kind::Bytes) throw std::bad_cast(); - return bytes_val_; -} - -const LiteArmValue::ListType& LiteArmValue::as_list() const { - if (kind_ != Kind::List) throw std::bad_cast(); - return *list_val_; -} - -const LiteArmValue::MapType& LiteArmValue::as_map() const { - if (kind_ != Kind::Map) throw std::bad_cast(); - return *map_val_; -} - -LiteArmValue::ListType& LiteArmValue::as_list() { - if (kind_ != Kind::List) throw std::bad_cast(); - return *list_val_; -} - -LiteArmValue::MapType& LiteArmValue::as_map() { - if (kind_ != Kind::Map) throw std::bad_cast(); - return *map_val_; -} - -LiteArmValue LiteArmValue::from_vec(const std::vector& v) { - ListType list; - list.reserve(v.size()); - for (double d : v) { - list.emplace_back(d); - } - return LiteArmValue(std::move(list)); -} - -LiteArmValue LiteArmValue::from_mat(const std::vector>& m) { - ListType outer; - outer.reserve(m.size()); - for (const auto& row : m) { - outer.push_back(from_vec(row)); - } - return LiteArmValue(std::move(outer)); -} - -std::vector LiteArmValue::to_vec() const { - std::vector result; - if (kind_ == Kind::List) { - for (const auto& item : *list_val_) { - if (item.is_double()) { - result.push_back(item.as_double()); - } else if (item.is_int()) { - result.push_back(static_cast(item.as_int())); - } else { - throw std::bad_cast(); - } - } - } else { - throw std::bad_cast(); - } - return result; -} - -std::vector> LiteArmValue::to_mat() const { - std::vector> result; - if (kind_ == Kind::List) { - for (const auto& item : *list_val_) { - result.push_back(item.to_vec()); - } - } else { - throw std::bad_cast(); - } - return result; -} - -LiteArmValue LiteArmValue::make_map( - std::initializer_list> items) -{ - MapType m; - for (auto& [k, v] : items) { - m[k] = v; - } - return LiteArmValue(std::move(m)); -} - -// ── ArmState ──────────────────────────────────────────────────────────────── - -const char* to_string(ArmState state) { - switch (state) { - case ArmState::DISCONNECTED: return "disconnected"; - case ArmState::CONNECTING: return "connecting"; - case ArmState::READY: return "ready"; - case ArmState::MOVING: return "moving"; - case ArmState::HOLDING: return "holding"; - case ArmState::ZERO_GRAVITY: return "zero_gravity"; - case ArmState::IMPEDANCE: return "impedance"; - case ArmState::FOLLOWING: return "following"; - case ArmState::STOPPING: return "stopping"; - case ArmState::FAULT: return "fault"; - default: return "unknown"; - } -} - -ArmState arm_state_from_string(const std::string& s) { - if (s == "disconnected") return ArmState::DISCONNECTED; - if (s == "connecting") return ArmState::CONNECTING; - if (s == "ready") return ArmState::READY; - if (s == "moving") return ArmState::MOVING; - if (s == "holding") return ArmState::HOLDING; - if (s == "zero_gravity") return ArmState::ZERO_GRAVITY; - if (s == "impedance") return ArmState::IMPEDANCE; - if (s == "following") return ArmState::FOLLOWING; - if (s == "stopping") return ArmState::STOPPING; - if (s == "fault") return ArmState::FAULT; - return ArmState::UNKNOWN; -} - -// ── JointTrajectory ───────────────────────────────────────────────────────── - -double JointTrajectory::duration_s() const { - if (frames.empty()) return 0.0; - return frames.back().t; -} - -std::vector> JointTrajectory::q() const { - std::vector> result; - result.reserve(frames.size()); - for (const auto& f : frames) { - result.push_back(f.q); - } - return result; -} - -std::vector JointTrajectory::t() const { - std::vector result; - result.reserve(frames.size()); - for (const auto& f : frames) { - result.push_back(f.t); - } - return result; -} - -LiteArmValue JointTrajectory::to_value() const { - LiteArmValue::MapType m; - m["schema"] = LiteArmValue(SCHEMA); - m["name"] = LiteArmValue(name); - m["created_at"] = LiteArmValue(created_at); - m["duration_s"] = LiteArmValue(duration_s()); - - if (sample_rate_hz) m["sample_rate_hz"] = LiteArmValue(*sample_rate_hz); - else m["sample_rate_hz"] = LiteArmValue(nullptr); - - if (filter_alpha) m["filter_alpha"] = LiteArmValue(*filter_alpha); - else m["filter_alpha"] = LiteArmValue(nullptr); - - if (robot_serial) m["robot_serial"] = LiteArmValue(*robot_serial); - else m["robot_serial"] = LiteArmValue(nullptr); - - if (config_checksum_sha256) m["config_checksum_sha256"] = LiteArmValue(*config_checksum_sha256); - else m["config_checksum_sha256"] = LiteArmValue(nullptr); - - LiteArmValue::ListType frame_list; - for (const auto& f : frames) { - LiteArmValue::MapType fm; - fm["t"] = LiteArmValue(f.t); - fm["q"] = LiteArmValue::from_vec(f.q); - if (f.dq) fm["dq"] = LiteArmValue::from_vec(*f.dq); - if (f.tau) fm["tau"] = LiteArmValue::from_vec(*f.tau); - frame_list.push_back(LiteArmValue(std::move(fm))); - } - m["frames"] = LiteArmValue(std::move(frame_list)); - - return LiteArmValue(std::move(m)); -} - -static std::vector extract_vec(const LiteArmValue& v) { - if (v.is_list()) return v.to_vec(); - throw std::runtime_error("Expected list for vector extraction"); -} - -JointTrajectory JointTrajectory::from_value(const LiteArmValue& v) { - if (!v.is_map()) throw std::runtime_error("JointTrajectory: expected map"); - const auto& m = v.as_map(); - - JointTrajectory traj; - - // Schema validation - auto schema_it = m.find("schema"); - if (schema_it != m.end() && !schema_it->second.is_null()) { - if (schema_it->second.as_string() != SCHEMA) { - throw std::runtime_error("Unsupported trajectory schema: " + - schema_it->second.as_string()); - } - } - - // Name - auto name_it = m.find("name"); - if (name_it != m.end() && !name_it->second.is_null()) { - traj.name = name_it->second.as_string(); - } - - // created_at - auto ca_it = m.find("created_at"); - if (ca_it != m.end() && !ca_it->second.is_null()) { - traj.created_at = ca_it->second.as_string(); - } - - // sample_rate_hz - auto sr_it = m.find("sample_rate_hz"); - if (sr_it == m.end()) sr_it = m.find("sample_rate"); - if (sr_it != m.end() && !sr_it->second.is_null()) { - traj.sample_rate_hz = sr_it->second.as_double(); - } - - // filter_alpha - auto fa_it = m.find("filter_alpha"); - if (fa_it != m.end() && !fa_it->second.is_null()) { - traj.filter_alpha = fa_it->second.as_double(); - } - - // robot_serial - auto rs_it = m.find("robot_serial"); - if (rs_it != m.end() && !rs_it->second.is_null()) { - traj.robot_serial = rs_it->second.as_string(); - } - - // config_checksum_sha256 - auto cs_it = m.find("config_checksum_sha256"); - if (cs_it != m.end() && !cs_it->second.is_null()) { - traj.config_checksum_sha256 = cs_it->second.as_string(); - } - - // Frames (support both "frames" and legacy "trajectory" key) - auto frames_it = m.find("frames"); - if (frames_it == m.end()) frames_it = m.find("trajectory"); - if (frames_it == m.end() || !frames_it->second.is_list()) { - throw std::runtime_error("Trajectory missing frames/trajectory array"); - } - - for (const auto& item : frames_it->second.as_list()) { - if (!item.is_map()) { - throw std::runtime_error("Trajectory frame must be a map"); - } - const auto& fm = item.as_map(); - - TrajectoryFrame frame; - - auto t_it = fm.find("t"); - if (t_it != fm.end()) frame.t = t_it->second.as_double(); - - auto q_it = fm.find("q"); - if (q_it == fm.end()) q_it = fm.find("joints"); - if (q_it != fm.end()) frame.q = extract_vec(q_it->second); - - auto dq_it = fm.find("dq"); - if (dq_it != fm.end() && !dq_it->second.is_null()) { - frame.dq = extract_vec(dq_it->second); - } - - auto tau_it = fm.find("tau"); - if (tau_it != fm.end() && !tau_it->second.is_null()) { - frame.tau = extract_vec(tau_it->second); - } - - traj.frames.push_back(std::move(frame)); - } - - // Normalize timestamps: shift so first frame starts at t=0 - if (!traj.frames.empty() && traj.frames[0].t != 0.0) { - double origin = traj.frames[0].t; - for (auto& f : traj.frames) { - f.t -= origin; - } - } - - return traj; -} - -} // namespace litearm diff --git a/tests/test_accessors.cpp b/tests/test_accessors.cpp new file mode 100644 index 0000000..5bfeb94 --- /dev/null +++ b/tests/test_accessors.cpp @@ -0,0 +1,155 @@ +// 只读访问器 —— ⚠ 这七个**全是与 Python 参照分叉**的加法 (参照侧一个都没有)。 +// +// 它们的共同契约 (也是本文件要钉的东西): +// * **纯本地快照**: 不发帧、不改状态; +// * **终态下也不抛** —— 现场最需要它们的时候, 正是链路已经废掉的时候。 +#include "test_support.hpp" + +using namespace litearm; + +// ---------------------------------------------------------------- is_connected + +TEST(accessor_is_connected_reflects_the_link_state) { + lt::Offline off; + CHECK(off->is_connected()); + off->close(); + CHECK(!off->is_connected()); +} + +TEST(accessor_is_connected_turns_false_when_the_reader_dies) { + // ⚠ 两个条件里的**第一条** (读线程死了)。这一支**不用等**超龄, 立刻翻转。 + lt::Offline off; + CHECK(off->is_connected()); + off.t().dfu_gone = true; // 桩的读路径开始抛 ⇒ reader_loop 走 die() + const double end = now_s() + 1.0; + while (off->is_connected() && now_s() < end) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + CHECK(!off->is_connected()); +} + +// ⚠ 「状态帧超龄 ⇒ 判失联」那条分支**搬到 `tests/test_clock.cpp` 了**: +// `clock_fake_clock_makes_the_staleness_judgement_instant_and_deterministic`。 +// 原来它在**这里**, 靠 `sleep_for(2.1s)` 真睡过 `STATUS_STALE_MAX_S` —— 那条写法有两个 +// 毛病: ① 一条用例 2 秒; ② 断言里混进了"调度器会不会按时醒来"这个无关变量。 +// 搬到假钟下面之后: 时间由用例推, 瞬间到位, 因果干净。 +// ⚠ 这不是"把一个判据删了": 断言的性质**一模一样**(越过阈值 ⇒ 翻 false), 只是换了驱动。 +// 而"默认时间源确实是真钟"由 `test_clock.cpp` 的 +// `clock_default_is_steady_so_timestamps_are_real` 单独守着。 + +// ---------------------------------------------------------------- status_seq / msg_hz + +TEST(accessor_status_seq_and_msg_hz_track_the_stream) { + lt::Offline off; + const uint64_t s1 = off->status_seq(); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + const uint64_t s2 = off->status_seq(); + CHECK(s2 > s1); + // 桩的被动流是 1ms 一拍 ⇒ 频率远高于 100Hz。⚠ 只判"确实在报频率", 不钉具体值 + // (钉具体值就是把这条用例变成对调度器速度的断言)。 + CHECK(off->msg_hz(proto::RSP_STATUS) > 100.0); + // 从没出现过的上行 id ⇒ 0 + CHECK_NEAR(off->msg_hz(0x7F), 0.0, 1e-12); +} + +// ---------------------------------------------------------------- banner_version + +TEST(accessor_banner_version_requires_a_real_version_string) { + lt::Offline off; + CHECK(!off->banner_version().has_value()); // 桩默认没有噪声文本 + + off.t().set_text_log("noise [litearm-usbcdc] ready (sig_ok=1) Litearm1.8.0-7J"); + const auto v = off->banner_version(); + CHECK(v.has_value()); + if (v) CHECK_EQ(*v, std::string("Litearm1.8.0-7J")); + + // ⚠ 小写的 `[litearm-usbcdc]` **不许**被当成版本锚 (锚是大小写敏感的 "Litearm") + off.t().set_text_log("only [litearm-usbcdc] ready, no version here"); + CHECK(!off->banner_version().has_value()); + + // ⚠ 只找到前缀、后面没有合法版本号 ⇒ 也**不许**报出去。 + // 本函数的全部用处就是"告诉现场板子上跑的是哪一版", 报半截比不报更坏。 + off.t().set_text_log("Litearm"); + CHECK(!off->banner_version().has_value()); + off.t().set_text_log("Litearm1.5"); + CHECK(!off->banner_version().has_value()); +} + +// ---------------------------------------------------------------- host_stats + +TEST(accessor_host_stats_starts_clean) { + lt::Offline off; + const auto s = off->host_stats(); + CHECK_EQ(s.dropped, uint64_t(0)); + CHECK_EQ(s.bad_status_frames, uint64_t(0)); + CHECK_EQ(s.flush_failures, uint64_t(0)); + CHECK_EQ(s.cart_evicted_unclaimed, uint64_t(0)); + CHECK_EQ(s.cart_extra_replies, uint64_t(0)); + CHECK(!s.cart_probe_silent); +} + +TEST(accessor_host_stats_forwards_the_transport_counters) { + // ⚠ 这条钉的是**转发链**: 假件注入的值必须能穿过测试夹具那层包装 (`SharedFake`) + // 到达 `host_stats()`。它曾经**传不上来** —— `flush_failures()` 当时不是虚函数, + // 包装层读的是自己那个恒 0 的计数器。包装层漏转发口 = 假绿, 见 test_support.hpp。 + lt::Offline off; + off.t().set_flush_failures(42); + CHECK_EQ(off->host_stats().flush_failures, uint64_t(42)); +} + +TEST(accessor_host_stats_counts_status_frames_that_pass_crc_but_fail_to_decode) { + // ⚠ 这个计数存在的**唯一**理由: "每帧都解不出来" 与 "根本没有帧" 在等待者眼里 + // 是同一种静默, 而两者要查的方向相反 (前者查版本/格式错配, 后者查链路)。 + // 没有它, 现场没法把二者分开。 + lt::Offline off; + // 3 字节的状态载荷: 帧层合法 (CRC 对), 但短于最小状态帧 ⇒ 解码必抛 + off.t().push_frame(proto::RSP_STATUS, {0x01, 0x02, 0x03}); + const double end = now_s() + 1.0; + while (off->host_stats().bad_status_frames == 0 && now_s() < end) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + CHECK_EQ(off->host_stats().bad_status_frames, uint64_t(1)); +} + +// ---------------------------------------------------------------- options + +TEST(accessor_options_reports_current_values_not_a_stale_snapshot) { + // ⚠⚠ 这条钉的是**设计决定**: `options()` 返回**现值**, 不是构造期那份死快照。 + // 本仓的旋钮是公开成员、**运行期可改** (与 A 侧"只在构造期定"不同) ⇒ 返回死快照 + // 会让调用方读到过期值 —— 那正是本仓刚修掉的 `port_string()` 那类坑 + // (注释承诺一套、代码做另一套)。见 arm.hpp 里 `options()` 的说明。 + lt::Offline off; + off->move_timeout = 7.5; + off->q_tol = 0.123; + const auto o = off->options(); + CHECK_NEAR(o.move_timeout, 7.5, 1e-12); + CHECK_NEAR(o.q_tol, 0.123, 1e-12); + CHECK(o.port.has_value()); + if (o.port) CHECK_EQ(*o.port, std::string("fake")); +} + +// ---------------------------------------------------------------- is_in_dfu + +TEST(accessor_is_in_dfu_flips_on_enter_dfu) { + lt::Offline off; + CHECK(!off->is_in_dfu()); + off->enter_dfu(); + CHECK(off->is_in_dfu()); +} + +// ---------------------------------------------------------------- 纯本地: 终态下也不抛 + +TEST(accessor_local_snapshots_never_throw_even_in_the_terminal_state) { + // ⚠ 这是这批访问器的**共同契约**: 终态下链路已经废了, 而那正是现场最想看一眼 + // "到底发生了什么"的时候 —— 此时抛异常等于把诊断口自己关掉。 + lt::Offline off; + off->enter_dfu(); + CHECK(off->is_in_dfu()); + CHECK_NOTHROW(off->is_connected()); + CHECK_NOTHROW(off->status_seq()); + CHECK_NOTHROW(off->msg_hz(proto::RSP_STATUS)); + CHECK_NOTHROW(off->banner_version()); + CHECK_NOTHROW(off->host_stats()); + CHECK_NOTHROW(off->options()); + CHECK_NOTHROW(off->port_string()); +} diff --git a/tests/test_arm.cpp b/tests/test_arm.cpp deleted file mode 100644 index 8b87236..0000000 --- a/tests/test_arm.cpp +++ /dev/null @@ -1,777 +0,0 @@ -#include -#include "litearm/arm.hpp" -#include "litearm/codec.hpp" -#include "litearm/exceptions.hpp" -#include "litearm/protocol.hpp" -#include "litearm/transport.hpp" - -namespace litearm { -namespace { - -// ── Mock server helper ────────────────────────────────────────────────────── - -/// Create an Arm + InProcTransport pair with a mock RPC handler. -struct MockArm { - Arm arm; - std::shared_ptr tp; - std::shared_ptr estop_sub; // for verifying estop publishes -}; - -using KwargsMap = std::map; - -template -MockArm make_mock_arm(const std::string& arm_id, Handler handler) { - auto tp = std::make_shared(); - - // Register the mock RPC handler - tp->declare_queryable(rpc_topic(arm_id), - [handler](const std::string& payload) -> std::string { - auto [method, kwargs] = decode_request(payload); - try { - LiteArmValue result = handler(method, kwargs); - return encode_reply_ok(result); - } catch (const LiteArmError& e) { - return encode_reply_error("LiteArmError", e.what()); - } catch (const std::exception& e) { - return encode_reply_error("LiteArmError", e.what()); - } - }); - - auto estop_sub = tp->sub(estop_topic(arm_id)); - - Arm arm("unused", arm_id, tp); - return MockArm{std::move(arm), tp, estop_sub}; -} - -// ── FK / IK ───────────────────────────────────────────────────────────────── - -TEST(ArmFkIk, ForwardKinematics) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "fk"); - EXPECT_EQ(kwargs.at("q").as_list().size(), 7u); - // Return [position, rotation_matrix] - LiteArmValue::ListType result; - result.push_back(LiteArmValue::from_vec({1.0, 2.0, 3.0})); - result.push_back(LiteArmValue::from_mat({ - {1.0, 0.0, 0.0}, - {0.0, 1.0, 0.0}, - {0.0, 0.0, 1.0}, - })); - return LiteArmValue(std::move(result)); - }); - - auto [pos, rot] = mock.arm.fk({0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}); - EXPECT_EQ(pos.size(), 3u); - EXPECT_DOUBLE_EQ(pos[0], 1.0); - EXPECT_EQ(rot.size(), 3u); - EXPECT_DOUBLE_EQ(rot[0][0], 1.0); -} - -TEST(ArmFkIk, InverseKinematics) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "ik"); - EXPECT_EQ(kwargs.at("pos_d").as_list().size(), 3u); - EXPECT_EQ(kwargs.at("R_d").as_list().size(), 3u); - LiteArmValue::ListType result; - result.push_back(LiteArmValue::from_vec({0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7})); - result.push_back(LiteArmValue(true)); - return LiteArmValue(std::move(result)); - }); - - auto [q, success] = mock.arm.ik( - {1.0, 2.0, 3.0}, - {{1,0,0},{0,1,0},{0,0,1}} - ); - EXPECT_TRUE(success); - EXPECT_EQ(q.size(), 7u); -} - -TEST(ArmFkIk, PlanMovel) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "plan_movel"); - LiteArmValue::ListType path; - path.push_back(LiteArmValue::from_vec({0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7})); - path.push_back(LiteArmValue::from_vec({0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8})); - return LiteArmValue(std::move(path)); - }); - - auto path = mock.arm.plan_movel( - {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, - LiteArmValue(std::string("goal")) - ); - EXPECT_EQ(path.size(), 2u); - EXPECT_EQ(path[0].size(), 7u); -} - -// ── Motion execution ──────────────────────────────────────────────────────── - -TEST(ArmMotion, Movej) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "movej"); - EXPECT_DOUBLE_EQ(kwargs.at("speed").as_double(), 0.5); - EXPECT_DOUBLE_EQ(kwargs.at("settle_s").as_double(), 1.0); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.movej({0,0,0,0,0,0,0}, 0.5)); -} - -TEST(ArmMotion, MovejWithMaxCycles) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "movej"); - EXPECT_EQ(kwargs.at("max_cycles").as_int(), 100); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.movej({0,0,0,0,0,0,0}, 1.0, 1.0, 100)); -} - -TEST(ArmMotion, Movel) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "movel"); - EXPECT_DOUBLE_EQ(kwargs.at("speed").as_double(), 0.5); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.movel(LiteArmValue(std::string("goal")), 0.5)); -} - -TEST(ArmMotion, Movec) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "movec"); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.movec( - LiteArmValue(std::string("via")), - LiteArmValue(std::string("goal")), - 0.5)); -} - -TEST(ArmMotion, Movep) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "movep"); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.movep(LiteArmValue(std::string("waypoints")), 0.5)); -} - -TEST(ArmMotion, Hold) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "hold"); - EXPECT_DOUBLE_EQ(kwargs.at("kp_scale").as_double(), 3.0); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.hold(3.0)); -} - -TEST(ArmMotion, ZeroGravity) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "zero_gravity"); - EXPECT_TRUE(kwargs.at("max_cycles").is_null()); - EXPECT_TRUE(kwargs.at("duration_s").is_null()); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.zero_gravity()); -} - -TEST(ArmMotion, JointImpedance) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "joint_impedance"); - EXPECT_DOUBLE_EQ(kwargs.at("engage_sec").as_double(), 0.3); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.joint_impedance( - {0,0,0,0,0,0,0}, - LiteArmValue::from_vec({100,100,100,100,100,100,100}), - LiteArmValue::from_vec({10,10,10,10,10,10,10}))); -} - -TEST(ArmMotion, CartesianImpedance) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "cartesian_impedance"); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.cartesian_impedance( - {0,0,0,0,0,0,0}, - LiteArmValue::from_vec({100,100,100,100,100,100}), - LiteArmValue::from_vec({10,10,10,10,10,10}))); -} - -TEST(ArmMotion, JointFollow) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "joint_follow"); - EXPECT_DOUBLE_EQ(kwargs.at("engage_sec").as_double(), 0.3); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.joint_follow()); -} - -TEST(ArmMotion, ReplayJointPath) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "replay_joint_path"); - EXPECT_TRUE(kwargs.at("goto_start").as_bool()); - EXPECT_DOUBLE_EQ(kwargs.at("goto_speed").as_double(), 0.3); - return LiteArmValue(true); - }); - - std::vector> path = { - {0,0,0,0,0,0,0}, - {0.1,0.1,0.1,0.1,0.1,0.1,0.1}, - }; - EXPECT_TRUE(mock.arm.replay_joint_path(path)); -} - -TEST(ArmMotion, ReplayTrajectory) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "replay_trajectory"); - return LiteArmValue(true); - }); - - // Use raw LiteArmValue - LiteArmValue::MapType traj; - traj["name"] = LiteArmValue(std::string("test_traj")); - traj["schema"] = LiteArmValue(std::string("pylitearm.joint_trajectory.v1")); - - LiteArmValue::ListType frames; - for (int i = 0; i < 2; ++i) { - LiteArmValue::MapType f; - f["t"] = LiteArmValue(static_cast(i)); - f["q"] = LiteArmValue::from_vec({0,0,0,0,0,0,0}); - frames.push_back(LiteArmValue(std::move(f))); - } - traj["frames"] = LiteArmValue(std::move(frames)); - - EXPECT_TRUE(mock.arm.replay_trajectory(LiteArmValue(std::move(traj)))); -} - -// ── Record trajectory ─────────────────────────────────────────────────────── - -TEST(ArmRecord, RecordTrajectory) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "record_trajectory"); - EXPECT_EQ(kwargs.at("output").as_string(), "trajectories"); - EXPECT_DOUBLE_EQ(kwargs.at("sample_rate_hz").as_double(), 100.0); - - LiteArmValue::MapType result; - result["schema"] = LiteArmValue(std::string("pylitearm.joint_trajectory.v1")); - result["name"] = LiteArmValue(std::string("my_recording")); - result["created_at"] = LiteArmValue(std::string("2024-01-01T00:00:00Z")); - result["sample_rate_hz"] = LiteArmValue(100.0); - result["filter_alpha"] = LiteArmValue(nullptr); - result["robot_serial"] = LiteArmValue(nullptr); - result["config_checksum_sha256"] = LiteArmValue(nullptr); - - LiteArmValue::ListType frames; - for (int i = 0; i < 3; ++i) { - LiteArmValue::MapType f; - f["t"] = LiteArmValue(static_cast(i) * 0.01); - f["q"] = LiteArmValue::from_vec({0,0,0,0,0,0,0}); - frames.push_back(LiteArmValue(std::move(f))); - } - result["frames"] = LiteArmValue(std::move(frames)); - - return LiteArmValue(std::move(result)); - }); - - auto traj = mock.arm.record_trajectory(); - EXPECT_EQ(traj.name, "my_recording"); - EXPECT_EQ(traj.frames.size(), 3u); - EXPECT_DOUBLE_EQ(traj.sample_rate_hz.value(), 100.0); -} - -// ── Request stop ──────────────────────────────────────────────────────────── - -TEST(ArmStop, RequestStopPublishesEstop) { - auto mock = make_mock_arm("armA", [](const std::string&, const std::map&) { - return LiteArmValue(nullptr); - }); - - mock.arm.request_stop(); - - auto msg = mock.estop_sub->try_recv(); - ASSERT_TRUE(msg.has_value()); - EXPECT_TRUE(decode_estop(*msg)); -} - -TEST(ArmStop, ClearStop) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map&) { - EXPECT_EQ(method, "clear_stop"); - return LiteArmValue(nullptr); - }); - - mock.arm.clear_stop(); -} - -TEST(ArmStop, Enable) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map&) { - EXPECT_EQ(method, "enable"); - return LiteArmValue(nullptr); - }); - - mock.arm.enable(); -} - -TEST(ArmStop, Disable) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map&) { - EXPECT_EQ(method, "disable"); - return LiteArmValue(nullptr); - }); - - mock.arm.disable(); -} - -// ── Get state ─────────────────────────────────────────────────────────────── - -TEST(ArmState, NoBroadcastReturnsNullopt) { - auto mock = make_mock_arm("armA", [](const std::string&, const std::map&) { - return LiteArmValue(nullptr); - }); - - EXPECT_FALSE(mock.arm.get_state().has_value()); -} - -TEST(ArmState, SingleBroadcast) { - auto mock = make_mock_arm("armA", [](const std::string&, const std::map&) { - return LiteArmValue(nullptr); - }); - - // Publish a state broadcast - RobotState state; - state.q = {0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7}; - state.dq = {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; - state.tau = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0}; - state.state = "ready"; - state.robot_serial = "SN001"; - mock.tp->pub(state_topic("armA"), encode_state(state)); - - auto result = mock.arm.get_state(); - ASSERT_TRUE(result.has_value()); - EXPECT_EQ(result->state, "ready"); - EXPECT_EQ(result->q.size(), 7u); - EXPECT_DOUBLE_EQ(result->q[0], 0.1); - EXPECT_EQ(result->robot_serial, "SN001"); -} - -TEST(ArmState, MultipleBroadcastsReturnsLatest) { - auto mock = make_mock_arm("armA", [](const std::string&, const std::map&) { - return LiteArmValue(nullptr); - }); - - for (const char* state_name : {"ready", "moving", "holding"}) { - RobotState state; - state.state = state_name; - mock.tp->pub(state_topic("armA"), encode_state(state)); - } - - auto result = mock.arm.get_state(); - ASSERT_TRUE(result.has_value()); - EXPECT_EQ(result->state, "holding"); -} - -// ── Parameter tuning ──────────────────────────────────────────────────────── - -TEST(ArmParams, SetGains) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "set_gains"); - EXPECT_DOUBLE_EQ(kwargs.at("kp").as_double(), 100.0); - LiteArmValue::MapType result; - result["kp"] = LiteArmValue(100.0); - result["kd"] = LiteArmValue(10.0); - return LiteArmValue(std::move(result)); - }); - - auto result = mock.arm.set_gains(LiteArmValue(100.0)); - EXPECT_DOUBLE_EQ(result.as_map().at("kp").as_double(), 100.0); -} - -TEST(ArmParams, GetGains) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map&) { - EXPECT_EQ(method, "get_gains"); - LiteArmValue::MapType result; - result["kp"] = LiteArmValue(200.0); - result["kd"] = LiteArmValue(20.0); - return LiteArmValue(std::move(result)); - }); - - auto result = mock.arm.get_gains(); - EXPECT_DOUBLE_EQ(result.as_map().at("kp").as_double(), 200.0); -} - -TEST(ArmParams, ClearFaults) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map&) { - EXPECT_EQ(method, "clear_faults"); - LiteArmValue::ListType faults; - LiteArmValue::ListType pair; - pair.push_back(LiteArmValue(int64_t(3))); - pair.push_back(LiteArmValue(int64_t(1))); - faults.push_back(LiteArmValue(std::move(pair))); - return LiteArmValue(std::move(faults)); - }); - - auto result = mock.arm.clear_faults(); - EXPECT_EQ(result.as_list().size(), 1u); -} - -TEST(ArmParams, SetPayload) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "set_payload"); - EXPECT_DOUBLE_EQ(kwargs.at("mass").as_double(), 1.5); - auto com = kwargs.at("com").to_vec(); - EXPECT_EQ(com.size(), 3u); - EXPECT_DOUBLE_EQ(com[0], 0.01); - LiteArmValue::MapType result; - result["mass"] = LiteArmValue(1.5); - return LiteArmValue(std::move(result)); - }); - - auto result = mock.arm.set_payload(1.5, {0.01, 0.02, 0.03}); - EXPECT_DOUBLE_EQ(result.as_map().at("mass").as_double(), 1.5); -} - -TEST(ArmParams, GetPayload) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map&) { - EXPECT_EQ(method, "get_payload"); - LiteArmValue::MapType result; - result["mass"] = LiteArmValue(0.0); - return LiteArmValue(std::move(result)); - }); - - auto result = mock.arm.get_payload(); - EXPECT_DOUBLE_EQ(result.as_map().at("mass").as_double(), 0.0); -} - -TEST(ArmParams, SetInstallation) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "set_installation"); - EXPECT_TRUE(kwargs.at("gravity").is_null()); - LiteArmValue::MapType result; - result["base_rpy"] = LiteArmValue::from_vec({0.0, 0.0, 0.0}); - return LiteArmValue(std::move(result)); - }); - - std::vector rpy = {0.0, 0.0, 0.0}; - auto result = mock.arm.set_installation(rpy); - EXPECT_TRUE(result.is_map()); -} - -TEST(ArmParams, GetInstallation) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map&) { - EXPECT_EQ(method, "get_installation"); - LiteArmValue::MapType result; - result["base_rpy"] = LiteArmValue::from_vec({0.0, 0.0, 0.0}); - return LiteArmValue(std::move(result)); - }); - - auto result = mock.arm.get_installation(); - EXPECT_TRUE(result.is_map()); -} - -TEST(ArmParams, GetTcpPose) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map&) { - EXPECT_EQ(method, "get_tcp_pose"); - LiteArmValue::ListType result; - result.push_back(LiteArmValue::from_vec({0.5, 0.0, 0.3})); - result.push_back(LiteArmValue::from_mat({ - {1,0,0},{0,1,0},{0,0,1} - })); - return LiteArmValue(std::move(result)); - }); - - auto [pos, rot] = mock.arm.get_tcp_pose(); - EXPECT_EQ(pos.size(), 3u); - EXPECT_DOUBLE_EQ(pos[0], 0.5); - EXPECT_EQ(rot.size(), 3u); -} - -// ── Error handling ────────────────────────────────────────────────────────── - -TEST(ArmError, SafetyViolationPropagates) { - auto tp = std::make_shared(); - - // Register a handler that always returns a safety error - tp->declare_queryable(rpc_topic("armA"), - [](const std::string&) -> std::string { - std::map details; - details["motor"] = "J3"; - details["temp"] = "85"; - return encode_reply_error("SafetyViolationError", "overtemp", details); - }); - - Arm arm("unused", "armA", tp); - - try { - arm.movej({0,0,0,0,0,0,0}); - FAIL() << "Expected SafetyViolationError"; - } catch (const SafetyViolationError& e) { - EXPECT_STREQ(e.what(), "overtemp"); - EXPECT_EQ(e.details.at("motor"), "J3"); - } -} - -// ── Lifecycle ─────────────────────────────────────────────────────────────── - -TEST(ArmLifecycle, ArmIdAccessor) { - auto mock = make_mock_arm("armB", [](const std::string&, const std::map&) { - return LiteArmValue(nullptr); - }); - EXPECT_EQ(mock.arm.arm_id(), "armB"); -} - -TEST(ArmLifecycle, CloseIsIdempotent) { - auto mock = make_mock_arm("armA", [](const std::string&, const std::map&) { - return LiteArmValue(nullptr); - }); - mock.arm.close(); - mock.arm.close(); // Should not crash -} - -// ── Unified params ────────────────────────────────────────────────────────── - -TEST(ArmParams, MovejCollisionRecovery) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "movej"); - EXPECT_TRUE(kwargs.at("allow_start_collision_recovery").as_bool()); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.movej({0,0,0,0,0,0,0}, 1.0, 1.0, 100, true)); -} - -TEST(ArmParams, ZeroGravityNewParams) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "zero_gravity"); - EXPECT_DOUBLE_EQ(kwargs.at("measured_overspeed_factor").as_double(), 1.5); - auto vel_max = kwargs.at("vel_max").to_vec(); - EXPECT_EQ(vel_max.size(), 7u); - if (vel_max.size() == 7u) { - EXPECT_DOUBLE_EQ(vel_max[0], 0.5); - } - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.zero_gravity(std::nullopt, std::nullopt, 1.5, - std::vector{0.5,0.5,0.5,0.5,0.5,0.5,0.5})); -} - -TEST(ArmParams, CartesianImpedanceNewParams) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "cartesian_impedance"); - EXPECT_DOUBLE_EQ(kwargs.at("sigma_min_thresh").as_double(), 0.01); - EXPECT_DOUBLE_EQ(kwargs.at("max_ori_err").as_double(), 0.2); - EXPECT_DOUBLE_EQ(kwargs.at("measured_overspeed_factor").as_double(), 1.5); - auto vel_max = kwargs.at("vel_max").to_vec(); - EXPECT_EQ(vel_max.size(), 6u); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.cartesian_impedance( - {0,0,0,0,0,0,0}, - LiteArmValue::from_vec({100,100,100,100,100,100}), - LiteArmValue::from_vec({10,10,10,10,10,10}), - std::nullopt, std::nullopt, 0.3, std::nullopt, - 0.01, 0.2, 1.5, - std::vector{1,1,1,1,1,1})); -} - -TEST(ArmParams, RecoverJointLimits) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "recover_joint_limits"); - EXPECT_DOUBLE_EQ(kwargs.at("speed").as_double(), 0.05); - EXPECT_DOUBLE_EQ(kwargs.at("settle_s").as_double(), 0.5); - EXPECT_DOUBLE_EQ(kwargs.at("inset_rad").as_double(), 0.1); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.recover_joint_limits(0.05, 0.5, std::nullopt, 0.1)); -} - -TEST(ArmMotion, ReplayTimedTrajectory) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "replay_timed_trajectory"); - auto traj_q = kwargs.at("traj_q").to_mat(); - EXPECT_EQ(traj_q.size(), 2u); - if (traj_q.size() == 2u) { - EXPECT_EQ(traj_q[0].size(), 7u); - } - auto traj_t = kwargs.at("traj_t").to_vec(); - EXPECT_EQ(traj_t.size(), 2u); - EXPECT_DOUBLE_EQ(kwargs.at("simplify_tolerance_rad").as_double(), 0.01); - return LiteArmValue(true); - }); - - std::vector> traj_q = { - {0,0,0,0,0,0,0}, - {0.1,0.1,0.1,0.1,0.1,0.1,0.1}, - }; - EXPECT_TRUE(mock.arm.replay_timed_trajectory(traj_q, {0.0, 1.0})); -} - -TEST(ArmMotion, PlayTrajectoryString) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "play_trajectory"); - EXPECT_EQ(kwargs.at("trajectory").as_string(), "trajectories/traj_001.json"); - EXPECT_TRUE(kwargs.at("verify_robot").as_bool()); - return LiteArmValue(true); - }); - - EXPECT_TRUE(mock.arm.play_trajectory("trajectories/traj_001.json")); -} - -// ── Server extension RPCs ──────────────────────────────────────────────────── - -namespace { - -LiteArmValue ok_map() { - LiteArmValue::MapType m; - m["ok"] = LiteArmValue(true); - return LiteArmValue(std::move(m)); -} - -} // namespace - -TEST(ArmExtension, GetSystemStats) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map&) { - EXPECT_EQ(method, "get_system_stats"); - LiteArmValue::MapType m; - m["cpu_percent"] = LiteArmValue(12.3); - m["uptime_seconds"] = LiteArmValue(int64_t(42)); - return LiteArmValue(std::move(m)); - }); - - auto result = mock.arm.get_system_stats(); - EXPECT_DOUBLE_EQ(result.as_map().at("cpu_percent").as_double(), 12.3); -} - -TEST(ArmExtension, GetLogs) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - EXPECT_EQ(method, "get_logs"); - EXPECT_EQ(kwargs.at("page").as_int(), 2); - EXPECT_EQ(kwargs.at("size").as_int(), 10); - EXPECT_EQ(kwargs.at("search").as_string(), "movej"); - return ok_map(); - }); - - auto result = mock.arm.get_logs(2, 10, "movej"); - EXPECT_TRUE(result.as_map().at("ok").as_bool()); -} - -TEST(ArmExtension, RestartService) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map&) { - EXPECT_EQ(method, "restart_service"); - return ok_map(); - }); - - EXPECT_TRUE(mock.arm.restart_service().as_map().at("ok").as_bool()); -} - -TEST(ArmExtension, SettingsRoundtrip) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - if (method == "get_joint_limits" || method == "get_zero_offsets" || - method == "get_end_effector" || method == "get_cartesian_limits" || - method == "get_collision_config") { - return LiteArmValue(LiteArmValue::MapType{}); - } - // set_* methods echo the payload back - if (kwargs.count("limits")) return kwargs.at("limits"); - if (kwargs.count("offsets")) return kwargs.at("offsets"); - if (kwargs.count("config")) return kwargs.at("config"); - return ok_map(); - }); - - EXPECT_TRUE(mock.arm.get_joint_limits().is_map()); - LiteArmValue::MapType limits; - limits["q_max"] = LiteArmValue::from_vec({3.0,3.0,3.0,3.0,3.0,3.0,3.0}); - auto echoed = mock.arm.set_joint_limits(LiteArmValue(limits)); - EXPECT_DOUBLE_EQ(echoed.as_map().at("q_max").to_vec()[0], 3.0); - - EXPECT_TRUE(mock.arm.set_zero_offsets(ok_map()).is_map()); - EXPECT_TRUE(mock.arm.set_end_effector(ok_map()).is_map()); - EXPECT_TRUE(mock.arm.set_cartesian_limits(ok_map()).is_map()); - EXPECT_TRUE(mock.arm.set_collision_config(ok_map()).is_map()); -} - -TEST(ArmExtension, TrajectoryCrud) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - if (method == "list_trajectories") { - LiteArmValue::MapType m; - m["total"] = LiteArmValue(int64_t(1)); - return LiteArmValue(std::move(m)); - } - if (method == "save_trajectory") { - EXPECT_EQ(kwargs.at("id").as_string(), "t1"); - EXPECT_EQ(kwargs.at("name").as_string(), "demo"); - EXPECT_EQ(kwargs.at("points").to_mat().size(), 2u); - return ok_map(); - } - if (method == "delete_trajectory") { - EXPECT_EQ(kwargs.at("id").as_string(), "t1"); - return ok_map(); - } - return ok_map(); - }); - - EXPECT_TRUE(mock.arm.start_recording().is_map()); - EXPECT_TRUE(mock.arm.stop_recording().is_map()); - EXPECT_TRUE(mock.arm.discard_recording().is_map()); - EXPECT_TRUE(mock.arm.get_recording_state().is_map()); - EXPECT_TRUE(mock.arm.get_playback_state().is_map()); - EXPECT_EQ(mock.arm.list_trajectories().as_map().at("total").as_int(), 1); - - std::vector> points = { - {0,0,0,0,0,0,0}, - {0.1,0.1,0.1,0.1,0.1,0.1,0.1}, - }; - EXPECT_TRUE(mock.arm.save_trajectory("t1", "demo", points).as_map().at("ok").as_bool()); - EXPECT_TRUE(mock.arm.delete_trajectory("t1").as_map().at("ok").as_bool()); -} - -TEST(ArmExtension, DeviceControl) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - if (method == "list_device_types") { - return LiteArmValue(LiteArmValue::ListType{}); - } - if (method == "connect_device") { - EXPECT_EQ(kwargs.at("category").as_string(), "hand"); - EXPECT_EQ(kwargs.at("subtype").as_string(), "lite6_hand"); - EXPECT_EQ(kwargs.at("device_id").as_string(), "end_0"); - return ok_map(); - } - if (method == "disconnect_device" || method == "get_active_device") { - EXPECT_EQ(kwargs.at("device_id").as_string(), "end_0"); - return ok_map(); - } - return ok_map(); - }); - - EXPECT_TRUE(mock.arm.list_device_types().is_list()); - EXPECT_TRUE(mock.arm.connect_device("hand", "lite6_hand").as_map().at("ok").as_bool()); - EXPECT_TRUE(mock.arm.disconnect_device().as_map().at("ok").as_bool()); - EXPECT_TRUE(mock.arm.get_active_device().as_map().at("ok").as_bool()); -} - -TEST(ArmExtension, Teleop) { - auto mock = make_mock_arm("armA", [](const std::string& method, const std::map& kwargs) { - if (method == "enter_teleop") { - EXPECT_EQ(kwargs.at("mode").as_string(), "slave"); - EXPECT_EQ(kwargs.at("peer").as_string(), "tcp/10.0.0.1:7447"); - return ok_map(); - } - return ok_map(); - }); - - std::map params; - params["peer"] = LiteArmValue(std::string("tcp/10.0.0.1:7447")); - EXPECT_TRUE(mock.arm.enter_teleop("slave", params).as_map().at("ok").as_bool()); - EXPECT_TRUE(mock.arm.exit_teleop().is_map()); - EXPECT_TRUE(mock.arm.get_teleop_status().is_map()); -} - -} // namespace -} // namespace litearm diff --git a/tests/test_arm_assembly.cpp b/tests/test_arm_assembly.cpp new file mode 100644 index 0000000..b8318fb --- /dev/null +++ b/tests/test_arm_assembly.cpp @@ -0,0 +1,250 @@ +// Arm 的会话装配 —— 握手/版本约定/幂等 connect/reconnect/收尾/终态。 +#include "test_support.hpp" + +using namespace litearm; + +namespace { +int count_cmd(const testing::FakeTransport& t, uint8_t cmd) { + int n = 0; + for (const auto& kv : t.tx_snapshot()) { + if (kv.first == cmd) ++n; + } + return n; +} +} // namespace + +TEST(assembly_connect_handshakes_and_fills_in_the_session_facts) { + lt::Offline off("Litearm1.7.0-7J", 7); + CHECK_EQ(off->firmware(), std::string("Litearm1.7.0-7J")); + CHECK_EQ(off->n(), 7); + CHECK(off->fw_version().has_value()); + if (off->fw_version()) { + CHECK_EQ(off->fw_version()->major, 1); + CHECK_EQ(off->fw_version()->minor, 7); + CHECK_EQ(off->fw_version()->patch, 0); + } + // 握手期间确实发过 GET_FIRMWARE + CHECK_EQ(count_cmd(off.t(), proto::CMD_GET_FIRMWARE), 1); +} + +TEST(assembly_connect_works_on_the_single_axis_bench_firmware) { + lt::Offline off("Litearm1.7.0-1J", 1); + CHECK_EQ(off->n(), 1); + CHECK_EQ(off->firmware(), std::string("Litearm1.7.0-1J")); +} + +TEST(assembly_rejects_a_legacy_version_string) { + lt::Unconnected un("A1.2.3-USB"); + CHECK_THROWS_AS(un.arm->connect(), FirmwareMismatchError); + // 失败路径必须关链路, 不留半开的会话 + CHECK(!un.arm->firmware().size()); + CHECK_EQ(un.fake->closed, true); +} + +TEST(assembly_rejects_too_old_firmware) { + lt::Unconnected un("Litearm1.4.9-7J"); + CHECK_THROWS_AS(un.arm->connect(), FirmwareMismatchError); +} + +TEST(assembly_accepts_exactly_the_minimum_firmware) { + lt::Offline off("Litearm1.5.0-7J", 7); + CHECK_EQ(off->firmware(), std::string("Litearm1.5.0-7J")); +} + +TEST(assembly_connect_is_idempotent_for_the_same_target) { + lt::Offline off; + // 重复调用最不该打断的正是正在跑的那个会话 —— 不关链路、不重建、不重新握手。 + CHECK_EQ(count_cmd(off.t(), proto::CMD_GET_FIRMWARE), 1); + off.arm->connect(); + off.arm->connect(); + CHECK_EQ(count_cmd(off.t(), proto::CMD_GET_FIRMWARE), 1); + CHECK_EQ(off->n(), 7); +} + +TEST(assembly_reconnect_always_rebuilds) { + lt::Offline off; + CHECK_EQ(count_cmd(off.t(), proto::CMD_GET_FIRMWARE), 1); + off.arm->reconnect(); + CHECK_EQ(count_cmd(off.t(), proto::CMD_GET_FIRMWARE), 2); +} + +TEST(assembly_connect_to_another_port_really_retargets) { + // 幂等不是"永远不做事": 显式给了另一个端口时真的改靶 —— 否则调用方以为连的是 ACM2, + // 实际还在 ACM1, 而本模块一贯不接受"静默无效"。 + auto a = std::make_shared("portA", 0.2, "Litearm1.7.0-7J", 7); + auto b = std::make_shared("portB", 0.2, "Litearm1.7.0-7J", 7); + ArmOptions opts; + opts.port = "portA"; + opts.transport_factory = [a, b](const std::string& p) -> std::unique_ptr { + if (p == "portB") return std::unique_ptr(new lt::SharedFake(b)); + return std::unique_ptr(new lt::SharedFake(a)); + }; + opts.move_timeout = 1.0; + Arm arm(opts); + arm.connect(); + CHECK_EQ(a->tx_count() > 0, true); + arm.connect("portB"); + CHECK_EQ(b->tx_count() > 0, true); + CHECK_EQ(count_cmd(*b, proto::CMD_GET_FIRMWARE), 1); + arm.close(); +} + +TEST(assembly_close_is_idempotent_and_clears_session_facts) { + lt::Offline off; + off.arm->close(); + CHECK(!off.arm->firmware().size()); + CHECK_EQ(off.arm->n(), 0); + CHECK(!off.arm->fw_version().has_value()); + CHECK_NOTHROW(off.arm->close()); + CHECK_NOTHROW(off.arm->close()); +} + +TEST(assembly_disconnect_is_close) { + lt::Offline off; + off.arm->disconnect(); + CHECK_EQ(off.arm->n(), 0); + CHECK_THROWS_AS(off.arm->get_tcp(), NotConnectedError); +} + +TEST(assembly_after_close_the_session_is_unusable) { + lt::Offline off; + off.arm->close(); + CHECK_THROWS_AS(off.arm->get_state(), NotConnectedError); + CHECK_THROWS_AS(off.arm->movej(std::vector(7, 0.0)), NotConnectedError); + CHECK_THROWS_AS(off.arm->enable(), NotConnectedError); + CHECK_THROWS_AS(off.arm->get_tcp(), NotConnectedError); + CHECK_THROWS_AS(off.arm->ik({0.3, 0, 0.4, 0, 0, 0}), NotConnectedError); +} + +TEST(assembly_unconnected_arm_raises_not_connected_not_something_else) { + lt::Unconnected un; + // 未连接时必须是 NotConnectedError —— 不能因 tr_ 为空退化成 AttributeError 那类。 + CHECK_THROWS_AS(un.arm->get_state(), NotConnectedError); + CHECK_THROWS_AS(un.arm->enable(), NotConnectedError); + CHECK_THROWS_AS(un.arm->get_status_now(), NotConnectedError); + CHECK_THROWS_AS(un.arm->poll_cart(), NotConnectedError); +} + +// ---------------------------------------------------------------- DFU 终态 + +TEST(assembly_dfu_refuses_while_enabled_without_sending_a_byte) { + lt::Offline off; + off->enable(); + const size_t before = off.t().tx_count(); + CHECK_THROWS_AS(off->enter_dfu(), InvalidCommandError); + // 一个字节都不发 + CHECK_EQ(off.t().tx_count(), before); + // 臂与控制链路原样可用 + CHECK_NOTHROW(off->get_state()); +} + +TEST(assembly_dfu_success_makes_the_session_terminal) { + lt::Offline off; + CHECK_NOTHROW(off->enter_dfu()); + // 成功返回后任何走写口/读口的调用都抛终态异常 + CHECK_THROWS_AS(off->get_state(), ArmIsInDfuError); + CHECK_THROWS_AS(off->movej(std::vector(7, 0.0)), ArmIsInDfuError); + CHECK_THROWS_AS(off->get_tcp(), ArmIsInDfuError); + CHECK_THROWS_AS(off->connect(), ArmIsInDfuError); + CHECK_THROWS_AS(off->reconnect(), ArmIsInDfuError); + // 终态不因重连复活 + CHECK_THROWS_AS(off->connect("fake"), ArmIsInDfuError); + // close() 照旧可用 (幂等空操作) —— teardown 在任何状态下都不该抛 + CHECK_NOTHROW(off->close()); +} + +TEST(assembly_dfu_terminal_state_also_blocks_local_precheck_entries) { + // move_js / send_mit / send_mit_all 把 arity/idx 预检排在 _cmd 之前, 而终态下 n 是 0 + // => 预检先炸会报出误导性的 "需 N 个" (N=0), 把人引向一个不存在的 arity bug。 + lt::Offline off; + off->enter_dfu(); + CHECK_THROWS_AS(off->move_js(std::vector{}), ArmIsInDfuError); + CHECK_THROWS_AS(off->send_mit(0, 0, 0, 0, 0, 0), ArmIsInDfuError); + CHECK_THROWS_AS(off->send_mit_all({}, {}, {}, {}, {}), ArmIsInDfuError); + // 纯本地访问器不抛 (它们本来就不碰链路, 那是对的) + CHECK_EQ(off->zero_g_active(), false); + CHECK_NOTHROW(off->last_reset_reason()); +} + +TEST(assembly_dfu_without_the_device_leaving_is_not_terminal) { + // ACK 只表示"已登记", 不表示"会跳" —— 登记的静默撤销点有三个。那条异常是它们的 + // 唯一出口: 说清"什么都没发生", 并让 Arm 保持可用。 + lt::Offline off; + off.t().dfu_vanishes = false; + CHECK_THROWS_AS(off->enter_dfu(0.1), LiteArmError); + // 对象照旧可用 + CHECK_NOTHROW(off->get_state()); + CHECK_EQ(off->n(), 7); +} + +TEST(assembly_dfu_when_armed_pending_firmware_side_rejects) { + // SDK 从状态帧看不到 enable_pending => 本地预检放行到固件, 由固件回 ERR{0x15,0x03}。 + lt::Offline off; + off.t().dfu_armed_pending = true; + try { + off->enter_dfu(); + FAIL("应当被固件拒绝"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.cmd), 0x15); + CHECK_EQ(int(e.code), 0x03); + } + // 那一刻固件还没登记 => 不是"登记被撤销", 对象可用 + CHECK_NOTHROW(off->get_state()); +} + +TEST(assembly_dfu_rom_table_invalid_is_reported_by_code) { + lt::Offline off; + off.t().dfu_rom_table_invalid = true; + try { + off->enter_dfu(); + FAIL("应当被固件拒绝"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.code), 0x02); + } + CHECK_NOTHROW(off->get_state()); +} + +TEST(assembly_last_reset_reason_reads_the_boot_banner) { + lt::Offline off; + // 桩的 text_log 是空的 => 没有开机签名 + CHECK_EQ(off->last_reset_reason(), std::string("")); + // 直接把签名喂进传输的噪声留痕 (真机上它是被当噪声丢掉的字节) + CHECK_NOTHROW(off->last_reset_reason()); +} + +TEST(assembly_bench_model_axis_default_mirrors_the_firmware_header) { + lt::Offline off; + CHECK_EQ(off->bench_model_axis, proto::BENCH_MODEL_AXIS); + CHECK_EQ(off->bench_model_axis, 5); +} + +TEST(assembly_cart_support_is_probed_once_at_connect) { + lt::Offline off; + CHECK(off->cart_supported()); + // 探测走的是空载荷 0x3A, 且探测帧不进 CartPending 队列 (登记了会造出一个永远等不到 + // 0x4E 的悬挂态)。 + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_L), 1); +} + +TEST(assembly_arm_options_are_honoured) { + auto fake = std::make_shared("fake", 0.2, "Litearm1.7.0-7J", 7); + ArmOptions opts; + opts.port = "fake"; + opts.q_tol = 0.123; + opts.dq_tol = 0.456; + opts.arrive_frames = 5; + opts.move_timeout = 7.0; + opts.min_firmware = proto::FirmwareVersion{1, 5, 0, ""}; + auto shared = fake; + opts.transport_factory = [shared](const std::string&) -> std::unique_ptr { + return std::unique_ptr(new lt::SharedFake(shared)); + }; + Arm arm(opts); + CHECK_NEAR(arm.q_tol, 0.123, 1e-12); + CHECK_NEAR(arm.dq_tol, 0.456, 1e-12); + CHECK_EQ(arm.arrive_frames, 5); + CHECK_NEAR(arm.move_timeout, 7.0, 1e-12); + arm.connect(); + CHECK_EQ(arm.n(), 7); + arm.close(); +} diff --git a/tests/test_arm_direct.cpp b/tests/test_arm_direct.cpp deleted file mode 100644 index cabf091..0000000 --- a/tests/test_arm_direct.cpp +++ /dev/null @@ -1,39 +0,0 @@ -#include -#include -#include "litearm/arm.hpp" -#include "litearm/codec.hpp" -#include "litearm/protocol.hpp" -#include "litearm/transport.hpp" - -namespace litearm { -namespace { - -TEST(ArmDirect, SendMitPublishesMitFrame) { - auto tp = std::make_shared(); - auto sub = tp->sub(command_topic("armA")); - Arm arm("", "armA", tp); - arm.send_mit({15.0,15.0,15.0,15.0,15.0,15.0,15.0}, - {2.0,2.0,2.0,2.0,2.0,2.0,2.0}, - {0.1,0.1,0.1,0.1,0.1,0.1,0.1}, - {0.0,0.0,0.0,0.0,0.0,0.0,0.0}, - {0.0,0.0,0.0,0.0,0.0,0.0,0.0}); - auto payload = sub->try_recv(); - ASSERT_TRUE(payload.has_value()); - EXPECT_NE(payload->find("\"mit\""), std::string::npos); - EXPECT_NE(payload->find("\"q_ref\""), std::string::npos); -} - -TEST(ArmDirect, SetGuardsSendsRpc) { - auto tp = std::make_shared(); - tp->declare_queryable(rpc_topic("armA"), [](const std::string& payload) -> std::string { - auto [method, kwargs] = decode_request(payload); - EXPECT_EQ(method, "set_guards"); - EXPECT_EQ(kwargs.at("slew_limit").as_double(), 0.5); - return encode_reply_ok(LiteArmValue()); - }); - Arm arm("", "armA", tp); - arm.set_guards(0.5); -} - -} // namespace -} // namespace litearm \ No newline at end of file diff --git a/tests/test_cart.cpp b/tests/test_cart.cpp new file mode 100644 index 0000000..257f1c4 --- /dev/null +++ b/tests/test_cart.cpp @@ -0,0 +1,549 @@ +// 笛卡尔固件规划 —— FIFO 配对 / 吸收额度 / 三条入口 / 规划失败映射 / 能力探测。 +#include "test_support.hpp" + +using namespace litearm; + +namespace { +int count_cmd(const testing::FakeTransport& t, uint8_t cmd) { + int n = 0; + for (const auto& kv : t.tx_snapshot()) { + if (kv.first == cmd) ++n; + } + return n; +} +const std::array kP1{0.30, 0.0, 0.40, 3.1416, 0.0, 0.0}; +const std::array kP2{0.32, 0.0, 0.42, 3.1416, 0.0, 0.0}; +const std::array kP3{0.34, 0.0, 0.44, 3.1416, 0.0, 0.0}; + +std::vector plan_reply(bool ok, int err, int n_wp, uint32_t us) { + std::vector body{uint8_t(ok ? 1 : 0), uint8_t(err)}; + const auto a = proto::pack_u16le(uint16_t(n_wp)); + const auto b = proto::pack_u32le(us); + body.insert(body.end(), a.begin(), a.end()); + body.insert(body.end(), b.begin(), b.end()); + return body; +} +} // namespace + +// ---------------------------------------------------------------- CartPlan + +TEST(cart_plan_from_reply_parses_the_eight_byte_payload) { + const CartPlan p = CartPlan::from_reply(plan_reply(true, 0, 12, 4700)); + CHECK(p.ok); + CHECK_EQ(p.err, 0); + CHECK_EQ(p.n_wp, 12); + CHECK_EQ(int(p.plan_us), 4700); +} + +TEST(cart_plan_from_reply_rejects_a_short_frame) { + CHECK_THROWS_AS(CartPlan::from_reply(std::vector{1, 0, 0}), TransportError); +} + +TEST(cart_raise_for_plan_maps_every_err_to_its_own_exception) { + CartPlan ok; + ok.ok = true; + CHECK_NOTHROW(raise_for_plan(ok)); // 成功时什么都不做 + + auto mk = [](int err) { + CartPlan p; + p.ok = false; + p.err = err; + return p; + }; + CHECK_THROWS_AS(raise_for_plan(mk(CART_ERR_IK)), IKError); + CHECK_THROWS_AS(raise_for_plan(mk(CART_ERR_COLLINEAR)), CartesianPlanError); + CHECK_THROWS_AS(raise_for_plan(mk(CART_ERR_TOO_LONG)), CartesianPlanError); + CHECK_THROWS_AS(raise_for_plan(mk(CART_ERR_LIMIT)), CartesianPlanError); + CHECK_THROWS_AS(raise_for_plan(mk(CART_ERR_BADARG)), InvalidCommandError); + // 接管是**预期内的**, 必须与"规划失败"分开: 混进通用异常会让调用方走故障恢复。 + CHECK_THROWS_AS(raise_for_plan(mk(CART_ERR_CANCELED)), MotionSupersededError); + // 未登记的 err 也归到 CartesianPlanError, 但消息里带上原始码 + try { + raise_for_plan(mk(99)); + FAIL("应当抛异常"); + } catch (const CartesianPlanError& e) { + CHECK(std::string(e.what()).find("99") != std::string::npos); + } +} + +TEST(cart_start_tolerances_track_the_move_p_defaults) { + // move_c 的起点校验与 move_p 的到位判据必须是**同一把尺子**, 否则 move_p 收工的位置 + // 会被 move_c 判成"起点不一致"。 + CHECK_NEAR(CART_START_POS_TOL, 0.006, 1e-12); + CHECK_NEAR(CART_START_RPY_TOL, 0.03, 1e-12); + lt::Offline off; + (void)off; +} + +// ---------------------------------------------------------------- 三条入口 + +TEST(cart_move_l_happy_path_plans_and_settles) { + lt::Offline off; + const CartPlan plan = off->move_l(kP2, 0.5); + CHECK(plan.ok); + CHECK_EQ(plan.n_wp, 12); + CHECK_EQ(int(plan.plan_us), 4700); + // wait=true => 四个收尾字段填真值 + CHECK(plan.started_busy); // 桩在 0x4E 之后的第 2..7 帧置 bit10 + CHECK(plan.settled); // 停稳 + 回读 TCP 与目标对得上 + CHECK_EQ(int(plan.q_final.size()), 7); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_L), 2); // 探测 1 次 + 本次 1 次 +} + +TEST(cart_move_l_payload_is_pose_then_speed) { + lt::Offline off; + off->move_l(kP2, 0.25); + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first != proto::CMD_MOVE_L || kv.second.empty()) continue; + if (kv.second.size() != 28) continue; // 跳过探测那条空载荷 + const auto v = proto::unpack_f32s(kv.second.data(), 0, 6); + CHECK_NEAR(v[0], 0.32, 1e-6); + const auto sp = proto::unpack_f32s(kv.second.data(), 24, 1); + CHECK_NEAR(sp[0], 0.25, 1e-6); + } +} + +TEST(cart_move_l_wait_false_only_waits_for_the_plan) { + lt::Offline off; + const CartPlan plan = off->move_l(kP2, 0.5, /*wait=*/false); + CHECK(plan.ok); + // 这四个字段不是"没到位", 而是"没等" + CHECK(!plan.started_busy); + CHECK(!plan.settled); + CHECK(plan.q_final.empty()); + CHECK_NEAR(plan.settle_err_rad, 0.0, 1e-12); +} + +TEST(cart_move_l_validates_speed_and_pose) { + lt::Offline off; + CHECK_THROWS_AS(off->move_l(kP2, 1.5), InvalidCommandError); + CHECK_THROWS_AS(off->move_l(kP2, -0.1), InvalidCommandError); + CHECK_NOTHROW(off->move_l(kP2, 0.0)); + CHECK_NOTHROW(off->move_l(kP2, 1.0)); + // 位姿形态非法 -> InvalidCommandError (文案里带实际收到的形状) + rot::Mat3 bad{}; + CHECK_THROWS_AS(off->move_l(rot::PoseInput::from_pos_rot({0, 0, 0}, bad)), + InvalidCommandError); +} + +TEST(cart_move_l_accepts_all_three_pose_forms) { + lt::Offline off; + CHECK_NOTHROW(off->move_l(kP2)); + const auto R = rot::rpy_to_mat({3.1416, 0.0, 0.0}); + CHECK_NOTHROW(off->move_l(rot::PoseInput::from_pos_rot( + rot::Vec3{0.32, 0.0, 0.42}, R))); + rot::Mat4 M{}; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) M[size_t(i)][size_t(j)] = R[size_t(i)][size_t(j)]; + } + M[0][3] = 0.32; + M[1][3] = 0.0; + M[2][3] = 0.42; + M[3][3] = 1.0; + CHECK_NOTHROW(off->move_l(M)); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_L), 4); // 探测 + 三次 +} + +TEST(cart_move_c_checks_the_start_against_the_measured_tcp) { + lt::Offline off; + off.t().set_pose({0.30, 0.0, 0.40, 3.1416, 0.0, 0.0}); + // 起点与实测 TCP 一致 -> 放行 + CHECK_NOTHROW(off->move_c(kP1, kP2, kP3)); + // 起点对不上 -> 响亮拒绝 (固件圆弧的起点恒为实测 TCP) + const std::array far{9.0, 9.0, 9.0, 0.0, 0.0, 0.0}; + try { + off->move_c(far, kP2, kP3); + FAIL("应当拒绝"); + } catch (const InvalidCommandError& e) { + CHECK(std::string(e.what()).find("固件圆弧的起点恒为实测 TCP") != std::string::npos); + } +} + +TEST(cart_move_c_accepts_a_rotation_equivalent_start) { + // 万向锁附近同一个旋转可以给出差很远的 rpy 分量 —— 起点校验必须补旋转等价判定, + // 只抄逐分量会在那时永远判失败。 + lt::Offline off; + const rot::Mat3 R = rot::rpy_to_mat({0.0, 3.14159265 / 2, 0.0}); + // 实测 TCP 报万向锁规范化后的 rpy + const auto canonical = rot::mat_to_rpy(R); + off.t().pose = std::vector{0.3, 0.0, 0.4, canonical[0], canonical[1], + canonical[2]}; + // 调用方给的是同一旋转的另一组 rpy (yaw 加了 2pi) + const std::array start{0.3, 0.0, 0.4, canonical[0], canonical[1], + canonical[2] + 2 * 3.14159265}; + CHECK_NOTHROW(off->move_c(start, kP2, kP3)); +} + +TEST(cart_move_path_sends_begin_add_run_and_targets_the_last_waypoint) { + lt::Offline off; + const CartPlan plan = off->move_path({kP1, kP2, kP3}, 0.5); + CHECK(plan.ok); + CHECK_EQ(count_cmd(off.t(), proto::CMD_CART_BEGIN), 1); + CHECK_EQ(count_cmd(off.t(), proto::CMD_CART_ADD), 3); + CHECK_EQ(count_cmd(off.t(), proto::CMD_CART_RUN), 1); + // BEGIN 的载荷 = n u8 + sp f32 + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first != proto::CMD_CART_BEGIN) continue; + CHECK_EQ(kv.second.size(), size_t(5)); + CHECK_EQ(int(kv.second[0]), 3); + } + // ADD 的载荷 = idx u8 + pose[6] + int idx_seen = 0; + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first != proto::CMD_CART_ADD) continue; + CHECK_EQ(kv.second.size(), size_t(25)); + CHECK_EQ(int(kv.second[0]), idx_seen); + ++idx_seen; + } + // RUN 是空载荷 + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first == proto::CMD_CART_RUN) CHECK(kv.second.empty()); + } +} + +TEST(cart_move_path_rejects_empty_and_over_capacity_paths) { + lt::Offline off; + CHECK_THROWS_AS(off->move_path({}), InvalidCommandError); + // 33 个路点超固件 CART_MAX_GOAL = 32 + const std::vector many(33, rot::PoseInput(kP1)); + CHECK_THROWS_AS(off->move_path(many), InvalidCommandError); + const std::vector exactly_32(32, rot::PoseInput(kP1)); + CHECK_NOTHROW(off->move_path(exactly_32)); +} + +TEST(cart_plan_failures_are_mapped_from_the_0x4e_result) { + struct Case { + int err; + int expect; // 0 = IKError, 1 = CartesianPlanError, 2 = MotionSuperseded, + // 3 = InvalidCommandError + }; + const std::vector cases = {{CART_ERR_IK, 0}, {CART_ERR_COLLINEAR, 1}, + {CART_ERR_TOO_LONG, 1}, {CART_ERR_LIMIT, 1}, + {CART_ERR_CANCELED, 2}, {CART_ERR_BADARG, 3}}; + for (const auto& c : cases) { + lt::Offline off; + off.t().cart_err_override = c.err; + bool matched = false; + try { + off->move_l(kP2, 0.5); + } catch (const IKError&) { + matched = (c.expect == 0); + } catch (const MotionSupersededError&) { + matched = (c.expect == 2); + } catch (const InvalidCommandError&) { + matched = (c.expect == 3); + } catch (const CartesianPlanError&) { + matched = (c.expect == 1); + } + CHECK(matched); + // 规划失败时入口**不返回** CartPlan + } +} + +TEST(cart_unsupported_firmware_fails_closed_without_sending_a_command) { + lt::Offline off; + // 关掉编译开关: 5 条整段不在固件里, 空载荷探测会拿到 ERR{0x3A,0x00} + off.t().cart_supported = false; + lt::Unconnected un; + // 重新连一次让探测看到"不支持" + lt::Offline off2; + (void)off2; + (void)un; + // 更直接的做法: 用一个已经探过且为 false 的会话 + auto fake = std::make_shared("fake", 0.2, "Litearm1.7.0-7J", 7); + fake->cart_supported = false; + ArmOptions opts; + opts.port = "fake"; + auto shared = fake; + opts.transport_factory = [shared](const std::string&) -> std::unique_ptr { + return std::unique_ptr(new lt::SharedFake(shared)); + }; + opts.move_timeout = 1.0; + Arm arm(opts); + arm.connect(); + CHECK(!arm.cart_supported()); + const size_t before = fake->tx_count(); + CHECK_THROWS_AS(arm.move_l(kP2, 0.5), UnsupportedByFirmwareError); + // 一个字节都不发 (未确认支持就不发能起规划的命令) + CHECK_EQ(fake->tx_count(), before); + arm.close(); +} + +TEST(cart_probe_silence_is_reported_differently_from_a_confirmed_absence) { + // 探测那一步把两种"不支持"分开了: 一支该换固件, 另一支该查链路。混成一句话会让现场 + // 无法归因。 + auto fake = std::make_shared("fake", 0.2, "Litearm1.7.0-7J", 7); + fake->unknown_cmds.insert(proto::CMD_MOVE_L); // 每次探测都回 ERR{0x3A,0x00} + ArmOptions opts; + opts.port = "fake"; + auto shared = fake; + opts.transport_factory = [shared](const std::string&) -> std::unique_ptr { + return std::unique_ptr(new lt::SharedFake(shared)); + }; + opts.move_timeout = 1.0; + Arm arm(opts); + arm.connect(); + try { + arm.move_l(kP2, 0.5); + FAIL("应当抛异常"); + } catch (const UnsupportedByFirmwareError& e) { + CHECK(std::string(e.what()).find("固件确报不支持") != std::string::npos); + } + arm.close(); +} + +TEST(cart_zero_g_state_rejects_all_three_entry_points_before_registering) { + lt::Offline off; + off->zero_g_start(); + CHECK_THROWS_AS(off->move_l(kP2), InvalidCommandError); + CHECK_THROWS_AS(off->move_c(kP1, kP2, kP3), InvalidCommandError); + CHECK_THROWS_AS(off->move_path({kP1, kP2}), InvalidCommandError); + off->zero_g_stop(); +} + +TEST(cart_poll_cart_returns_nothing_when_no_result_is_waiting) { + lt::Offline off; + CHECK(!off->poll_cart().has_value()); +} + +TEST(cart_poll_cart_claims_a_result_that_nobody_waited_for) { + lt::Offline off; + // wait=false 的入口返回后结果已被 wait 取走 (unclaim) —— 这里手工造一条"没人认领"的: + // 直接往收集器里喂一条 0x4E。 + off.t().push_frame(proto::RSP_CART_PLAN, plan_reply(true, 0, 5, 1234)); + // 读线程会把它交给收集器; 此时队列为空 => 那是"多了一条" => 从读路径炸出来。 + bool caught = false; + try { + for (int i = 0; i < 20 && !caught; ++i) { + off->get_state(true, 0.05); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } catch (const LiteArmError& e) { + caught = true; + CHECK(std::string(e.what()).find("队列为空") != std::string::npos); + } + CHECK(caught); +} + +// ---------------------------------------------------------------- 清队与作废 + +TEST(cart_a_clearing_opcode_invalidates_in_flight_plans) { + // movej/home/estop/disable 这些 opcode 会让固件静默作废在途规划 —— 一条 0x4E 都不发。 + // SDK 侧必须在**发出前**清掉待配队列, 否则下一个 0x4E 一到就错配。 + CartPending cp(cart_absorb_ttl(1.0)); + const auto tok = cp.register_token(); + CHECK_EQ(cp.pending(), 1); + const int n = cp.clear_pending("测试作废"); + CHECK_EQ(n, 1); + CHECK_EQ(cp.pending(), 0); + // 清队唤醒等待者 (标成"结局未知"), 不让它白等到超时 + CHECK_THROWS_AS(cp.wait(tok, 0.01), CartReplyLostError); +} + +TEST(cart_clear_pending_with_nothing_in_flight_does_not_refresh_the_credit) { + // 零重力保活线程每 40ms 发一条清队 opcode (而它几乎永远是空清队)。若空清队也刷新截止, + // 额度就永远不过期 —— 此后一条**真**脱同步的应答会被永久静默吞掉。 + CartPending cp(0.05); // 很短的额度存活时间 + cp.clear_pending(); + cp.clear_pending(); + cp.clear_pending(); + // 等过 TTL, 再喂一条不可归属的 0x4E —— 应该报错而不是被静默吸收 + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + CHECK_THROWS_AS(cp.on_reply(plan_reply(true, 0, 1, 1)), LiteArmError); + CHECK_EQ(int(cp.extra_replies), 1); +} + +TEST(cart_the_queue_pairs_replies_in_arrival_order) { + CartPending cp(1.0); + const auto t1 = cp.register_token(); + const auto t2 = cp.register_token(); + cp.on_reply(plan_reply(true, 0, 1, 1)); // 第一条配给 t1 + cp.on_reply(plan_reply(true, 0, 2, 2)); // 第二条配给 t2 + CHECK(!cp.pending()); + // 等待者按 token 拿到各自那条 (通过 wait 取回原始载荷) + // 注意: wait 会去 unclaim, 但这两条已经被 resolve 且进了待认领列。 + CHECK_EQ(int(CartPlan::from_reply(cp.wait(t1, 0.1)).n_wp), 1); + CHECK_EQ(int(CartPlan::from_reply(cp.wait(t2, 0.1)).n_wp), 2); +} + +TEST(cart_an_unmatched_reply_is_loud_and_counted) { + // 应答比请求多说明固件与主机**已经**错位, 吞掉它就把错位藏了起来。 + CartPending cp(1.0); + try { + cp.on_reply(plan_reply(true, 0, 1, 1)); + FAIL("应当抛异常"); + } catch (const LiteArmError& e) { + CHECK(std::string(e.what()).find("错配") != std::string::npos); + } + CHECK_EQ(int(cp.extra_replies), 1); + // 抛基类而不给它一个专门的异常类型: 没有任何调用方能据它做出正确决定, 也不该被专门 catch。 + CHECK_EQ(int(cp.absorbed_replies), 0); +} + +TEST(cart_clearing_grants_absorb_credits_that_swallow_late_replies) { + // 清队那一刻固件那条规划可能已经跑完、应答早躺在主机 RX 缓冲里 —— 记账被销毁了, + // 但被清掉的条数留下一份额度。 + CartPending cp(1.0); + cp.register_token(); + cp.register_token(); + CHECK_EQ(cp.clear_pending(), 2); + CHECK_NOTHROW(cp.on_reply(plan_reply(true, 0, 1, 1))); + CHECK_NOTHROW(cp.on_reply(plan_reply(true, 0, 1, 1))); + CHECK_EQ(int(cp.absorbed_replies), 2); + // 额度用完了 => 第三条响亮报错 + CHECK_THROWS_AS(cp.on_reply(plan_reply(true, 0, 1, 1)), LiteArmError); +} + +TEST(cart_an_inherited_credit_is_consumed_and_does_not_perpetuate_itself) { + // connect() 重建会话时把旧会话的在途条数当额度继承过来 —— 它至多影响新会话的一条命令, + // 且**不被超时路径续期** (否则此后每一条命令都会报"结局未知")。 + CartPending cp(1.0, /*absorb=*/1); + CHECK_NOTHROW(cp.on_reply(plan_reply(true, 0, 1, 1))); // 吃掉那一格 + CHECK_EQ(int(cp.absorbed_replies), 1); + CHECK_THROWS_AS(cp.on_reply(plan_reply(true, 0, 1, 1)), LiteArmError); +} + +TEST(cart_absorb_credit_expires_lazily) { + CartPending cp(0.05); + cp.register_token(); + cp.clear_pending(); // 一格额度, 存活 0.05s + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + CHECK_THROWS_AS(cp.on_reply(plan_reply(true, 0, 1, 1)), LiteArmError); +} + +TEST(cart_drop_and_absorb_leaves_a_credit_for_a_late_reply) { + CartPending cp(1.0); + const auto tok = cp.register_token(); + cp.drop_and_absorb(tok, /*timed_out=*/false); + CHECK_EQ(cp.pending(), 0); + // 固件其实受理了那条命令 => 它的应答还在路上 => 不该从无关读路径炸出 LiteArmError + CHECK_NOTHROW(cp.on_reply(plan_reply(true, 0, 1, 1))); +} + +TEST(cart_timeout_path_does_not_extend_the_credit_when_one_was_already_consumed) { + // 超时支的唯一例外: 窗口里已经吸收过一条 => 那条就是本条请求的应答, 固件不再欠, + // 故不补额度 (补了就是**自持**: 实测曾让此后每一条命令都报"结局未知")。 + CartPending cp(1.0); + const auto tok = cp.register_token(); // 水位线: absorbed_at = 0 + const auto other = cp.register_token(); + cp.drop_and_absorb(other, /*timed_out=*/false); // 造出一格额度 + // 这一条不可归属的应答被那格额度吸收 (落在 tok 的等待窗口里) + cp.on_reply(plan_reply(true, 0, 1, 1)); + CHECK_EQ(int(cp.absorbed_replies), 1); + // tok 的窗口里确实消费过额度 => 那条就是它自己的应答 => 不补 + cp.drop_and_absorb(tok, /*timed_out=*/true); + // 不补额度 => 下一条不可归属的应答应当响亮报错, 而不是被静默吞掉 + CHECK_THROWS_AS(cp.on_reply(plan_reply(true, 0, 1, 1)), LiteArmError); +} + +TEST(cart_read_link_failure_path_still_grants_a_credit) { + // pump 支 (读链路炸) 根本没等过窗口 => "被吸收的是自己那条"没有依据 => 照旧补一格。 + CartPending cp(1.0); + const auto tok = cp.register_token(); + cp.on_reply(plan_reply(true, 0, 1, 1)); // 消费一格 (给 tok) + cp.drop_and_absorb(tok, /*timed_out=*/false); + // 补了一格 => 下一条应答被吸收而不是报错 + CHECK_NOTHROW(cp.on_reply(plan_reply(true, 0, 1, 1))); +} + +TEST(cart_token_timeout_raises_reply_lost_not_a_plain_timeout) { + // 这里要表达的是"未知结局", 混进通用超时会让调用方按"没生效"去重发。 + CartPending cp(1.0); + const auto tok = cp.register_token(); + try { + cp.wait(tok, 0.05); + FAIL("应当抛异常"); + } catch (const CartReplyLostError& e) { + CHECK(std::string(e.what()).find("结局未知") != std::string::npos); + } catch (const MotionTimeoutError&) { + FAIL("必须是 CartReplyLostError, 不是通用超时"); + } +} + +TEST(cart_the_unclaimed_column_is_bounded) { + // 无界的唯一现实来源是"调用方放弃 token" (ACK 超时路径), 每放弃一条就永久留下一条。 + CartPending cp(1.0); + for (size_t i = 0; i < kUnclaimedMax + 3; ++i) { + cp.register_token(); + cp.on_reply(plan_reply(true, 0, int(i), uint32_t(i))); + } + CHECK_EQ(int(cp.evicted_unclaimed), 3); + // claim_resolved 只交付队首, 且一次一条 + int claimed = 0; + while (cp.claim_resolved()) ++claimed; + CHECK_EQ(claimed, int(kUnclaimedMax)); +} + +TEST(cart_claim_resolved_does_not_repeat_delivery) { + CartPending cp(1.0); + cp.register_token(); + cp.on_reply(plan_reply(true, 0, 7, 1)); + auto t1 = cp.claim_resolved(); + CHECK(t1 != nullptr); + CHECK(cp.claim_resolved() == nullptr); // 同一条结果不会被交付两次 +} + +TEST(cart_wait_removes_the_result_from_the_unclaimed_column) { + CartPending cp(1.0); + const auto tok = cp.register_token(); + cp.on_reply(plan_reply(true, 0, 9, 1)); + CHECK_EQ(int(CartPlan::from_reply(cp.wait(tok, 0.1)).n_wp), 9); + // 已被 wait 取走 => poll_cart 不该再交付一次 + CHECK(cp.claim_resolved() == nullptr); +} + +TEST(cart_drop_removes_the_token_from_both_columns) { + CartPending cp(1.0); + const auto tok = cp.register_token(); + cp.drop(tok); + CHECK_EQ(cp.pending(), 0); + CHECK(cp.claim_resolved() == nullptr); +} + +TEST(cart_request_drops_its_own_token_when_the_write_fails) { + // 契约: write 抛 TransportError 蕴含整帧未送达 => 摘下自己那个 token 才是干净的。 + CartPending cp(1.0); + CHECK_THROWS_AS(cp.request([]() { throw TransportError("未送达"); }), TransportError); + CHECK_EQ(cp.pending(), 0); +} + +TEST(cart_request_keeps_the_token_when_the_failure_cannot_prove_non_delivery) { + // 其余异常证明不了帧没送达 (最现实的是中断落在 flush 之后) => 留在队里: + // 最坏是后续请求报"结局未知" (安全方向), 而摘掉它会让那条应答配给后面的活 token + // = **假成功** (危险方向)。 + CartPending cp(1.0); + CHECK_THROWS_AS(cp.request([]() { throw std::runtime_error("别的东西"); }), + std::runtime_error); + CHECK_EQ(cp.pending(), 1); +} + +// ---------------------------------------------------------------- 常量契约 + +TEST(cart_clears_upon_set_is_exactly_the_twelve_opcodes) { + const std::set expect = {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x11, 0x12, 0x13, 0x14, 0x2A}; + CHECK(cart_clears_upon() == expect); + // 0x3A/0x3B/0x3E **不在**此列 —— 它们欠应答, 而且欠两条。 + CHECK(cart_clears_upon().count(0x3A) == 0); + CHECK(cart_clears_upon().count(0x3B) == 0); + CHECK(cart_clears_upon().count(0x3E) == 0); +} + +TEST(cart_absorb_ttl_has_a_floor_and_only_grows) { + // 额度是"清队/放弃那一刻那条请求可能还欠一条应答"的通行证, 下限是**正确性条件**。 + CHECK_NEAR(cart_absorb_ttl(1.0), kAbsorbTtlFloor, 1e-12); + CHECK_NEAR(cart_absorb_ttl(0.0), kAbsorbTtlFloor, 1e-12); + // 更大的 move_timeout 不该被一条新加的兜底砍短 + CHECK_NEAR(cart_absorb_ttl(30.0), 30.0, 1e-12); +} + +TEST(cart_error_code_constants_match_the_firmware_enum) { + CHECK_EQ(CART_ERR_OK, 0); + CHECK_EQ(CART_ERR_IK, 1); + CHECK_EQ(CART_ERR_COLLINEAR, 2); + CHECK_EQ(CART_ERR_TOO_LONG, 3); + CHECK_EQ(CART_ERR_LIMIT, 4); + CHECK_EQ(CART_ERR_CANCELED, 5); + CHECK_EQ(CART_ERR_BADARG, 6); + CHECK_EQ(CartPlan::ERR_REPLY_LOST, -1); +} diff --git a/tests/test_cli.cpp b/tests/test_cli.cpp new file mode 100644 index 0000000..8957810 --- /dev/null +++ b/tests/test_cli.cpp @@ -0,0 +1,41 @@ +// 简易 CLI (巡检/冒烟) —— 参数解析与错误路径。 +#include "test_support.hpp" + +using namespace litearm; + +TEST(cli_help_exits_zero) { + CHECK_EQ(main_impl({"--help"}), 0); + CHECK_EQ(main_impl({"-h"}), 0); +} + +TEST(cli_unknown_port_is_a_transport_error_not_a_crash) { + // 无硬件 / 端口不存在时应当是那句可读的 TransportError, 而不是别的异常。 + CHECK_THROWS_AS(main_impl({"status", "--port", "/dev/litearm-nope-xyz"}), + TransportError); +} + +TEST(cli_no_hardware_at_all_is_reported_as_such) { + // 本机没接臂且没给 --port => 自动发现失败 => 同一句 TransportError。 + // (若本机真的接了 1d50:606f 的臂, 这条会去连它 —— 那也不是失败, 故用 catch 兜住。) + try { + const int rc = main_impl({"fw", "--port", "/dev/litearm-nope-xyz"}); + CHECK_EQ(rc, 0); + } catch (const TransportError& e) { + CHECK(std::string(e.what()).find("/dev/litearm-nope-xyz") != std::string::npos || + std::string(e.what()).find("STM32 CDC") != std::string::npos); + } +} + +TEST(cli_action_names_are_recognised) { + // 这些 action 都会被解析, 只是没有硬件时连不上 —— 故只断言"连不上"这一步, + // 而不是"参数不认识"。 + for (const char* action : {"status", "fw", "enable", "disable", "reset", + "emergency", "movej", "home", "tcp"}) { + CHECK_THROWS_AS(main_impl({action, "--port", "/dev/litearm-nope-xyz"}), + TransportError); + } +} + +TEST(cli_version_string_is_exposed) { + CHECK_EQ(std::string(version()), std::string("2.1.0")); +} diff --git a/tests/test_clock.cpp b/tests/test_clock.cpp new file mode 100644 index 0000000..3171e31 --- /dev/null +++ b/tests/test_clock.cpp @@ -0,0 +1,92 @@ +// 可注入时钟 (`ArmOptions::clock`)。 +// +// ⚠⚠ **本仓的时钟只管"现在几点", 不管"怎么等"** —— 先读 `include/litearm/clock.hpp` +// 开头那段。要旨: 等待走条件变量的**真实**时间, 只有 deadline/时间戳走注入的钟 +// ⇒ 一个**不会自己前进**的假钟会让"等超时真的发生"的判据**挂死**。 +// 本文件里所有用假钟的用例都只碰**纯比较**类判据, 一条等待路径都不走。 +#include "test_support.hpp" + +using namespace litearm; + +// ---------------------------------------------------------------- 默认 = 真钟 + +TEST(clock_default_is_steady_so_timestamps_are_real) { + // 不注入 ⇒ 进程级 `SteadyClock`。 + // + // ⚠⚠ 判据**不能**写成"时间戳 > 某个常数" —— `steady_clock` 的 epoch 是**开机时刻**, + // 那个常数就变成了"机器开机时长必须超过多少", 于是刚开机的机器上**必红** + // (我第一版写的 `> 1e4` 就是这种: 开机不足 2.8 小时的机器上直接失败)。 + // 正解: 与**同一个时钟的当前值**比 —— 这才是"时间戳来自这个钟"的直接表述, + // 与开机多久无关。 + lt::Offline off; + const auto m = off->get_ff_scalar(1, 0); + CHECK_NEAR(m.timestamp, steady_clock_instance().now_s(), 30.0); + // ⚠ 顺带守住另一个真陷阱: 若默认钟被误换成"从 0 起"的实现, 上面那条立刻红 + // (0 与 steady 的当前值差着整个开机时长)。 +} + +TEST(clock_injection_is_visible_through_options) { + auto clk = testing::FakeClock::make(); + lt::Offline off("Litearm1.7.0-7J", 7, clk); + CHECK(off->options().clock == clk); +} + +TEST(clock_connect_works_under_an_injected_clock) { + // 注入假钟**不许**破坏连接流程: 握手/建软限缓存都要照常走完。 + // (它们之所以能走完, 是因为**应答真的会到** —— 假件的应答是立刻的。 + // 若应答不到, `expect` 会等满 timeout; 而 timeout 在假钟下永远到不了 ⇒ 挂死。 + // 这正是 clock.hpp 里那条警告的具体形状。) + auto clk = testing::FakeClock::make(); + lt::Offline off("Litearm1.7.0-7J", 7, clk); + CHECK_EQ(off->n(), 7); + CHECK_EQ(off->firmware(), std::string("Litearm1.7.0-7J")); + CHECK(off->is_connected()); +} + +// ---------------------------------------------------------------- 假钟的最大价值 + +TEST(clock_fake_clock_makes_the_staleness_judgement_instant_and_deterministic) { + // ★★ 这条是假钟**最大的价值所在**。 + // + // `Arm::is_connected()` 的第二条判据是"状态帧超龄"(`STATUS_STALE_MAX_S` = 2.0s)。 + // 用真钟验它要**真睡满 2 秒**才能翻到 false —— 一条用例 2 秒, 而且是对调度器的断言。 + // 用假钟: 时间由用例**推**, 瞬间到位, 且断言的因果是干净的 (推 3 秒 ⇒ 越阈值)。 + auto clk = testing::FakeClock::make(); + lt::Offline off("Litearm1.7.0-7J", 7, clk); + // ⚠ **必须先把被动流停掉**: 假件每 1ms 还在投递状态帧, 而投递会**刷新时间戳** + // (用当前假钟的值) ⇒ 不停流的话推完时间立刻又被刷新, 判据永远翻不到 false。 + lt::quiesce(off.t()); + + CHECK(off->is_connected()); + clk->advance(1.0); + CHECK(off->is_connected()); // 1.0s < 2.0s: 还在阈值内 + clk->advance(2.0); // 累计 3.0s + CHECK(!off->is_connected()); // 越过阈值 ⇒ 判失联 + // ⚠ 判据是"**纯粹由时钟推进触发**"的: 全程没有真睡, 也没有任何一帧新数据。 +} + +TEST(clock_status_seq_is_not_affected_by_the_clock) { + // 状态帧计数是**到达**的度量, 与时间源无关 —— 别让注入时钟顺带改了它。 + auto clk = testing::FakeClock::make(); + lt::Offline off("Litearm1.7.0-7J", 7, clk); + const uint64_t a = off->status_seq(); + clk->advance(100.0); // 推 100 秒: 一帧都不会因此多出来 + const uint64_t b = off->status_seq(); + CHECK(b >= a); + CHECK(b - a < 100); // 推时间不产帧 +} + +// ---------------------------------------------------------------- 假钟自身的契约 + +TEST(clock_fake_clock_starts_non_zero_and_advances_monotonically) { + // ⚠ 起始值**刻意非 0**: 本仓多处用 `timestamp <= 0` 表示"从没收到过" + // (如 `Arm::is_connected` 里那句 `recv_stats(...).second <= 0.0`)。 + // 假钟若从 0 起, 那类判据会在"明明收到过"时判假 ⇒ 用例测的是一个现实中不存在的状态。 + auto clk = testing::FakeClock::make(); + CHECK(clk->now_s() > 0.0); + const double a = clk->now_s(); + clk->advance(2.5); + CHECK_NEAR(clk->now_s(), a + 2.5, 1e-12); + clk->advance(0.0); // 推进 0 是合法的 no-op + CHECK_NEAR(clk->now_s(), a + 2.5, 1e-12); +} diff --git a/tests/test_codec.cpp b/tests/test_codec.cpp deleted file mode 100644 index b422e94..0000000 --- a/tests/test_codec.cpp +++ /dev/null @@ -1,359 +0,0 @@ -#include -#include "litearm/codec.hpp" -#include "litearm/exceptions.hpp" -#include "litearm/types.hpp" -#include "litearm.pb.h" - -namespace litearm { -namespace { - -// ── Value conversion roundtrip ────────────────────────────────────────────── - -TEST(CodecValue, NullRoundtrip) { - LiteArmValue v(nullptr); - auto pv = to_proto(v); - auto back = from_proto(pv); - EXPECT_TRUE(back.is_null()); -} - -TEST(CodecValue, BoolRoundtrip) { - for (bool b : {true, false}) { - LiteArmValue v(b); - auto pv = to_proto(v); - auto back = from_proto(pv); - EXPECT_TRUE(back.is_bool()); - EXPECT_EQ(back.as_bool(), b); - } -} - -TEST(CodecValue, IntRoundtrip) { - for (int64_t i : {int64_t(0), int64_t(42), int64_t(-1), int64_t(1LL << 40)}) { - LiteArmValue v(i); - auto pv = to_proto(v); - auto back = from_proto(pv); - EXPECT_TRUE(back.is_int()); - EXPECT_EQ(back.as_int(), i); - } -} - -TEST(CodecValue, DoubleRoundtrip) { - for (double d : {0.0, 3.14, -2.718, 1e-10}) { - LiteArmValue v(d); - auto pv = to_proto(v); - auto back = from_proto(pv); - EXPECT_TRUE(back.is_double()); - EXPECT_DOUBLE_EQ(back.as_double(), d); - } -} - -TEST(CodecValue, StringRoundtrip) { - for (const auto& s : {"", "hello", "litearm/v4/armA/rpc"}) { - std::string str(s); - LiteArmValue v(str); - auto pv = to_proto(v); - auto back = from_proto(pv); - EXPECT_TRUE(back.is_string()); - EXPECT_EQ(back.as_string(), str); - } -} - -TEST(CodecValue, ListRoundtrip) { - LiteArmValue::ListType list; - list.push_back(LiteArmValue(1.0)); - list.push_back(LiteArmValue(2.0)); - list.push_back(LiteArmValue(3.0)); - LiteArmValue v(std::move(list)); - - auto pv = to_proto(v); - auto back = from_proto(pv); - EXPECT_TRUE(back.is_list()); - EXPECT_EQ(back.as_list().size(), 3u); - EXPECT_DOUBLE_EQ(back.as_list()[0].as_double(), 1.0); - EXPECT_DOUBLE_EQ(back.as_list()[2].as_double(), 3.0); -} - -TEST(CodecValue, MapRoundtrip) { - LiteArmValue::MapType m; - m["key"] = LiteArmValue(std::string("value")); - m["num"] = LiteArmValue(42.0); - LiteArmValue v(std::move(m)); - - auto pv = to_proto(v); - auto back = from_proto(pv); - EXPECT_TRUE(back.is_map()); - EXPECT_EQ(back.as_map().at("key").as_string(), "value"); - EXPECT_DOUBLE_EQ(back.as_map().at("num").as_double(), 42.0); -} - -TEST(CodecValue, NestedRoundtrip) { - // Nested: list containing maps - LiteArmValue::MapType inner; - inner["name"] = LiteArmValue(std::string("joint_0")); - inner["value"] = LiteArmValue(1.5); - - LiteArmValue::ListType list; - list.push_back(LiteArmValue(std::move(inner))); - LiteArmValue v(std::move(list)); - - auto pv = to_proto(v); - auto back = from_proto(pv); - EXPECT_TRUE(back.is_list()); - EXPECT_EQ(back.as_list()[0].as_map().at("name").as_string(), "joint_0"); -} - -TEST(CodecValue, VecConversion) { - std::vector vec = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0}; - auto v = LiteArmValue::from_vec(vec); - auto back = v.to_vec(); - EXPECT_EQ(back, vec); -} - -TEST(CodecValue, MatConversion) { - std::vector> mat = { - {1.0, 0.0, 0.0}, - {0.0, 1.0, 0.0}, - {0.0, 0.0, 1.0}, - }; - auto v = LiteArmValue::from_mat(mat); - auto back = v.to_mat(); - EXPECT_EQ(back, mat); -} - -TEST(CodecValue, BadCastThrows) { - LiteArmValue v(42.0); - EXPECT_THROW(v.as_bool(), std::bad_cast); - EXPECT_THROW(v.as_string(), std::bad_cast); - EXPECT_THROW(v.as_list(), std::bad_cast); -} - -// ── Request encoding/decoding ─────────────────────────────────────────────── - -TEST(CodecRequest, EmptyKwargs) { - std::map kwargs; - auto payload = encode_request("get_gains", kwargs); - auto [method, decoded] = decode_request(payload); - EXPECT_EQ(method, "get_gains"); - EXPECT_TRUE(decoded.empty()); -} - -TEST(CodecRequest, SimpleKwargs) { - std::map kwargs; - kwargs["speed"] = LiteArmValue(0.5); - kwargs["name"] = LiteArmValue(std::string("test")); - - auto payload = encode_request("movej", kwargs); - auto [method, decoded] = decode_request(payload); - EXPECT_EQ(method, "movej"); - EXPECT_DOUBLE_EQ(decoded.at("speed").as_double(), 0.5); - EXPECT_EQ(decoded.at("name").as_string(), "test"); -} - -TEST(CodecRequest, NestedKwargs) { - std::map kwargs; - kwargs["q_target"] = LiteArmValue::from_vec({0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7}); - kwargs["max_cycles"] = LiteArmValue(nullptr); - - auto payload = encode_request("movej", kwargs); - auto [method, decoded] = decode_request(payload); - EXPECT_EQ(method, "movej"); - auto q = decoded.at("q_target").to_vec(); - EXPECT_EQ(q.size(), 7u); - EXPECT_DOUBLE_EQ(q[0], 0.1); - EXPECT_TRUE(decoded.at("max_cycles").is_null()); -} - -// ── Reply encoding/decoding ───────────────────────────────────────────────── - -TEST(CodecReply, OkNull) { - auto payload = encode_reply_ok(LiteArmValue(nullptr)); - auto result = decode_reply(payload); - EXPECT_TRUE(result.is_null()); -} - -TEST(CodecReply, OkBool) { - auto payload = encode_reply_ok(LiteArmValue(true)); - auto result = decode_reply(payload); - EXPECT_TRUE(result.as_bool()); -} - -TEST(CodecReply, OkList) { - LiteArmValue::ListType list; - list.push_back(LiteArmValue(1.0)); - list.push_back(LiteArmValue(2.0)); - auto payload = encode_reply_ok(LiteArmValue(std::move(list))); - auto result = decode_reply(payload); - EXPECT_EQ(result.as_list().size(), 2u); -} - -TEST(CodecReply, OkMap) { - LiteArmValue::MapType m; - m["kp"] = LiteArmValue(100.0); - m["kd"] = LiteArmValue(10.0); - auto payload = encode_reply_ok(LiteArmValue(std::move(m))); - auto result = decode_reply(payload); - EXPECT_DOUBLE_EQ(result.as_map().at("kp").as_double(), 100.0); -} - -TEST(CodecReply, ErrorLiteArmError) { - auto payload = encode_reply_error("LiteArmError", "something went wrong"); - EXPECT_THROW(decode_reply(payload), LiteArmError); - try { - decode_reply(payload); - } catch (const LiteArmError& e) { - EXPECT_STREQ(e.what(), "something went wrong"); - } -} - -TEST(CodecReply, ErrorSafetyViolation) { - std::map details; - details["joint"] = "3"; - details["reason"] = "overtemp"; - auto payload = encode_reply_error("SafetyViolationError", "safety!", details); - - try { - decode_reply(payload); - FAIL() << "Expected SafetyViolationError"; - } catch (const SafetyViolationError& e) { - EXPECT_STREQ(e.what(), "safety!"); - EXPECT_EQ(e.details.at("joint"), "3"); - EXPECT_EQ(e.details.at("reason"), "overtemp"); - } -} - -TEST(CodecReply, ErrorFeedbackTimeout) { - auto payload = encode_reply_error("FeedbackTimeoutError", "stale feedback"); - try { - decode_reply(payload); - FAIL() << "Expected FeedbackTimeoutError"; - } catch (const FeedbackTimeoutError& e) { - EXPECT_STREQ(e.what(), "stale feedback"); - } -} - -TEST(CodecReply, ErrorMotionCancelled) { - auto payload = encode_reply_error("MotionCancelled", "user stop"); - try { - decode_reply(payload); - FAIL() << "Expected MotionCancelled"; - } catch (const MotionCancelled& e) { - EXPECT_STREQ(e.what(), "user stop"); - } -} - -TEST(CodecReply, ErrorConfigurationError) { - auto payload = encode_reply_error("ConfigurationError", "bad config"); - try { - decode_reply(payload); - FAIL() << "Expected ConfigurationError"; - } catch (const ConfigurationError& e) { - EXPECT_STREQ(e.what(), "bad config"); - } -} - -TEST(CodecReply, ErrorTransportError) { - auto payload = encode_reply_error("TransportError", "can write timeout"); - try { - decode_reply(payload); - FAIL() << "Expected TransportError"; - } catch (const TransportError& e) { - EXPECT_STREQ(e.what(), "can write timeout"); - } -} - -TEST(CodecReply, ErrorUnknownTypeFallsBackToBase) { - auto payload = encode_reply_error("SomeNewError", "unknown error type"); - try { - decode_reply(payload); - FAIL() << "Expected LiteArmError"; - } catch (const LiteArmError& e) { - EXPECT_STREQ(e.what(), "unknown error type"); - } -} - -TEST(CodecReply, ErrorCartesianPlan) { - auto payload = encode_reply_error("CartesianPlanError", "IK failed"); - try { - decode_reply(payload); - FAIL() << "Expected CartesianPlanError"; - } catch (const CartesianPlanError& e) { - EXPECT_STREQ(e.what(), "IK failed"); - EXPECT_EQ(e.index, -1); - } -} - -// ── State encoding/decoding ───────────────────────────────────────────────── - -TEST(CodecState, FullRoundtrip) { - RobotState state; - state.q = {0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7}; - state.dq = {0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; - state.tau = {1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0}; - state.errs = {0, 0, 0, 0, 0, 0, 0}; - state.faults = {{3, 1}}; - state.temps = {{40, 45}, {38, 42}, {41, 44}, {39, 43}, {40, 46}, {37, 41}, {42, 47}}; - state.state = "ready"; - - state.feedback.max_age_s = 0.5; - state.feedback.stale_joints = {2}; - state.feedback.joints.push_back({0, 100, 0.01, true}); - - state.watchdog.enabled = true; - state.watchdog.timeout_s = 1.0; - state.watchdog.mode = "soft"; - state.watchdog.tripped = false; - state.watchdog.last_kick_age_s = 0.1; - - state.robot_serial = "SN12345"; - state.config_checksum_sha256 = "abc123def456"; - - auto payload = encode_state(state); - auto decoded = decode_state(payload); - - EXPECT_EQ(decoded.q, state.q); - EXPECT_EQ(decoded.dq, state.dq); - EXPECT_EQ(decoded.tau, state.tau); - EXPECT_EQ(decoded.errs, state.errs); - EXPECT_EQ(decoded.faults.size(), 1u); - EXPECT_EQ(decoded.faults[0].joint, 3); - EXPECT_EQ(decoded.faults[0].err_code, 1); - EXPECT_EQ(decoded.temps.size(), 7u); - EXPECT_EQ(decoded.temps[0].mos_temp, 40); - EXPECT_EQ(decoded.state, "ready"); - EXPECT_DOUBLE_EQ(decoded.feedback.max_age_s, 0.5); - EXPECT_EQ(decoded.feedback.stale_joints.size(), 1u); - EXPECT_EQ(decoded.feedback.joints.size(), 1u); - EXPECT_TRUE(decoded.watchdog.enabled); - EXPECT_DOUBLE_EQ(decoded.watchdog.timeout_s, 1.0); - EXPECT_EQ(decoded.watchdog.mode, "soft"); - EXPECT_EQ(decoded.robot_serial, "SN12345"); - EXPECT_EQ(decoded.config_checksum_sha256, "abc123def456"); -} - -TEST(CodecState, EmptyState) { - RobotState state; - state.state = "disconnected"; - - auto payload = encode_state(state); - auto decoded = decode_state(payload); - EXPECT_EQ(decoded.state, "disconnected"); - EXPECT_TRUE(decoded.q.empty()); -} - -// ── Estop encoding/decoding ───────────────────────────────────────────────── - -TEST(CodecEstop, TriggerTrue) { - auto payload = encode_estop(true); - EXPECT_TRUE(decode_estop(payload)); -} - -TEST(CodecEstop, TriggerFalse) { - auto payload = encode_estop(false); - EXPECT_FALSE(decode_estop(payload)); -} - -TEST(CodecEstop, InvalidPayloadReturnsFalse) { - EXPECT_FALSE(decode_estop("invalid data")); -} - -} // namespace -} // namespace litearm diff --git a/tests/test_commands.cpp b/tests/test_commands.cpp new file mode 100644 index 0000000..46ab0bc --- /dev/null +++ b/tests/test_commands.cpp @@ -0,0 +1,447 @@ +// Arm 的命令面 —— 使能/安全/关节运动/状态读取/IK/伺服透传。 +#include "test_support.hpp" + +using namespace litearm; + +namespace { +int count_cmd(const testing::FakeTransport& t, uint8_t cmd) { + int n = 0; + for (const auto& kv : t.tx_snapshot()) { + if (kv.first == cmd) ++n; + } + return n; +} +std::vector zeros7() { return std::vector(7, 0.0); } +} // namespace + +// ---------------------------------------------------------------- 使能 / 安全 + +TEST(cmd_enable_and_disable_round_trip) { + lt::Offline off; + CHECK_NOTHROW(off->enable()); + CHECK(off.t().enabled); + CHECK_NOTHROW(off->disable()); + CHECK(!off.t().enabled); +} + +TEST(cmd_enable_retries_only_the_whitelisted_code) { + // 0x03 = "可重试 (反馈未齐 / CMODE 首写…重发即可)" —— 每次重试之间 sleep(0.3)。 + lt::Offline off; + off.t().err_override[proto::CMD_ENABLE] = 0x03; + const double t0 = now_s(); + CHECK_THROWS_AS(off->enable(3), CommandRejectedError); + const double dt = now_s() - t0; + // 3 次尝试 => 2 次 sleep 约 0.6s。这不是"精确计时", 是"确实退避过而不是立刻抛"。 + CHECK(dt >= 0.5); + CHECK_EQ(count_cmd(off.t(), proto::CMD_ENABLE), 3); +} + +TEST(cmd_enable_does_not_retry_latched_or_unknown_codes) { + // 0x06 (锁存须先 RESET) / 0x07 (补写预算耗尽, 重发无用) / 0x00 (固件没这条命令) + // 都明确是"重发无用" —— 黑名单写法会白耗 3.3 秒。 + for (uint8_t code : {uint8_t(0x06), uint8_t(0x07), uint8_t(0x05)}) { + lt::Offline off; + off.t().err_override[proto::CMD_ENABLE] = code; + const double t0 = now_s(); + CHECK_THROWS_AS(off->enable(12), CommandRejectedError); + const double dt = now_s() - t0; + CHECK(dt < 0.25); // 立刻抛, 没有 sleep + CHECK_EQ(count_cmd(off.t(), proto::CMD_ENABLE), 1); // 只发了一次 + } +} + +TEST(cmd_enable_reports_code_and_cmd_for_programmatic_decisions) { + lt::Offline off; + off.t().err_override[proto::CMD_ENABLE] = 0x06; + try { + off->enable(); + FAIL("应当抛异常"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.cmd), 0x10); + CHECK_EQ(int(e.code), 0x06); + // 消息里带得出"该去 reset 还是现场排查" + CHECK(std::string(e.what()).find("EMERGENCY") != std::string::npos); + } +} + +TEST(cmd_enable_unsupported_firmware_is_its_own_type) { + lt::Offline off; + off.t().unknown_cmds.insert(proto::CMD_ENABLE); + try { + off->enable(3); + FAIL("应当抛异常"); + } catch (const UnsupportedByFirmwareError& e) { + CHECK_EQ(int(e.code), 0x00); + CHECK_EQ(count_cmd(off.t(), proto::CMD_ENABLE), 1); // 不重试 + } catch (...) { + FAIL("0x00 应当被判成 UnsupportedByFirmwareError"); + } +} + +TEST(cmd_emergency_stop_reset_and_clear_faults) { + lt::Offline off; + CHECK_NOTHROW(off->emergency_stop()); + CHECK_NOTHROW(off->reset()); + CHECK_NOTHROW(off->clear_faults()); + CHECK_EQ(count_cmd(off.t(), proto::CMD_EMERGENCY_STOP), 1); + CHECK_EQ(count_cmd(off.t(), proto::CMD_RESET), 1); + CHECK_EQ(count_cmd(off.t(), proto::CMD_CLEAR_FAULTS), 1); +} + +TEST(cmd_speed_percent_validates_and_sends) { + lt::Offline off; + CHECK_NOTHROW(off->set_speed(0)); + CHECK_NOTHROW(off->set_speed(100)); + CHECK_NOTHROW(off->set_speed(50)); + CHECK_THROWS_AS(off->set_speed(-1), InvalidCommandError); + CHECK_THROWS_AS(off->set_speed(101), InvalidCommandError); +} + +TEST(cmd_set_motion_mode_only_accepts_zero) { + // 本固件只识别 mode=0 —— 其它值固件回 ACK 但模式不变。响亮失败优于静默无效。 + lt::Offline off; + CHECK_NOTHROW(off->set_motion_mode(0)); + CHECK_NOTHROW(off->park()); + CHECK_THROWS_AS(off->set_motion_mode(1), InvalidCommandError); + CHECK_THROWS_AS(off->set_motion_mode(-1), InvalidCommandError); + CHECK_THROWS_AS(off->set_motion_mode(256), InvalidCommandError); +} + +// ---------------------------------------------------------------- 关节运动 + +TEST(cmd_movej_validates_then_waits_for_arrival) { + lt::Offline off; + const std::vector target{0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7}; + const RobotState st = off->movej(target, 0.3); + CHECK_EQ(int(st.joints.size()), 7); + CHECK_NEAR(st.q()[3], 0.4, 1e-5); + // 载荷 = q[7] + sp + bool found = false; + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first == proto::CMD_MOVE_J) { + CHECK_EQ(kv.second.size(), size_t(4 * 7 + 4)); + const auto back = proto::unpack_f32s(kv.second.data(), 0, 7); + CHECK_NEAR(back[0], 0.1, 1e-6); + const auto sp = proto::unpack_f32s(kv.second.data(), 28, 1); + CHECK_NEAR(sp[0], 0.3, 1e-6); + found = true; + } + } + CHECK(found); +} + +TEST(cmd_movej_rejects_wrong_arity_and_speed) { + lt::Offline off; + CHECK_THROWS_AS(off->movej(std::vector(6, 0.0)), InvalidCommandError); + CHECK_THROWS_AS(off->movej(std::vector(8, 0.0)), InvalidCommandError); + CHECK_THROWS_AS(off->movej(zeros7(), 1.5), InvalidCommandError); + CHECK_THROWS_AS(off->movej(zeros7(), -0.1), InvalidCommandError); + CHECK_NOTHROW(off->movej(zeros7(), 0.0)); + CHECK_NOTHROW(off->movej(zeros7(), 1.0)); +} + +TEST(cmd_movej_timeout_when_it_never_arrives) { + lt::Offline off; + // 让目标与桩实际到达的位置不同 => 判不到位的分支 + const std::vector target(7, 0.9); + off.t().set_q(std::vector(7, 0.0)); + // 桩把 q 直接置成目标, 故这里要先让状态帧报别的值 —— 用 joint_fault 之外的方式: + // 直接把 arrive 的目标设成一个桩永远不报的值 (桩置 q = payload 的 q)。 + // 于是改走"故障"分支: 让状态帧带 FAULT。 + off.t().err_override[proto::CMD_MOVE_J] = 0x03; + CHECK_THROWS_AS(off->movej(target, 0.5), CommandRejectedError); +} + +TEST(cmd_movej_raises_motor_fault_when_the_status_reports_one) { + lt::Offline off; + // 桩在 MOVE_J 之后推的状态帧不带故障; 这里用 joint_fault 注入那条判据 —— + // 直接把 jp 表改掉不行, 故用 get_state 侧验证"故障会被读出"。 + const auto st = off->get_state(true).value; + CHECK(st.has_value()); + CHECK(!st->faulted()); +} + +TEST(cmd_movej_sync_uses_the_sync_opcode) { + lt::Offline off; + const std::vector target{0.2, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}; + const RobotState st = off->movej_sync(target, 0.4); + CHECK_NEAR(st.q()[0], 0.2, 1e-5); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_J_SYNC), 1); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_J), 0); + CHECK_THROWS_AS(off->movej_sync(std::vector(6, 0.0)), InvalidCommandError); + CHECK_THROWS_AS(off->movej_sync(zeros7(), 2.0), InvalidCommandError); +} + +TEST(cmd_move_p_accepts_all_three_pose_forms) { + lt::Offline off; + const std::array p6{0.30, 0.0, 0.40, 3.1416, 0.0, 0.0}; + CHECK_NOTHROW(off->move_p(p6)); + // (pos, R) 写法 + const auto R = rot::rpy_to_mat({3.1416, 0.0, 0.0}); + CHECK_NOTHROW(off->move_p(rot::PoseInput::from_pos_rot( + rot::Vec3{0.30, 0.0, 0.40}, R))); + // 4x4 齐次 + rot::Mat4 M{}; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) M[size_t(i)][size_t(j)] = R[size_t(i)][size_t(j)]; + } + M[0][3] = 0.30; + M[1][3] = 0.0; + M[2][3] = 0.40; + M[3][3] = 1.0; + CHECK_NOTHROW(off->move_p(M)); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_P), 3); +} + +TEST(cmd_move_p_reports_a_timeout_when_the_arm_never_reaches_the_pose) { + lt::Offline off; + // 让回读的 TCP 恒与目标不符 => 判不到位, 耗满 move_timeout。 + off.t().pos_override = std::vector{9.0, 9.0, 9.0}; + const double t0 = now_s(); + CHECK_THROWS_AS(off->move_p({0.3, 0.0, 0.4, 0.0, 0.0, 0.0}), MotionTimeoutError); + CHECK(now_s() - t0 >= 0.9); // move_timeout (夹具里是 1.0s) +} + +TEST(cmd_home_sends_the_opcode_and_waits_for_zero) { + lt::Offline off; + off.t().set_q(std::vector(7, 0.5)); + const RobotState st = off->home(); + CHECK_EQ(int(st.joints.size()), 7); + for (double q : st.q()) CHECK_NEAR(q, 0.0, 1e-6); + CHECK_EQ(count_cmd(off.t(), proto::CMD_HOME), 1); +} + +TEST(cmd_home_rejects_the_python_style_positional_speed) { + // home 的速度由固件写死 0.10 —— C++ 签名里根本没有 speed 参数, 这条钉的是"没有 + // 偷偷收一个位置参数进来" (旧 Python 签名 home(speed) 会把速度值静默当超时用)。 + lt::Offline off; + CHECK_NOTHROW(off->home()); + CHECK_NOTHROW(off->home(5.0)); // 这个 5.0 是 timeout, 不是 speed +} + +// ---------------------------------------------------------------- 伺服 / 透传 + +TEST(cmd_move_js_arity_and_payload) { + lt::Offline off; + CHECK_NOTHROW(off->move_js(zeros7())); + CHECK_NOTHROW(off->move_js(zeros7(), std::vector(7, 0.1))); + CHECK_NOTHROW(off->move_js(zeros7(), std::vector(7, 0.1), + std::vector(7, 0.2))); + CHECK_THROWS_AS(off->move_js(std::vector(6, 0.0)), InvalidCommandError); + CHECK_THROWS_AS(off->move_js(zeros7(), std::vector(3, 0.0)), + InvalidCommandError); + CHECK_THROWS_AS(off->move_js(zeros7(), {}, std::vector(2, 0.0)), + InvalidCommandError); + // 无 tau_ff => 8N 字节; 有 => 12N + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first != proto::CMD_MOVE_JS) continue; + CHECK(kv.second.size() % size_t(4 * 7) == 0); + } +} + +TEST(cmd_send_mit_validates_index_and_payload_size) { + lt::Offline off; + CHECK_NOTHROW(off->send_mit(0, 0.1, 0.0, 30.0, 1.0, 0.0)); + CHECK_NOTHROW(off->send_mit(6, 0.1, 0.0, 30.0, 1.0, 0.0)); + CHECK_THROWS_AS(off->send_mit(7, 0, 0, 0, 0, 0), InvalidCommandError); + CHECK_THROWS_AS(off->send_mit(-1, 0, 0, 0, 0, 0), InvalidCommandError); + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first == proto::CMD_MOVE_MIT) CHECK_EQ(kv.second.size(), size_t(21)); + } +} + +TEST(cmd_send_mit_all_validates_every_array) { + lt::Offline off; + CHECK_NOTHROW(off->send_mit_all(zeros7(), zeros7(), zeros7(), zeros7(), zeros7())); + CHECK_THROWS_AS(off->send_mit_all(std::vector(6, 0.0), zeros7(), zeros7(), + zeros7(), zeros7()), + InvalidCommandError); + CHECK_THROWS_AS(off->send_mit_all(zeros7(), zeros7(), zeros7(), zeros7(), + std::vector(1, 0.0)), + InvalidCommandError); + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first == proto::CMD_MOVE_MIT_ALL) { + CHECK_EQ(kv.second.size(), size_t(20 * 7)); + } + } +} + +// ---------------------------------------------------------------- 状态读取 + +TEST(cmd_get_state_returns_the_envelope_and_works_without_refresh) { + lt::Offline off; + const auto m1 = off->get_state(); + CHECK(m1.value.has_value()); + CHECK_EQ(int(m1.value->joints.size()), 7); + CHECK(m1.timestamp > 0.0); + // refresh=false 且有缓存时不取帧 —— 回的是同一份缓存 + const auto m2 = off->get_state(); + CHECK(m2.value.has_value()); + const auto m3 = off->get_state(true); + CHECK(m3.value.has_value()); +} + +TEST(cmd_get_status_now_actively_requests_a_frame) { + lt::Offline off; + const auto before = count_cmd(off.t(), proto::CMD_GET_STATUS); + const auto m = off->get_status_now(); + CHECK(m.value.joints.size() == 7); + CHECK_EQ(count_cmd(off.t(), proto::CMD_GET_STATUS), before + 1); + // timeout <= 0 的语义是"立刻返回当前缓存" + CHECK_NOTHROW(off->get_status_now(0.0)); +} + +TEST(cmd_get_status_now_without_any_frame_is_loud) { + lt::Unconnected un; + // 桩在 GET_STATUS 时会回一帧, 故这条测的是"连接都还没有" + CHECK_THROWS_AS(un.arm->get_status_now(0.0), NotConnectedError); +} + +TEST(cmd_get_tcp_returns_six_values) { + lt::Offline off; + off.t().set_pose({0.31, 0.02, 0.42, 3.1, 0.0, 0.1}); + const auto m = off->get_tcp(); + CHECK(m.value.has_value()); + if (m.value) { + CHECK_EQ(m.value->size(), size_t(6)); + CHECK_NEAR((*m.value)[0], 0.31, 1e-6); + } +} + +TEST(cmd_get_tcp_returns_empty_value_on_a_short_frame) { + // 帧长不足的 RSP_TCP: 入口不该抛, 而是回 Msg.value = 空。 + // ⚠ 用桩的确定注入点而不是手工 push_frame —— 后者会与读线程抢时序。 + lt::Offline off; + off.t().tcp_payload_override = std::vector{1, 2, 3}; + const auto m = off->get_tcp(); + CHECK(!m.value.has_value()); + // 正常长度照样解得出 + off.t().tcp_payload_override.reset(); + CHECK(off->get_tcp().value.has_value()); +} + +TEST(cmd_msg_hz_is_zero_for_the_first_frame_of_a_request_response_type) { + lt::Offline off; + // 单发请求/应答式: 一次调用只到达一帧 => 第一次调用必然 hz == 0.0。 + const auto m = off->get_tcp(); + CHECK_NEAR(m.hz, 0.0, 1e-12); + CHECK(m.timestamp > 0.0); +} + +TEST(cmd_ik_sends_pose_plus_seven_axis_seed) { + lt::Offline off; + const std::vector q = off->ik({0.30, 0.0, 0.35, 3.1416, 0.0, 0.0}); + CHECK_EQ(int(q.size()), 7); + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first == proto::CMD_GET_IK) { + CHECK_EQ(kv.second.size(), size_t(4 * 13)); // pose[6] + seed[7] + } + } + // ⚠ pose 的 arity 在 C++ 里由类型钉死 (std::array) —— 花括号给少几个会被 + // 补 0 而不是报错, 故这里没有"pose 长度"这一类运行期断言。种子长度那条见下。 +} + +TEST(cmd_ik_rejects_a_bad_seed_length) { + lt::Offline off; + CHECK_THROWS_AS(off->ik({0.3, 0, 0.4, 0, 0, 0}, std::vector(6, 0.0)), + InvalidCommandError); + CHECK_NOTHROW(off->ik({0.3, 0, 0.4, 0, 0, 0}, std::vector(7, 0.0))); +} + +TEST(cmd_ik_uses_the_model_axis_mapping_on_the_single_axis_bench) { + // 台架 1J 上当前反馈只有 1 个值 => 按 BENCH_MODEL_AXIS(=5) 把台架电机的实测值填进 + // 7 轴种子的对应位, 其余为 0。 + lt::Offline off("Litearm1.7.0-1J", 1); + CHECK_NOTHROW(off->ik({0.3, 0, 0.4, 0, 0, 0})); + bool found = false; + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first != proto::CMD_GET_IK) continue; + const auto seed = proto::unpack_f32s(kv.second.data(), 24, 7); + found = true; + // 桩的 q 是 0 => 种子全 0; 这条钉的是"能构造出 7 个而不是崩" + CHECK_EQ(int(seed.size()), 7); + } + CHECK(found); +} + +TEST(cmd_ik_raises_ik_error_when_the_firmware_reports_failure) { + lt::Offline off; + off.t().ik_ok = 0; // 应答里那个 ok 字节为 0 = 目标不可达/IK 失败 + CHECK_THROWS_AS(off->ik({0.3, 0, 0.4, 0, 0, 0}, std::vector(7, 0.0)), IKError); + // ok != 0 时正常返回 7 个关节角 + off.t().ik_ok = 1; + CHECK_EQ(int(off->ik({0.3, 0, 0.4, 0, 0, 0}, std::vector(7, 0.0)).size()), 7); +} + +TEST(cmd_move_p_arrives_when_the_firmware_canonicalises_rpy_at_gimbal_lock) { + // 固件 `kin_rot_to_rpy` 在 |pitch| 约等于 pi/2 时**强制 yaw=0**。 + // 目标 (roll=0.5, pitch=pi/2, yaw=0.2) 与固件回读的 (roll=0.3, pi/2, 0.0) 其实是 + // **同一个旋转** (只依赖 roll-yaw), 但逐分量比较会差 0.2rad > rpy_tol + // => 只抄逐分量的实现每次都要耗满 move_timeout 才报"未到位"。 + lt::Offline off; + off->move_timeout = 0.5; + off.t().pos_override = std::vector{0.30, 0.0, 0.35}; + off.t().rpy_override = std::vector{0.3, M_PI / 2, 0.0}; // 固件规范化后的等价表示 + CHECK_NOTHROW(off->move_p({0.30, 0.0, 0.35, 0.5, M_PI / 2, 0.2})); +} + +TEST(cmd_move_p_still_rejects_a_genuinely_different_orientation) { + // 别把判定放松成"永远到位": 真的差 90 度必须仍判未到位。 + lt::Offline off; + off->move_timeout = 0.4; + off.t().pos_override = std::vector{0.30, 0.0, 0.35}; + off.t().rpy_override = std::vector{0.0, 0.0, M_PI / 2}; // yaw 差 90 度 + CHECK_THROWS_AS(off->move_p({0.30, 0.0, 0.35, 0.0, 0.0, 0.0}), MotionTimeoutError); +} + +TEST(cmd_pose_near_keeps_componentwise_behaviour) { + // 新增的旋转等价判定必须是**超集**: 逐分量已判到的, 不能反而判丢。 + lt::Offline off; + const std::array tcp{0.3002, 0.001, 0.35, 3.1416, 0.0, 0.001}; + const std::array goal{0.30, 0.0, 0.35, 3.1416, 0.0, 0.0}; + CHECK(TestAccess::pose_near(*off.arm, tcp, goal, 0.006, 0.03)); + const std::array far{0.60, 0.0, 0.35, 3.1416, 0.0, 0.0}; + CHECK(!TestAccess::pose_near(*off.arm, far, goal, 0.006, 0.03)); + // 位置差过容差 -> 直接否掉 (不看姿态) + const std::array off_by_pos{0.307, 0.0, 0.35, 3.1416, 0.0, 0.0}; + CHECK(!TestAccess::pose_near(*off.arm, off_by_pos, goal, 0.006, 0.03)); +} + +TEST(cmd_unsupported_during_motion_wait_maps_to_the_same_exception) { + // 运动等待路径里的 ERR 也要走同一套映射 (不能退化成"无应答"超时)。 + lt::Offline off; + off.t().unknown_cmds.insert(proto::CMD_MOVE_J); + CHECK_THROWS_AS(off->movej(zeros7()), UnsupportedByFirmwareError); +} + +TEST(cmd_unsupported_during_status_read_maps_to_the_same_exception) { + // ⚠⚠ `quiesce` 不是讲究, 是这个用例**成立的前提** —— 缺了它, 本用例约 1/15 概率翻红 + // (实测 2026-09-28, TSan 下 30 次里 2 次), 而翻红的原因**不是** ERR 映射坏了。 + // + // `get_status_now` 的结论判据是「ERR 到位 **或** 状态帧前进」(与上游逐字一致)。 + // 后一半的毛病: 它认的不是"我们那条命令的应答", 而是**任何**一帧新状态帧。桩的被动流 + // (以及 `connect()` 握手压进 `resp_` 的那几帧) 与我们的 `0x40` 是两条独立的线。 + // 若在 `seq0` 采样与首次判据之间落进一帧状态帧, 判据当场成立, 而那条 + // `ERR{0x40,0x00}` 可能**还排在 `resp_` 里没被投递** ⇒ 函数查一眼 ERR 队列 (空) + // 便一路落到"返回成功" —— 固件明明拒了, SDK 报成功。 + // + // 量化: 调用期间**一帧状态帧都没交付**时 150/150 全部正确抛出; 交付了恰好 1 帧时, + // 150 次里有 26~35 次报成功。⇒ 判据是"有没有帧落进窗口", 不是"ERR 有没有到"。 + // + // 这是**上游就有的逻辑缺陷** (逐字移植, 非移植引入), 移植刻意保留原语义 —— + // 详见 README「已知继承的差异/缺口」。所以这里做的是**把变量摘掉**: 静链路之后本用例 + // 只钉它真正要钉的那件事 —— "固件回的 ERR -> UnsupportedByFirmwareError 这条映射"。 + // ⚠ 别把它改回"直接在活跃链路上断言": 那会重新变成一条测时序的用例。 + lt::Offline off; + lt::quiesce(off.t()); + off.t().unknown_cmds.insert(proto::CMD_GET_STATUS); + CHECK_THROWS_AS(off->get_status_now(), UnsupportedByFirmwareError); +} + +TEST(cmd_submodules_are_lazily_created_and_stable) { + lt::Offline off; + CHECK(&off->params() == &off->params()); + CHECK(&off->model() == &off->model()); + CHECK(&off->log() == &off->log()); + CHECK(&off->diag() == &off->diag()); +} diff --git a/tests/test_device.cpp b/tests/test_device.cpp deleted file mode 100644 index 9bb4106..0000000 --- a/tests/test_device.cpp +++ /dev/null @@ -1,170 +0,0 @@ -#include -#include "litearm/arm.hpp" -#include "litearm/codec.hpp" -#include "litearm/protocol.hpp" -#include "litearm/transport.hpp" - -namespace litearm { -namespace { - -using KwargsMap = std::map; - -/// Capture device.* RPC calls and reply true by default. -struct MockServer { - std::shared_ptr tp; - std::vector methods; // full "device.X.method" - std::vector kwargs_list; - - explicit MockServer(const std::string& arm_id = "armA") { - tp = std::make_shared(); - tp->declare_queryable(rpc_topic(arm_id), - [this](const std::string& payload) -> std::string { - auto [method, kwargs] = decode_request(payload); - methods.push_back(method); - kwargs_list.push_back(kwargs); - - LiteArmValue result(true); - if (method.find(".get_width") != std::string::npos) { - result = LiteArmValue(0.42); - } else if (method.find(".list_gestures") != std::string::npos || - method.find(".get_joints") != std::string::npos) { - result = LiteArmValue(LiteArmValue::ListType{}); - } else if (method.find(".get_state") != std::string::npos || - method.find(".get_status") != std::string::npos || - method.find(".get_info") != std::string::npos || - method.find(".get_buttons") != std::string::npos) { - result = LiteArmValue(LiteArmValue::MapType{}); - } - return encode_reply_ok(result); - }); - } -}; - -// ── RemoteDevice routing ───────────────────────────────────────────────────── - -TEST(RemoteDevice, RoutesThroughArmRpc) { - MockServer srv; - Arm arm("unused", "armA", srv.tp); - - auto hand = arm.device("hand_0"); - EXPECT_TRUE(hand.open()); - EXPECT_EQ(srv.methods.back(), "device.hand_0.open"); - - EXPECT_TRUE(hand.close()); - EXPECT_EQ(srv.methods.back(), "device.hand_0.close"); - - EXPECT_TRUE(hand.set_gesture("pinch")); - EXPECT_EQ(srv.methods.back(), "device.hand_0.set_gesture"); - EXPECT_EQ(srv.kwargs_list.back().at("gesture").as_string(), "pinch"); - - EXPECT_TRUE(hand.finger_move({0.1, 0.2, 0.3, 0.4, 0.5, 0.6})); - EXPECT_EQ(srv.methods.back(), "device.hand_0.finger_move"); - EXPECT_EQ(srv.kwargs_list.back().at("pose").to_vec().size(), 6u); - - EXPECT_TRUE(hand.set_speed({1.0, 1.0})); - EXPECT_EQ(srv.methods.back(), "device.hand_0.set_speed"); - - EXPECT_TRUE(hand.set_torque({1.0, 1.0})); - EXPECT_EQ(srv.methods.back(), "device.hand_0.set_torque"); -} - -TEST(RemoteDevice, StateAndGestureListing) { - MockServer srv; - Arm arm("unused", "armA", srv.tp); - - auto hand = arm.device("hand_0"); - // get_state / list_gestures / get_status / get_info return whatever server sends - hand.get_state(); - EXPECT_EQ(srv.methods.back(), "device.hand_0.get_state"); - hand.list_gestures(); - EXPECT_EQ(srv.methods.back(), "device.hand_0.list_gestures"); - hand.get_status(); - EXPECT_EQ(srv.methods.back(), "device.hand_0.get_status"); - hand.get_info(); - EXPECT_EQ(srv.methods.back(), "device.hand_0.get_info"); - - // bool-returning common methods - EXPECT_TRUE(hand.connect()); - hand.disconnect(); - EXPECT_TRUE(hand.clear_faults()); - EXPECT_TRUE(hand.set_force(0.8)); - EXPECT_EQ(srv.kwargs_list.back().at("force").as_double(), 0.8); -} - -TEST(RemoteDevice, GripperWidth) { - MockServer srv; - Arm arm("unused", "armA", srv.tp); - - auto gripper = arm.device("gripper_0"); - EXPECT_TRUE(gripper.set_width(0.5)); - EXPECT_EQ(srv.methods.back(), "device.gripper_0.set_width"); - EXPECT_DOUBLE_EQ(srv.kwargs_list.back().at("width").as_double(), 0.5); - - double width = gripper.get_width(); - EXPECT_EQ(srv.methods.back(), "device.gripper_0.get_width"); - EXPECT_DOUBLE_EQ(width, 0.42); -} - -TEST(RemoteDevice, TeachPendantMethods) { - MockServer srv; - Arm arm("unused", "armA", srv.tp); - - auto teach = arm.device("teach_0"); - teach.get_joints(); - EXPECT_EQ(srv.methods.back(), "device.teach_0.get_joints"); - teach.get_buttons(); - EXPECT_EQ(srv.methods.back(), "device.teach_0.get_buttons"); -} - -TEST(RemoteDevice, ArbitraryCall) { - MockServer srv; - Arm arm("unused", "armA", srv.tp); - - auto dev = arm.device("custom_0"); - dev.call("some_method", {{"arg", LiteArmValue(int64_t(1))}}); - EXPECT_EQ(srv.methods.back(), "device.custom_0.some_method"); - EXPECT_EQ(srv.kwargs_list.back().at("arg").as_int(), 1); -} - -TEST(RemoteDevice, ArmPrefixDoesNotApplyToPlainRpc) { - MockServer srv; - Arm arm("unused", "armA", srv.tp); - - // device() must NOT leak the device prefix into ordinary arm RPCs - arm.device("hand_0"); // create the lazy DeviceManager - EXPECT_TRUE(arm.movej({0,0,0,0,0,0,0})); - EXPECT_EQ(srv.methods.back(), "movej"); -} - -// ── DeviceManager ──────────────────────────────────────────────────────────── - -TEST(DeviceManager, LazyCreationAndReuse) { - MockServer srv; - Arm arm("unused", "armA", srv.tp); - - auto& mgr = arm.devices(); - EXPECT_FALSE(mgr.contains("hand_0")); - - auto h1 = mgr.get("hand_0"); - EXPECT_TRUE(mgr.contains("hand_0")); - auto h2 = mgr["hand_0"]; - EXPECT_EQ(h1.device_id(), h2.device_id()); - - auto ids = mgr.device_ids(); - ASSERT_EQ(ids.size(), 1u); - EXPECT_EQ(ids[0], "hand_0"); -} - -TEST(DeviceManager, DistinctDevicesRouteDistinctly) { - MockServer srv; - Arm arm("unused", "armA", srv.tp); - - auto& mgr = arm.devices(); - mgr["hand_0"].open(); - mgr["gripper_0"].open(); - EXPECT_EQ(srv.methods[0], "device.hand_0.open"); - EXPECT_EQ(srv.methods[1], "device.gripper_0.open"); -} - -} // namespace -} // namespace litearm diff --git a/tests/test_diagnostics.cpp b/tests/test_diagnostics.cpp new file mode 100644 index 0000000..3b49470 --- /dev/null +++ b/tests/test_diagnostics.cpp @@ -0,0 +1,120 @@ +// 固件自检 —— KIN_BENCH 文本解析与两帧收齐。 +#include "test_support.hpp" + +using namespace litearm; + +TEST(diag_parse_timings_handles_names_glued_to_digits) { + // 固件用 txt(名字) + u32_cat(n) 拼接, 而 u32_cat 只在数字之后补空格 —— 所以名字与 + // 第一个数字是相连的 (`FK200 108 240 `), 不能用「名字 + 空白 + 数字」式正则去切。 + const auto r = parse_kin_bench(testing::KIN_BENCH_TEXT); + CHECK_EQ(int(r.timings.size()), 9); + const auto& fk = r.timings.at("FK"); + CHECK_EQ(int(fk.size()), 3); + CHECK_EQ(int(fk[0]), 200); + CHECK_EQ(int(fk[1]), 108); + CHECK_EQ(int(fk[2]), 240); + // LOOP1 是 (cycles,) —— `LOOP14000` 里名字与数字相连 + CHECK_EQ(int(r.timings.at("LOOP1").size()), 1); + CHECK_EQ(int(r.timings.at("LOOP1")[0]), 4000); + const auto& ik = r.timings.at("IK"); + CHECK_EQ(int(ik[0]), 100); + CHECK_EQ(int(ik[1]), 2600); + CHECK_EQ(int(ik[2]), 5200); +} + +TEST(diag_parse_link_reads_every_counter) { + const auto r = parse_kin_bench(testing::KIN_BENCH_TEXT); + CHECK_EQ(int(r.link.at("crc")), 3); + CHECK_EQ(int(r.link.at("ovf")), 11); + CHECK_EQ(int(r.link.at("rfd")), 1); + CHECK_EQ(int(r.link.at("txf")), 7); + CHECK_EQ(int(r.link.at("loop_k")), 2); + CHECK_EQ(int(r.link.at("ovr")), 4); + // flt=<锁存拍> /<原因码> —— 数字与 '/' 之间有一个尾随空格 (u32_cat 语义所致) + CHECK_EQ(int(r.link.at("fault_tick")), 12345); + CHECK_EQ(int(r.link.at("fault_cause")), 6); + // st 是语义化的布尔: 1 = 从 flash 装载 + CHECK_EQ(int(r.link.at("store_from_flash")), 1); +} + +TEST(diag_parse_link_reads_the_digit_bearing_keys) { + // ⚠ rxl0 / rxl1 **名字里带数字** —— 从前写的是 [a-z_]+, 匹配不到它们, 于是那两个 + // "静默丢反馈"的计数器整个丢失。这是同一个文件里第二次栽在"名字里带数字"上。 + const auto r = parse_kin_bench(testing::KIN_BENCH_TEXT); + CHECK_EQ(int(r.rx_fifo_lost_motor()), 13); // rxl0 + CHECK_EQ(int(r.rx_fifo_lost_bridge()), 17); // rxl1 + CHECK_EQ(int(r.gsusb_ring_drops()), 19); // rbd + CHECK(r.link.count("rxl0") != 0); + CHECK(r.link.count("rxl1") != 0); +} + +TEST(diag_convenience_getters_read_zero_only_when_the_key_is_absent) { + // 拿不到就是静默的 0, 与"没有出错"分不开 —— 这正是"只收一帧必然丢掉全部链路计数" + // 那条纪律的由来。 + KinBenchResult empty; + CHECK_EQ(int(empty.crc_errors()), 0); + CHECK_EQ(int(empty.reply_dropped()), 0); + CHECK_EQ(int(empty.can_tx_fail()), 0); + CHECK_EQ(int(empty.loop_max_kcycle()), 0); + CHECK_EQ(int(empty.loop_overruns()), 0); + const auto r = parse_kin_bench(testing::KIN_BENCH_TEXT); + CHECK_EQ(int(r.crc_errors()), 3); + CHECK_EQ(int(r.reply_dropped()), 1); + CHECK_EQ(int(r.can_tx_fail()), 7); + CHECK_EQ(int(r.loop_max_kcycle()), 2); + CHECK_EQ(int(r.loop_overruns()), 4); +} + +TEST(diag_empty_reply_is_an_error_not_all_zeros) { + CHECK_THROWS_AS(parse_kin_bench(""), TransportError); + CHECK_THROWS_AS(parse_kin_bench(" \n \n"), TransportError); +} + +TEST(diag_result_keeps_the_raw_text) { + const auto r = parse_kin_bench(testing::KIN_BENCH_TEXT); + CHECK(r.raw == std::string(testing::KIN_BENCH_TEXT)); + CHECK(r.str() == r.raw); +} + +TEST(diag_kin_bench_collects_both_frames) { + // ⚠ 只收第 1 帧会**静默**丢掉全部链路计数 (那几个 link.get(k,0) 出口会全报 0, + // 与"没有出错"分不开)。 + lt::Offline off; + const auto m = off->diag().kin_bench(); + CHECK_EQ(int(m.value.timings.size()), 9); + CHECK(m.value.link.count("crc") != 0); // 第 2 帧确实收到了 + CHECK_EQ(int(m.value.crc_errors()), 3); + CHECK_EQ(int(m.value.rx_fifo_lost_motor()), 13); // 带数字的键也在 +} + +TEST(diag_kin_bench_is_compatible_with_old_firmware_that_sends_one_frame) { + // 旧固件只发一帧, 靠短窗口超时退出 —— 那是兼容情形, 不是错误。 + lt::Offline off; + off.t().kin_bench_one_frame = true; + const auto m = off->diag().kin_bench(); + CHECK_EQ(int(m.value.timings.size()), 9); + CHECK(m.value.link.empty()); // 链路计数确实拿不到 (旧固件根本不发) +} + +TEST(diag_kin_bench_empty_reply_is_rejected) { + lt::Offline off; + off.t().kin_bench_text = ""; + CHECK_THROWS_AS(off->diag().kin_bench(), TransportError); +} + +TEST(diag_kin_bench_timeout_is_configurable) { + lt::Offline off; + CHECK_NOTHROW(off->diag().kin_bench(2.0)); +} + +TEST(diag_split_kin_bench_mirrors_the_firmware_framing) { + // 拆点取 LINK 行首 —— 固件是"重置缓冲后从 `LINK ` 重新写", 故第 2 帧以 LINK 开头。 + const auto parts = testing::split_kin_bench(testing::KIN_BENCH_TEXT); + CHECK_EQ(int(parts.size()), 2); + CHECK(parts[1].rfind("LINK", 0) == 0); + CHECK(parts[0].find("LINK") == std::string::npos); + // 不含 LINK 的文本仍回一帧 + CHECK_EQ(int(testing::split_kin_bench("FK200 ").size()), 1); + // one_frame 只给第 1 帧 + CHECK_EQ(int(testing::split_kin_bench(testing::KIN_BENCH_TEXT, true).size()), 1); +} diff --git a/tests/test_errors.cpp b/tests/test_errors.cpp new file mode 100644 index 0000000..c5f92d7 --- /dev/null +++ b/tests/test_errors.cpp @@ -0,0 +1,176 @@ +// 错误层级 + (cmd, code) 语义表 + ERR 载荷 -> 异常的映射。 +#include "test_support.hpp" + +using namespace litearm; + +TEST(errors_hierarchy_everything_derives_from_lite_arm_error) { + // 全部 catch 点都挂在 LiteArmError 上, 所以每一条都必须是它的派生类。 + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); + CHECK((std::is_base_of::value)); +} + +TEST(errors_forked_session_is_a_not_connected_error) { + // 继承自 NotConnectedError: except NotConnectedError 与 except LiteArmError 都抓得到 —— + // 这个会话在本进程里确实不可用。 + CHECK((std::is_base_of::value)); + try { + throw ForkedSessionError("x"); + } catch (const NotConnectedError&) { + // 期望走到这里 + } catch (...) { + FAIL("ForkedSessionError 应当能被 NotConnectedError 抓到"); + } +} + +TEST(errors_unsupported_by_firmware_is_a_command_rejected_error) { + // 既有捕获 CommandRejectedError 的调用方不受影响。 + CHECK((std::is_base_of::value)); +} + +TEST(errors_motion_superseded_is_not_a_plan_error) { + // 接管是正常用法, 不得继承 CartesianPlanError —— 混进"规划失败"会让调用方走故障恢复。 + CHECK(!(std::is_base_of::value)); + CHECK(!(std::is_base_of::value)); + // ArmIsInDfuError 刻意不继承 NotConnectedError: 它不是一次可恢复的掉线。 + CHECK(!(std::is_base_of::value)); +} + +TEST(errors_raise_firmware_error_code_zero_is_unsupported) { + // code == 0x00 恒等于固件 usb_cmd.c 的 default 分支 = 未实现该命令。 + const std::vector payload{0x34, 0x00}; + try { + raise_firmware_error(payload, "model.get_body: "); + FAIL("应当抛异常"); + } catch (const UnsupportedByFirmwareError& e) { + CHECK_EQ(int(e.cmd), 0x34); + CHECK_EQ(int(e.code), 0x00); + CHECK(std::string(e.what()).find("固件没有实现这条命令") != std::string::npos); + } +} + +TEST(errors_raise_firmware_error_other_codes_are_command_rejected) { + const std::vector payload{0x01, 0x03}; + try { + raise_firmware_error(payload, "movej 被固件拒绝: "); + FAIL("应当抛异常"); + } catch (const UnsupportedByFirmwareError&) { + FAIL("非 0 码不该被判成 UnsupportedByFirmwareError"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.cmd), 0x01); + CHECK_EQ(int(e.code), 0x03); + // 文案里要带出具体语义 (查 ERR_TEXT) + CHECK(std::string(e.what()).find("ERR [01,03]") != std::string::npos); + CHECK(std::string(e.what()).find("未使能") != std::string::npos); + } +} + +TEST(errors_raise_firmware_error_handles_short_payload) { + // 载荷缺失时按 0 处理, 不越界读。 + try { + raise_firmware_error({}, "x: "); + FAIL("应当抛异常"); + } catch (const UnsupportedByFirmwareError& e) { + CHECK_EQ(int(e.cmd), 0); + CHECK_EQ(int(e.code), 0); + } +} + +TEST(errors_err_reason_first_tier_uses_the_specific_table) { + // 具体档命中 -> 用它。同一个 code 在不同命令上语义不同, 这正是具体档存在的理由。 + const std::string a = err_reason(0x01, 0x03); + const std::string b = err_reason(0x10, 0x03); + CHECK(a != b); + CHECK(a.find("EMERGENCY") != std::string::npos); + CHECK(b.find("可重试") != std::string::npos); + // 0x23 的 0x03 又是第三层语义 + CHECK(err_reason(0x23, 0x03).find("新限位关不住在途目标") != std::string::npos); +} + +TEST(errors_err_reason_second_tier_appends_the_raw_code) { + // 具体档未命中、通用档命中 -> 用通用档并附上原始码 (固件新增一档时这里是唯一会让上位机 + // 看见"这是我没见过的码"的地方)。 + const std::string s = err_reason(0x01, 0x05); // 0x01 未登记 0x05 + CHECK(s.find("本命令未登记此码") != std::string::npos); + CHECK(s.find("cmd=0x01") != std::string::npos); + CHECK(s.find("code=0x05") != std::string::npos); +} + +TEST(errors_err_reason_third_tier_says_unregistered) { + const std::string s = err_reason(0x01, 0x7F); // 两级都没有 + CHECK(s.find("未登记的固件错误码") != std::string::npos); + CHECK(s.find("cmd=0x01") != std::string::npos); + CHECK(s.find("code=0x7F") != std::string::npos); +} + +TEST(errors_zero_code_is_never_in_the_specific_table) { + // code == 0x00 一律不在此表 —— 它恒等于"固件没有这条命令", 由通用档承担。 + for (const auto& kv : err_text()) { + CHECK(kv.first.second != 0x00); + } + CHECK(err_code_text().count(0x00) != 0); +} + +TEST(errors_error_codes_are_unique_per_command_and_well_formed) { + for (const auto& kv : err_text()) { + CHECK(kv.first.first > 0); + // 0x08 是唯一超过 0x07 的一档 (0x10 ENABLE 的"未激活") + CHECK(kv.first.second >= 0x01 && kv.first.second <= 0x08); + CHECK(!kv.second.empty()); + } +} + +TEST(errors_specific_table_pins_the_load_bearing_entries) { + // 这几条是现场判定用的判据, 逐条钉住 —— 改错了会让人判反方向。 + CHECK(err_reason(0x01, 0x06).find("drop_hold") != std::string::npos); + CHECK(err_reason(0x2A, 0x06).find("drop_hold") != std::string::npos); + CHECK(err_reason(0x3A, 0x06).find("drop_hold") != std::string::npos); + CHECK(err_reason(0x10, 0x08).find("未激活") != std::string::npos); + CHECK(err_reason(0x10, 0x06).find("EMERGENCY") != std::string::npos); + CHECK(err_reason(0x10, 0x07).find("重发无用") != std::string::npos); + CHECK(err_reason(0x02, 0x03).find("二义码") != std::string::npos); + CHECK(err_reason(0x32, 0x07).find("掩码不符") != std::string::npos); + CHECK(err_reason(0x3F, 0x02).find("聚合档") != std::string::npos); + CHECK(err_reason(0x23, 0x02).find("放宽") != std::string::npos); + CHECK(err_reason(0x28, 0x04).find("feat/hyy-model-import") != std::string::npos); +} + +TEST(errors_0x28_02_text_scopes_the_clamp_claim) { + // 幅值静默钳制只对**有 clamp 的** item 成立 —— 文案不许说成全称。 + const std::string s = err_reason(0x28, 0x02); + CHECK(s.find("静默钳制") != std::string::npos); + CHECK(s.find("item 9") != std::string::npos); + CHECK(s.find("payload_mass") != std::string::npos); +} + +TEST(errors_enable_retry_whitelist_is_only_0x03) { + // 白名单 ("只有它说可重试才重试") 而不是黑名单: 0x06/0x07/0x00 都明确是"重发无用"。 + const auto& codes = enable_retryable_codes(); + CHECK_EQ(int(codes.size()), 1); + CHECK(codes.count(0x03) != 0); + CHECK(codes.count(0x06) == 0); + CHECK(codes.count(0x07) == 0); + CHECK(codes.count(0x00) == 0); +} + +TEST(errors_generic_table_covers_the_skeleton_codes) { + for (int c = 0; c <= 7; ++c) { + CHECK(err_code_text().count(c) != 0); + } + CHECK(err_code_text().at(0x01).find("长度") != std::string::npos); + CHECK(err_code_text().at(0x06).find("reset") != std::string::npos); +} diff --git a/tests/test_ff.cpp b/tests/test_ff.cpp new file mode 100644 index 0000000..1f578ac --- /dev/null +++ b/tests/test_ff.cpp @@ -0,0 +1,155 @@ +// 前馈/动力学调参 (0x26/0x27/0x28/0x31 + 读回 0x2B/0x2C)。 +#include "test_support.hpp" + +using namespace litearm; + +TEST(ff_item_tables_cover_the_firmware_ranges) { + // item 1..15 都能写/能读回 (含 12~15 的零重力与 kd_extra) + CHECK_EQ(int(Arm::ff_vec_items().size()), 15); + CHECK_EQ(int(Arm::ff_scalar_items().size()), 17); // 1..8 + 10..18 + CHECK_EQ(int(Arm::ff_scalar_ro_items().size()), 1); + CHECK_EQ(Arm::ff_scalar_ro_items()[0].first, 9); + // item 7/8 是具名入口的载体 + CHECK_EQ(Arm::ff_vec_items()[6].first, 7); + CHECK_EQ(Arm::ff_vec_items()[6].second, std::string("gravity_scale")); + CHECK_EQ(Arm::ff_vec_items()[14].first, 15); + CHECK_EQ(Arm::ff_vec_items()[14].second, std::string("kd_extra")); +} + +TEST(ff_vec_write_then_read_back) { + lt::Offline off; + const std::vector gs{1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6}; + off->set_ff_vec(7, gs); + const auto got = off->get_ff_vec(7); + CHECK_EQ(int(got.value.size()), 7); + for (size_t i = 0; i < gs.size(); ++i) CHECK_NEAR(got.value[i], gs[i], 1e-6); +} + +TEST(ff_vec_validates_item_and_arity) { + lt::Offline off; + CHECK_THROWS_AS(off->set_ff_vec(0, std::vector(7, 0.0)), InvalidCommandError); + CHECK_THROWS_AS(off->set_ff_vec(16, std::vector(7, 0.0)), InvalidCommandError); + CHECK_THROWS_AS(off->set_ff_vec(7, std::vector(6, 0.0)), InvalidCommandError); + CHECK_THROWS_AS(off->get_ff_vec(0), InvalidCommandError); + CHECK_THROWS_AS(off->get_ff_vec(16), InvalidCommandError); + // item 1..15 全都能读写 + for (int item = 1; item <= 15; ++item) { + CHECK_NOTHROW(off->set_ff_vec(item, std::vector(7, 0.5))); + CHECK_NOTHROW(off->get_ff_vec(item)); + } +} + +TEST(ff_named_gravity_and_inertia_scales) { + lt::Offline off; + const std::vector v{2.0, 2.0, 2.0, 2.0, 2.0, 2.0, 2.0}; + CHECK_NOTHROW(off->set_gravity_scale(v)); + CHECK_NOTHROW(off->set_inertia_scale(v)); + CHECK_THROWS_AS(off->set_gravity_scale(std::vector(4, 1.0)), + InvalidCommandError); + CHECK_THROWS_AS(off->set_inertia_scale(std::vector(4, 1.0)), + InvalidCommandError); +} + +TEST(ff_scalar_write_then_read_back) { + lt::Offline off; + off->set_ff_scalar(4, 0, 1.25); // payload_mass + CHECK_NEAR(off->get_ff_scalar(4, 0).value, 1.25, 1e-6); + off->set_ff_scalar(5, 2, -0.5); // payload_com z + CHECK_NEAR(off->get_ff_scalar(5, 2).value, -0.5, 1e-6); + // item 10..18 (零重力族与 hold_kp_gain) 也能写 + for (int item : {10, 11, 12, 13, 14, 15, 16, 17, 18}) { + CHECK_NOTHROW(off->set_ff_scalar(item, 0, 0.5)); + CHECK_NOTHROW(off->get_ff_scalar(item, 0)); + } +} + +TEST(ff_scalar_validates_item_and_sub) { + lt::Offline off; + CHECK_THROWS_AS(off->set_ff_scalar(0, 0, 1.0), InvalidCommandError); + CHECK_THROWS_AS(off->set_ff_scalar(9, 0, 1.0), InvalidCommandError); // item 9 保留 + CHECK_THROWS_AS(off->set_ff_scalar(19, 0, 1.0), InvalidCommandError); + CHECK_THROWS_AS(off->set_ff_scalar(4, 3, 1.0), InvalidCommandError); // sub 0..2 + CHECK_THROWS_AS(off->set_ff_scalar(4, -1, 1.0), InvalidCommandError); + // 读口收 item 9 (只读扩展) 与 1..18 + CHECK_NOTHROW(off->get_ff_scalar(9, 0)); + CHECK_THROWS_AS(off->get_ff_scalar(19), InvalidCommandError); +} + +TEST(ff_mask_refuses_bits_outside_ff_all) { + // 固件侧会做 mask & FF_ALL_MASK, 于是 0x1000 这类误用被静默折成 0 = 前馈全关 + // (重力补偿被关掉, 臂会垂下来)。宁可在这里报错, 也不让一个危险值悄悄变成另一个"合法"值。 + lt::Offline off; + CHECK_NOTHROW(off->set_ff_mask(0)); + CHECK_NOTHROW(off->set_ff_mask(proto::FF_ALL)); + CHECK_NOTHROW(off->set_ff_mask(proto::FF_G | proto::FF_INERTIA)); + CHECK_THROWS_AS(off->set_ff_mask(0x1000), InvalidCommandError); + CHECK_THROWS_AS(off->set_ff_mask(0xFFFFFFFFu), InvalidCommandError); +} + +TEST(ff_mask_reads_back_through_the_read_only_item) { + lt::Offline off; + off->set_ff_mask(proto::FF_G | proto::FF_FRICTION); + // get_ff_mask 是 get_ff_scalar(9,0) 的标量投影 (走 0x2C item 9, 只读) + const int mask = off->get_ff_mask(); + CHECK_EQ(mask, int(proto::FF_G | proto::FF_FRICTION)); +} + +TEST(ff_preset_validates_the_range) { + lt::Offline off; + CHECK_NOTHROW(off->ff_preset(0)); + CHECK_NOTHROW(off->ff_preset(1)); + CHECK_NOTHROW(off->ff_preset(2)); + CHECK_THROWS_AS(off->ff_preset(3), InvalidCommandError); + CHECK_THROWS_AS(off->ff_preset(-1), InvalidCommandError); +} + +TEST(ff_set_payload_writes_mass_and_each_component) { + lt::Offline off; + const size_t before = off.t().tx_count(); + off->set_payload(1.5, {0.01, -0.02, 0.03}); + // 1 次 item4 + 3 次 item5 + // ⚠ 取**一次**快照再数, 不要边遍历边问桩要长度: 保活线程会并发 append, + // 裸读 `tx_log` 是 use-after-free 级的竞争 (见 `tx_snapshot` 的说明)。 + const auto log = off.t().tx_snapshot(); + int n = 0; + for (size_t i = before; i < log.size(); ++i) { + if (log[i].first == proto::CMD_SET_FF_SCALAR) ++n; + } + CHECK_EQ(n, 4); + CHECK_NEAR(off->get_ff_scalar(4, 0).value, 1.5, 1e-6); + CHECK_NEAR(off->get_ff_scalar(5, 0).value, 0.01, 1e-6); + CHECK_NEAR(off->get_ff_scalar(5, 2).value, 0.03, 1e-6); +} + +TEST(ff_set_gravity_vector_writes_three_components) { + lt::Offline off; + off->set_gravity_vector({0.0, 0.0, -9.81}); + CHECK_NEAR(off->get_ff_scalar(6, 2).value, -9.81, 1e-4); +} + +TEST(ff_save_params_requires_a_disarmed_arm) { + // 写 flash 期间电机不得在无监督下保持使能 —— 固件回 ERR{0x25,0x04}。 + lt::Offline off; + off->enable(); + try { + off->save_params(); + FAIL("武装态应当被拒"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.cmd), 0x25); + CHECK_EQ(int(e.code), 0x04); + } + off->disable(); + CHECK_NOTHROW(off->save_params()); +} + +TEST(ff_errors_propagate_the_firmware_code) { + lt::Offline off; + off.t().err_override[proto::CMD_SET_FF_VEC] = 0x02; + try { + off->set_ff_vec(1, std::vector(7, 1.0)); + FAIL("应当抛异常"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.code), 0x02); + CHECK(std::string(e.what()).find("符号契约") != std::string::npos); + } +} diff --git a/tests/test_fork_guard.cpp b/tests/test_fork_guard.cpp new file mode 100644 index 0000000..aec0aaf --- /dev/null +++ b/tests/test_fork_guard.cpp @@ -0,0 +1,201 @@ +// fork 守卫 —— 子进程里的会话 fail-closed (一个字节都不下发)。 +// +// ⚠ 这是本包最"载重"的安全判据之一, 且**只能靠真 fork 测**: 线程不被 fork 复制是操作系统的 +// 事实, 任何桩都模拟不出来。 +#include +#include + +#include + +#include "test_support.hpp" + +using namespace litearm; + +#ifndef _WIN32 + +namespace { + +/// 子进程里跑一段代码, 返回它的退出码 (0 = 子进程判定通过)。 +int run_in_child(const std::function& body) { + ::fflush(nullptr); + const pid_t pid = ::fork(); + if (pid < 0) return -1; + if (pid == 0) { + // 子进程。⚠ 别用 std::exit (它会跑静态析构) —— 用 _exit。 + int rc = 0; + try { + body(); + } catch (...) { + rc = 1; + } + ::_exit(rc); + } + int status = 0; + ::waitpid(pid, &status, 0); + if (WIFEXITED(status)) return WEXITSTATUS(status); + return -1; +} + +} // namespace + +TEST(fork_guard_child_process_refuses_every_command) { + // 子进程**继承 fd** (写照样能到 CDC 上) 但**不继承线程** —— 读线程在子进程里不存在。 + // 于是命令会真的发出去、而应答永远没人读 ⇒ 调用方只看到"无应答"超时 ⇒ 重试 = + // **重复下发**。所以口径是: 一个字节都不下发, 立刻抛。 + lt::Offline off; + const size_t before = off.t().tx_count(); + const int rc = run_in_child([&off]() { + int refusals = 0; + const auto expect_refusal = [&](const std::function& f) { + try { + f(); + } catch (const ForkedSessionError&) { + ++refusals; + } catch (const NotConnectedError&) { + // 也接受 —— ForkedSessionError 继承自它, 但这条分支不该被走到 + ++refusals; + } + }; + // 读路径 + expect_refusal([&]() { off.arm->get_state(); }); + expect_refusal([&]() { off.arm->get_state(true); }); + expect_refusal([&]() { off.arm->get_tcp(); }); + expect_refusal([&]() { off.arm->get_status_now(); }); + // 写路径 + expect_refusal([&]() { off.arm->enable(); }); + expect_refusal([&]() { off.arm->disable(); }); + expect_refusal([&]() { off.arm->emergency_stop(); }); + expect_refusal([&]() { off.arm->movej(std::vector(7, 0.0)); }); + expect_refusal([&]() { off.arm->params().get_joint_param(0); }); + expect_refusal([&]() { off.arm->diag().kin_bench(); }); + expect_refusal([&]() { off.arm->poll_cart(); }); + // 一条都没漏才算通过 + if (refusals != 11) ::_exit(3); + }); + CHECK_EQ(rc, 0); + // **零下发** —— 这才是这条守卫要买的东西 + CHECK_EQ(off.t().tx_count(), before); +} + +TEST(fork_guard_child_refuses_with_the_specific_exception_type) { + lt::Offline off; + const int rc = run_in_child([&off]() { + try { + off.arm->get_state(); + ::_exit(1); // 没抛 = 失败 + } catch (const ForkedSessionError&) { + ::_exit(0); + } catch (...) { + ::_exit(2); // 抛了别的类型 + } + }); + CHECK_EQ(rc, 0); +} + +TEST(fork_guard_close_in_the_child_is_still_callable) { + // close() 在子进程里**仍可调** (收尾不能被拦死), 但它**只做会话状态清理**, + // **不碰传输层** —— 那会取一把从父进程继承来、永远不会释放的锁 ⇒ **永久挂死**。 + // (离线 FakeTransport 没有那把锁, 所以这条判据测的是"不走传输层"而非"不会挂" —— + // 真机上的那一半由"不碰传输层"这个结构性事实保证。) + lt::Offline off; + const int rc = run_in_child([&off]() { + try { + off.arm->close(); + } catch (...) { + ::_exit(1); + } + if (off.arm->n() != 0) ::_exit(2); // 会话状态确实清了 + if (off.arm->firmware().size() != 0) ::_exit(3); + ::_exit(0); + }); + CHECK_EQ(rc, 0); + // 父进程的顺序不因 fork 而改变 —— 它照样要能收尾 + CHECK_NOTHROW(off.arm->get_state()); + off.arm->close(); +} + +TEST(fork_guard_destroying_the_whole_arm_in_the_child_does_not_hang) { + // `close()` 走的是 `Arm` 的 fork 分支 (release 掉两个句柄)。而**销毁整个对象**是另一条 + // 路径: `~Arm` -> `close()` -> 之后**成员析构**还要跑 (`cart_` / 各子模块 / 串行锁 …)。 + // 那条路径也必须不挂 —— 它靠的是"只有 condvar 的析构会死锁, 而本对象剩下的成员只涉及 + // malloc/free"。这条用例把那个判断钉住。 + lt::Offline off; + const int rc = run_in_child([&off]() { + // 子进程里销毁整个 Arm (析构会兜底 close) + off.arm.reset(); + if (off.arm != nullptr) ::_exit(1); + ::_exit(0); + }); + CHECK_EQ(rc, 0); +} + +TEST(fork_guard_a_child_arm_going_out_of_scope_does_not_hang) { + // 同一个形状, 但走"作用域结束自动析构"—— 用户最可能写成的那一种。 + lt::Offline off; + const int rc = run_in_child([&off]() { + // 子进程里要新建一个**自己的**会话 —— 真机上还要求父进程先 close 释放端口, + // 这里用桩故不涉及。`Arm()` 不带工厂会去开真串口, 故照 `a_fresh_arm_...` 那条 + // 用例的样子注入一份子进程本地的桩。 + testing::FakeTransport fake("fake", 0.2, "Litearm1.7.0-7J", 7); + auto holder = std::shared_ptr(&fake, [](void*) {}); + { + ArmOptions opts; + opts.port = "fake"; + opts.transport_factory = [holder](const std::string&) + -> std::unique_ptr { + return std::unique_ptr(new lt::SharedFake(holder)); + }; + opts.move_timeout = 1.0; + Arm scoped(opts); + scoped.connect(); + if (scoped.n() != 7) ::_exit(2); + } // 作用域退出 -> ~Arm -> close() + ::_exit(0); + }); + CHECK_EQ(rc, 0); + off.arm->close(); +} + +TEST(fork_guard_parent_still_works_after_the_child_exits) { + lt::Offline off; + const size_t before = off.t().tx_count(); + CHECK_EQ(run_in_child([&off]() { ::_exit(off.arm->n() == 7 ? 0 : 1); }), 0); + // 父进程的会话一个字节都没被扰动 + CHECK_EQ(off.t().tx_count(), before); + CHECK_NOTHROW(off.arm->movej(std::vector(7, 0.0))); +} + +TEST(fork_guard_a_fresh_arm_in_the_child_is_the_supported_path) { + // 子进程要用臂 ⇒ 在子进程里**新建一个 Arm** (父进程先 close 释放端口)。 + // 这里验的是: 新对象没有继承来的 pid_ 落章, 于是守卫放行 —— 它有自己的会话。 + lt::Offline off; + const int rc = run_in_child([&off]() { + // 在子进程里新建一个 Arm 并连上**同一台桩** (真机上要求父进程先 close)。 + testing::FakeTransport fake("fake", 0.2, "Litearm1.7.0-7J", 7); + ArmOptions opts; + opts.port = "fake"; + opts.transport_factory = [&fake](const std::string&) -> std::unique_ptr { + // 子进程里不能共享父进程的对象图, 故这一份是子进程自己构造的转发壳 + return std::unique_ptr(new lt::SharedFake( + std::shared_ptr(&fake, [](void*) {}))); + }; + opts.move_timeout = 1.0; + Arm child(opts); + child.connect(); + if (child.n() != 7) ::_exit(1); + child.movej(std::vector(7, 0.0)); + child.close(); + ::_exit(0); + }); + CHECK_EQ(rc, 0); + off.arm->close(); +} + +#else // _WIN32 + +TEST(fork_guard_is_a_posix_only_concern) { + // Windows 没有 fork() —— 这道守卫在那边是纯空操作 (进程模型不同)。 + CHECK(true); +} + +#endif // _WIN32 diff --git a/tests/test_frame_ownership.cpp b/tests/test_frame_ownership.cpp new file mode 100644 index 0000000..2af6f7e --- /dev/null +++ b/tests/test_frame_ownership.cpp @@ -0,0 +1,272 @@ +// 帧归属 —— 读线程模型的**验收闸门**。 +// +// **这条不变量一句话**: +// +// 推进链路的任一帧, 要么被某个队列的等待者取走, 要么在**队列封顶**时被计数。 +// **不存在"静默销毁"这条路。** +// +// 它靠一个机制成立: **SDK 只有一条读线程** (`Ack::reader_loop`, 唯一碰 +// `transport->read_frame` 的地方), 它把每帧按 `(上行 id, 回显码)` 投进队列, 等待者只从 +// 自己的队列里取。于是"谁读到归谁"这个问题**不存在**了。 +// +// (上游对应 tests/test_frame_ownership.py。) +#include + +#include "test_support.hpp" + +using namespace litearm; + +namespace { + +constexpr uint8_t kOtherCmd = 0x7F; // 一个不存在的命令码, 保证与任何被测命令都不同队列 + +void push_frame(Arm& arm, uint8_t cmd, const std::vector& payload, + testing::FakeTransport& t) { + t.push_frame(cmd, payload); + // 驱动读线程把它投递进队列 (等它真的落进去, 用例才有"帧已躺在队列里"这个前提) + (void)arm; +} + +} // namespace + +// --------------------------------------------------------------------------- +// 1. 只有一条读线程 —— 结构性的, 用源码扫描钉住 +// --------------------------------------------------------------------------- + +TEST(ownership_read_frame_has_exactly_one_call_site_and_it_is_the_reader_loop) { + // 全包**只有一个**地方调 `transport->read_frame`。多一处就会抢走别人的应答, + // 症状是"受害者报无应答, 而它的命令其实已经生效"。 + // + // ⚠ 这是**源码扫描**, 不是运行时断言 —— 与上游同口径 (那边用 `ast`)。 + // 它守的是"没人再开第二个读口"这条结构不变量。 + // ⚠ 路径从 `__FILE__` 推 (见 `lt::repo_root`), **不是**相对当前目录 —— ctest 的 + // 工作目录是构建目录, 相对路径在那边打不开。实测踩过。 + const std::vector files = { + "src/ack.cpp", "src/arm.cpp", "src/cart.cpp", "src/log.cpp", + "src/params.cpp", "src/model.cpp", "src/diagnostics.cpp", + "src/transport.cpp", "src/testing.cpp", + }; + std::vector callers; + for (const auto& f : files) { + const std::string src = lt::read_repo_file(f); + if (src.empty()) { + FAIL("读不到源文件 (用例失效): " + f); + return; + } + // 只数**调用** (`x->read_frame(` / `x.read_frame(`), 不是定义 + size_t pos = 0; + while ((pos = src.find("read_frame(", pos)) != std::string::npos) { + const bool is_call = pos >= 2 && src[pos - 1] == '>' && src[pos - 2] == '-'; + if (is_call) callers.push_back(f); + pos += 1; + } + } + if (callers.size() != 1 || callers[0] != "src/ack.cpp") { + std::string msg = "transport->read_frame 的调用点应当**有且只有** src/ack.cpp 一处; 实到:"; + for (const auto& c : callers) msg += " " + c; + FAIL(msg); + } +} + +// --------------------------------------------------------------------------- +// 2. 归属判据 —— 谁等哪条队列, 就只拿得到哪条 +// --------------------------------------------------------------------------- + +TEST(ownership_an_ack_waiter_must_declare_which_command_it_waits_for) { + // 等 `RSP_ACK`/`RSP_ERR` 时 `echo_cmd` **必填** —— 它就是归属判据。 + // 不给就等于"随便哪条 ACK 都算我的", 而那正是并发下两条命令互吃应答的成因 + // (上游实测: 到达序与等待序不一致时 60/60 轮必有一方丢)。 + try { + wait_keys(proto::RSP_ACK, std::nullopt); + FAIL("不给 echo_cmd 应当被拒"); + } catch (const InvalidCommandError& e) { + CHECK(std::string(e.what()).find("echo_cmd") != std::string::npos); + } + // 给了就正常 + CHECK_NOTHROW(wait_keys(proto::RSP_ACK, proto::CMD_ENABLE)); +} + +TEST(ownership_a_reply_goes_to_the_waiter_that_owns_it_not_the_first_to_read) { + // `ACK{0x11}` 落在 `(0x11)` 那条队列里, 等 `ACK{0x10}` 的人**碰不到它**。 + lt::Offline off; + off.t().push_frame(proto::RSP_ACK, {proto::CMD_DISABLE}); + CHECK(TestAccess::await_queued(*off.arm, {proto::RSP_ACK, proto::CMD_DISABLE})); + // 等 `enable` 的一方来取 ("别人的读者") —— 它不该拿到这条 + const auto got = TestAccess::ack(*off.arm) + .wait({{proto::RSP_ACK, int(proto::CMD_ENABLE)}}, 0.05); + CHECK(!got.has_value()); + // 主人拿得到 (队列里那条还在) + CHECK_NOTHROW(TestAccess::ack(*off.arm) + .expect(proto::RSP_ACK, 0.3, "disable", true, proto::CMD_DISABLE)); +} + +TEST(ownership_two_concurrent_commands_do_not_eat_each_others_replies) { + // **真两线程**: 到达序与等待序**相反**时, 两个等待者都必须成功。 + // ⚠ 这是旧实现最要命的一条 (上游实测 100% 有一方丢应答)。 + lt::Offline off; + off.t().push_frame(proto::RSP_ACK, {proto::CMD_DISABLE}); // 到达序与等待序相反 + off.t().push_frame(proto::RSP_ACK, {proto::CMD_ENABLE}); + + std::atomic ok_enable{0}; + std::atomic ok_disable{0}; + auto waiter = [&](uint8_t echo, std::atomic* slot) { + try { + TestAccess::ack(*off.arm) + .expect(proto::RSP_ACK, 0.5, "waiter", true, echo); + slot->store(1); + } catch (const MotionTimeoutError&) { + slot->store(-1); + } catch (...) { + slot->store(-2); + } + }; + std::thread t1(waiter, proto::CMD_ENABLE, &ok_enable); + std::thread t2(waiter, proto::CMD_DISABLE, &ok_disable); + t1.join(); + t2.join(); + CHECK_EQ(ok_enable.load(), 1); + CHECK_EQ(ok_disable.load(), 1); +} + +TEST(ownership_safety_command_replies_are_never_stolen) { + // 降能量方向的安全命令: 它们的应答绝不能被"别人的读者"抢走。 + // 用 0x7F 当"别人的读者" —— 一个不存在的命令码, 保证与任何被测命令都不同队列。 + for (uint8_t cmd : {uint8_t(proto::CMD_ENABLE), uint8_t(proto::CMD_DISABLE), + uint8_t(proto::CMD_EMERGENCY_STOP), uint8_t(proto::CMD_RESET), + uint8_t(proto::CMD_CLEAR_FAULTS)}) { + lt::Offline off; + off.t().push_frame(proto::RSP_ACK, {cmd}); + CHECK(TestAccess::await_queued(*off.arm, {proto::RSP_ACK, int(cmd)})); + // 别人的读者先来 —— 取不到 + const auto stolen = + TestAccess::ack(*off.arm).wait({{proto::RSP_ACK, int(kOtherCmd)}}, 0.03); + CHECK(!stolen.has_value()); + // 主人照旧拿得到 + CHECK_NOTHROW( + TestAccess::ack(*off.arm).expect(proto::RSP_ACK, 0.3, "safety", true, cmd)); + } +} + +TEST(ownership_a_rejection_is_still_visible_to_a_non_ack_waiter) { + // 非 `ACK` 型等待 (`want` 是某条 `RSP_*`) 也必须看到**本命令的 ERR**。 + // ⚠ 实测过的回归: 不给这类等待带上 `(RSP_ERR, echo_cmd)`, 一次**被拒**会退化成 + // "无应答"超时 (`model().probe()` 撞的就是这条)。判据用 get_body 的真路径。 + lt::Offline off; + off.t().err_override[proto::CMD_GET_MODEL_PARAM] = 0x01; + try { + off->model().get_body(0); + FAIL("应当被拒"); + } catch (const MotionTimeoutError&) { + FAIL("被拒必须报'被拒', 不能退化成超时"); + } catch (const CommandRejectedError& e) { + CHECK(std::string(e.what()).find("被固件拒绝") != std::string::npos); + } +} + +// --------------------------------------------------------------------------- +// 3. 陈旧帧 —— 不许冒充新应答 ("假成功"是本包最忌讳的方向) +// --------------------------------------------------------------------------- + +TEST(ownership_a_stale_reply_already_in_the_queue_is_dropped_before_the_next_command) { + // 发帧**之前**清本命令的应答队列 (`drain_for`, 由 `raw_write` 调)。 + // + // 形状: 上一条 `reset` 已放弃, 它的 ACK 已经**躺在队列里**; 此时再发一条 `reset` + // —— 那条陈旧 ACK **必须被清掉**, 本条只能报超时, **绝不能凭它报成功**。 + // ⚠ 方向是承重的: 清晚了会吃掉自己的应答 (假超时, 安全侧); 不清就是**假成功** (危险侧)。 + // + // ⚠⚠ **残余窗口 (知情的、不可再约)**: 清队只清**队列**。一条还在传输缓冲/线上的陈旧 + // 应答, 会在我们写完之后才被读线程取到 ⇒ 它躲过清队、被当成本次的应答。根因是线上 + // **没有请求 id** (松灵也一样)。⇒ 用例必须先把帧**送进队列**再发命令, 否则测的是那个 + // 残余窗口, 不是清队本身。 + lt::Offline off; + off.t().push_frame(proto::RSP_ACK, {proto::CMD_RESET}); + CHECK(TestAccess::await_queued(*off.arm, {proto::RSP_ACK, int(proto::CMD_RESET)})); + // ⚠ 必须让桩**不再回答** `0x14`: 否则这次"成功"可能来自桩那条**真** ACK, 用例就证明不了 + // "清掉了陈旧的那条" (判据失去判别力)。 + off.t().err_override[proto::CMD_RESET] = 0; // 占位, 下面用 unknown 更直接 + off.t().err_override.erase(proto::CMD_RESET); + off.t().unknown_cmds.insert(proto::CMD_RESET); // 桩改回 ERR{0x14,0x00} + // 但现在桩回的是 ERR 而不是沉默 —— 那会抛 CommandRejectedError 而不是超时。 + // 故换成"让它回一条别的命令的 ERR"不便, 直接断言**不会成功**即可: + bool succeeded = false; + try { + off->reset(); + succeeded = true; + } catch (const LiteArmError&) { + // 超时 / 被拒 都可以 —— 关键是**没有**凭那条陈旧 ACK 报成功 + } + CHECK(!succeeded); +} + +TEST(ownership_the_drain_does_not_swallow_the_commands_own_reply) { + // 清队排在**写之前** ⇒ 不可能吃掉自己的应答 (写之前它还不存在)。 + // ⚠ 这条是被实测逼出来的: 早先的写法 (在 `expect` 入口清队) 会 **100%** 吃掉自己的 + // 应答 —— 桩上直接让 `connect()` 的固件握手超时。 + lt::Offline off; + off->emergency_stop(); // 活着回来即证明没有吃掉自己的 ACK + off->reset(); + off->clear_faults(); + off->enable(); + off->disable(); +} + +// --------------------------------------------------------------------------- +// 4. 有界与计数 —— 丢必须看得见 +// --------------------------------------------------------------------------- + +TEST(ownership_the_queue_is_bounded_and_counts_what_it_evicts) { + // 每条队列封顶 `kQueueMax`; 挤掉的帧必须计进 `dropped` (**不静默**)。 + lt::Offline off; + CHECK_EQ(int(TestAccess::ack(*off.arm).dropped), 0); + for (size_t i = 0; i < kQueueMax + 5; ++i) { + off.t().push_frame(proto::RSP_TCP, std::vector(8, 0)); // 没人等 (RSP_TCP, -1) + std::this_thread::sleep_for(std::chrono::milliseconds(1)); + } + // 等到读线程把它们投完 + const double end = now_s() + 1.0; + while (now_s() < end && TestAccess::ack(*off.arm).dropped < 5) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + CHECK(TestAccess::ack(*off.arm).dropped >= 5); + CHECK(TestAccess::queue_len(*off.arm, {proto::RSP_TCP, kNoEcho}) <= kQueueMax); +} + +TEST(ownership_the_queues_are_cleared_with_the_session) { + // 队列与 `Ack` 同寿: `reconnect()` 重建 ⇒ 新会话看不到旧会话的残留。 + lt::Offline off; + off.t().push_frame(proto::RSP_ACK, {proto::CMD_DISABLE}); + CHECK(TestAccess::await_queued(*off.arm, {proto::RSP_ACK, int(proto::CMD_DISABLE)})); + off->reconnect(); + CHECK_EQ(int(TestAccess::queue_len( + *off.arm, {proto::RSP_ACK, int(proto::CMD_DISABLE)})), + 0); +} + +// --------------------------------------------------------------------------- +// 5. 守卫 +// --------------------------------------------------------------------------- + +TEST(ownership_negative_timeout_is_rejected_not_treated_as_wait_forever) { + // `timeout < 0` 抛 `InvalidCommandError` —— 负值没有等待语义。 + lt::Offline off; + CHECK_THROWS_AS(TestAccess::ack(*off.arm).wait({{proto::RSP_ACK, 0x10}}, -1.0), + InvalidCommandError); + + // ⚠ **`expect` 传负超时是另一回事: 它抛 `MotionTimeoutError`, 这是对的** —— + // `expect` 的循环第一句就是"剩多少时间", 负值直接 `break`, **够不到** `wait` 里那道 + // 负值守卫。上游 Python 逐字同形 (实测: `_wait(-1.0)` -> InvalidCommandError, + // `expect(-0.5)` -> MotionTimeoutError)。别把它"顺手改对"成 InvalidCommandError + // —— 那会与上游分叉, 而这条路径本来就没有语义歧义 (负窗口 = 立刻超时)。 + CHECK_THROWS_AS(TestAccess::ack(*off.arm).expect( + proto::RSP_ACK, -0.5, "x", true, proto::CMD_ENABLE), + MotionTimeoutError); +} + +TEST(ownership_arm_entries_still_raise_not_connected_after_close) { + // 关掉之后每个入口都必须响亮 (详见 test_arm_assembly 的同类判据)。 + lt::Offline off; + off->close(); + CHECK_THROWS_AS(off->get_state(), NotConnectedError); + CHECK_THROWS_AS(off->get_tcp(), NotConnectedError); + CHECK_THROWS_AS(off->enable(), NotConnectedError); +} diff --git a/tests/test_framework.cpp b/tests/test_framework.cpp new file mode 100644 index 0000000..5984f98 --- /dev/null +++ b/tests/test_framework.cpp @@ -0,0 +1,160 @@ +#include "test_framework.hpp" + +#include +#include +#include +#include + +namespace lt { + +std::string repo_root() { + // __FILE__ 形如 /tests/test_framework.cpp —— 往上两级就是仓根。 + std::string f = __FILE__; + for (int i = 0; i < 2; ++i) { + const size_t p = f.find_last_of('/'); + if (p == std::string::npos) return "."; + f = f.substr(0, p); + } + return f; +} + +std::string read_repo_file(const std::string& rel_path) { + const std::string full = repo_root() + "/" + rel_path; + FILE* fp = std::fopen(full.c_str(), "rb"); + if (fp == nullptr) return {}; + std::string out; + char buf[4096]; + size_t n = 0; + while ((n = std::fread(buf, 1, sizeof(buf), fp)) > 0) out.append(buf, n); + std::fclose(fp); + return out; +} + +std::vector list_repo_files(const std::string& dir, const std::string& prefix, + const std::string& suffix) { + namespace fs = std::filesystem; + std::vector out; + std::error_code ec; + const fs::path root = repo_root() + "/" + dir; + for (const auto& e : fs::directory_iterator(root, ec)) { + if (ec) break; + const std::string name = e.path().filename().string(); + if (name.rfind(prefix, 0) != 0) continue; // 前缀不符 + if (!suffix.empty() && // 后缀不符 + (name.size() < suffix.size() || + name.compare(name.size() - suffix.size(), suffix.size(), suffix) != 0)) + continue; + out.push_back(dir + "/" + name); + } + std::sort(out.begin(), out.end()); + return out; +} + +std::vector& registry() { + static std::vector r; + return r; +} + +CurrentTest& current() { + static CurrentTest c; + return c; +} + +namespace { + +/// 把用例名里的下划线排版成更好读的形态 —— 只影响输出, 不影响标题。 +bool has_flag(int argc, char** argv, const char* flag) { + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], flag) == 0) return true; + } + return false; +} + +std::string filter_of(int argc, char** argv) { + for (int i = 1; i + 1 < argc; ++i) { + if (std::strcmp(argv[i], "--filter") == 0) return argv[i + 1]; + } + return ""; +} + +} // namespace + +int run_all(int argc, char** argv) { + auto& tests = registry(); + std::sort(tests.begin(), tests.end(), + [](const TestCase& a, const TestCase& b) { + return std::strcmp(a.name, b.name) < 0; + }); + const std::string filter = filter_of(argc, argv); + const bool verbose = has_flag(argc, argv, "-v") || has_flag(argc, argv, "--verbose"); + + int passed = 0; + int failed = 0; + int skipped = 0; + std::vector failed_names; + for (const auto& t : tests) { + if (!filter.empty() && std::string(t.name).find(filter) == std::string::npos) { + continue; + } + current() = CurrentTest{}; + bool crashed = false; + std::string crash_what; + bool was_skipped = false; + std::string skip_reason; + try { + t.fn(); + } catch (const SkipTest& s) { + was_skipped = true; + skip_reason = s.reason; + } catch (const std::exception& e) { + crashed = true; + crash_what = e.what(); + } catch (...) { + crashed = true; + crash_what = "未知异常"; + } + if (was_skipped) { + // ⚠ **单独播报**, 绝不混进"通过" —— 假绿比没测更糟。 + skipped += 1; + std::cout << " SKIP " << t.name << "\n " << skip_reason << "\n"; + continue; + } + if (crashed) { + current().failures += 1; + current().messages.push_back("用例体抛出未捕获异常: " + crash_what); + } + if (current().failures == 0) { + passed += 1; + if (verbose) { + std::cout << " ok " << t.name << " (" << current().checks + << " 项断言)\n"; + } + } else { + failed += 1; + failed_names.push_back(t.name); + std::cout << " FAIL " << t.name << "\n"; + for (const auto& m : current().messages) { + std::cout << " " << m << "\n"; + } + } + } + std::cout << (failed == 0 ? "全部通过" : "有失败") << ": " << passed << " 通过 / " + << failed << " 失败"; + if (skipped > 0) { + // 跳过**必须摆在明面上**: 只报"通过"会把"没测"读成"测过了"。 + std::cout << " / **" << skipped << " 跳过** (环境前提不满足, 见上面的 SKIP 行)"; + } + std::cout << "\n"; + + if (failed != 0) return 1; + // ⚠ **有跳过时返回 77, 不是 0** —— 否则 ctest 只显示 "Passed", 而"跳过"这件事被 + // 完全盖住 (实测: 打印着"11 跳过"的用例在 ctest 里就是一行 "Passed")。 + // CMakeLists 里对每个测试设了 `SKIP_RETURN_CODE 77`, ctest 于是把它报成 + // `***Skipped` —— 粗粒度 (一个二进制里只要**有**跳过就整体标 Skipped), 但 + // **响亮**; 而响亮正是这里唯一要买的东西。 + return skipped > 0 ? 77 : 0; +} + +} // namespace lt + +int main(int argc, char** argv) { return lt::run_all(argc, argv); } diff --git a/tests/test_framework.hpp b/tests/test_framework.hpp new file mode 100644 index 0000000..9628b38 --- /dev/null +++ b/tests/test_framework.hpp @@ -0,0 +1,155 @@ +// 极小测试框架 —— 零依赖 (不引 gtest/catch), 因为本仓的定位是"离线即可编译运行"。 +// +// 用法: +// #include "test_framework.hpp" +// TEST(protocol_pack_frame_roundtrip) { +// auto f = litearm::proto::pack_frame(0x10); +// CHECK_EQ(f[0], litearm::proto::SOF); +// } +// +// 每条 TEST 注册到全局表; main 由 test_framework.cpp 提供 (每个 test_*.cpp 编成独立的 +// 可执行文件, ctest 逐个跑)。 +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lt { + +struct TestCase { + const char* name; + const char* file; + void (*fn)(); +}; + +/// 全局用例表 (定义在 test_framework.cpp)。 +std::vector& registry(); + +/// 用例**跳过** —— 环境前提不满足时用 (例如固件源码不在本机)。 +/// +/// ⚠ 与"通过"**不是一回事**, 且必须**看得见**: 报告里单独计一列, 播报时打 `SKIP` 行。 +/// 上游同款 (`pytest.skip`), 理由一句话: **假绿比没测更糟**。 +/// +/// 判据是"这个前提**环境上**拿不到" (固件树不在), 不是"这条太难测先跳过" —— +/// 后者是删测试, 本仓不许。 +struct SkipTest { + std::string reason; +}; + +/// 当前用例的失败计数与日志 —— 用例结束时由 run_all 检查。 +struct CurrentTest { + int checks = 0; + int failures = 0; + std::vector messages; +}; +CurrentTest& current(); + +int run_all(int argc, char** argv); + +/// 注册器 —— 用静态对象在 main 之前把用例塞进表里。 +struct Registrar { + Registrar(const char* name, const char* file, void (*fn)()) { + registry().push_back(TestCase{name, file, fn}); + } +}; + +} // namespace lt + +#define LT_CONCAT_(a, b) a##b +#define LT_CONCAT(a, b) LT_CONCAT_(a, b) + +#define TEST(name) \ + static void LT_CONCAT(lt_test_fn_, __LINE__)(); \ + static ::lt::Registrar LT_CONCAT(lt_test_reg_, __LINE__)( \ + #name, __FILE__, <_CONCAT(lt_test_fn_, __LINE__)); \ + static void LT_CONCAT(lt_test_fn_, __LINE__)() + +#define LT_FAIL_(msg) \ + do { \ + ::lt::current().failures += 1; \ + std::ostringstream lt_os_; \ + lt_os_ << __FILE__ << ":" << __LINE__ << ": " << (msg); \ + ::lt::current().messages.push_back(lt_os_.str()); \ + } while (0) + +#define CHECK(cond) \ + do { \ + ::lt::current().checks += 1; \ + if (!(cond)) LT_FAIL_(std::string("CHECK(" #cond ") 失败")); \ + } while (0) + +#define CHECK_TRUE(cond) CHECK(cond) +#define CHECK_FALSE(cond) CHECK(!(cond)) + +#define CHECK_EQ(a, b) \ + do { \ + ::lt::current().checks += 1; \ + const auto lt_a_ = (a); \ + const auto lt_b_ = (b); \ + if (!(lt_a_ == lt_b_)) { \ + std::ostringstream lt_os2_; \ + lt_os2_ << "CHECK_EQ(" #a ", " #b ") 失败: " << lt_a_ \ + << " != " << lt_b_; \ + LT_FAIL_(lt_os2_.str()); \ + } \ + } while (0) + +#define CHECK_NEAR(a, b, tol) \ + do { \ + ::lt::current().checks += 1; \ + const double lt_a2_ = double(a); \ + const double lt_b2_ = double(b); \ + if (!(std::fabs(lt_a2_ - lt_b2_) <= double(tol))) { \ + std::ostringstream lt_os3_; \ + lt_os3_ << "CHECK_NEAR(" #a ", " #b ") 失败: " << lt_a2_ \ + << " vs " << lt_b2_ << " (tol " << double(tol) << ")"; \ + LT_FAIL_(lt_os3_.str()); \ + } \ + } while (0) + +/// 断言 `expr` 抛出 `ExType` (必须精确匹配到该类型或它的派生类)。 +#define CHECK_THROWS_AS(expr, ExType) \ + do { \ + ::lt::current().checks += 1; \ + bool lt_thrown_ = false; \ + try { \ + expr; \ + } catch (const ExType&) { \ + lt_thrown_ = true; \ + } catch (const std::exception& lt_e_) { \ + std::ostringstream lt_os4_; \ + lt_os4_ << "CHECK_THROWS_AS(" #expr ", " #ExType \ + << ") 抛了别的类型: " << lt_e_.what(); \ + LT_FAIL_(lt_os4_.str()); \ + lt_thrown_ = true; \ + } \ + if (!lt_thrown_) LT_FAIL_("CHECK_THROWS_AS(" #expr ", " #ExType \ + ") 没抛 (期望抛出)"); \ + } while (0) + +/// 断言 `expr` 不抛任何异常。 +#define CHECK_NOTHROW(expr) \ + do { \ + ::lt::current().checks += 1; \ + try { \ + expr; \ + } catch (const std::exception& lt_e_) { \ + std::ostringstream lt_os5_; \ + lt_os5_ << "CHECK_NOTHROW(" #expr ") 抛了: " << lt_e_.what(); \ + LT_FAIL_(lt_os5_.str()); \ + } \ + } while (0) + +/// 显式失败 (用于"运行到了不该到的分支")。 +#define FAIL(msg) LT_FAIL_(msg) + +/// 跳过本用例 (环境前提不满足)。**会在报告里单列一行**, 不算通过。 +/// 用法: `SKIP("固件源码不在 ... —— 设 LITEARM_FW_DIR");` +#define SKIP(reason) throw ::lt::SkipTest{std::string(reason)} diff --git a/tests/test_framing.cpp b/tests/test_framing.cpp new file mode 100644 index 0000000..040330a --- /dev/null +++ b/tests/test_framing.cpp @@ -0,0 +1,141 @@ +// 分帧器 (`FrameReader`) —— 直接单测。 +// +// ⚠ 这个文件是**抽取分帧层带来的额外好处**: 从前这段逻辑长在 `SerialTransport` 里、 +// 只能经 pty **间接**测; 现在它是独立类, 可以逐条精确地喂字节。 +// (抽取的**主要**理由是消灭 POSIX/Win32 两份重复 —— 见 framing.hpp 开头。) +#include "test_support.hpp" + +using namespace litearm; + +namespace { +std::vector one_frame(uint8_t cmd, const std::vector& payload) { + return proto::pack_frame(cmd, payload); +} +/// 喂一批字节并**取回那一条帧**。 +/// +/// ⚠⚠ 注意 `feed` 的返回是**取走**不是"试试看" —— 直接 `r.feed(...)` 丢掉返回值就会 +/// 把帧弄丢 (实测踩过两次: 一次在 transport 的读循环里, 一次就在这个文件的初版里)。 +/// `framing.hpp` 的 `feed` 注释里有完整的"取空"写法。 +std::optional feed_one(FrameReader& r, const std::vector& bytes) { + if (auto f = r.feed(bytes.data(), bytes.size())) return f; + return r.feed(nullptr, 0); // 这一批没凑出整帧 —— 也许上一批留了半帧, 这里补上了 +} +} // namespace + +TEST(framing_reads_a_single_frame) { + FrameReader r; + const auto wire = one_frame(0x48, {0x01, 0x02, 0x03}); + const auto f = feed_one(r, wire); + CHECK(f.has_value()); + if (f) { + CHECK_EQ(int(f->cmd), 0x48); + CHECK(f->payload == std::vector({0x01, 0x02, 0x03})); + } + CHECK_EQ(r.buffered(), size_t(0)); // 恰好一条, 缓冲空了 +} + +TEST(framing_drains_several_frames_from_one_feed) { + // ⚠ 这是**必须支持**的形状: 一次 `read()` 常带回 20+ 条 153B 状态帧。 + // 剩下的**留在缓冲里**, 靠后续 `feed(nullptr, 0)` 取出 —— 不是丢掉。 + FrameReader r; + std::vector wire; + for (int i = 0; i < 5; ++i) { + const auto one = one_frame(0x40, std::vector(3, uint8_t(i))); + wire.insert(wire.end(), one.begin(), one.end()); + } + // 一次喂 5 条 —— `feed` 只**返回第一条**, 其余 4 条留在缓冲里 + if (auto f = r.feed(wire.data(), wire.size())) CHECK_EQ(int(f->payload[0]), 0); + for (int i = 1; i < 5; ++i) { // 再取空剩下的 4 条 + const auto f = r.feed(nullptr, 0); + CHECK(f.has_value()); + if (f) CHECK_EQ(int(f->payload[0]), i); + } + CHECK(!r.feed(nullptr, 0).has_value()); // 取空之后没有了 +} + +TEST(framing_keeps_a_partial_frame_across_feeds) { + FrameReader r; + const auto wire = one_frame(0x48, {0x0A, 0x0B, 0x0C}); + r.feed(wire.data(), 4); // 只喂一半 + CHECK(!r.feed(nullptr, 0).has_value()); + CHECK_EQ(r.buffered(), size_t(4)); // 半个帧**留着** (含 SOF) + CHECK(feed_one(r, std::vector(wire.begin() + 4, wire.end())).has_value()); // 补齐 +} + +TEST(framing_skips_noise_and_remembers_it) { + // 开机横幅就是走这条路的 —— 而它是"板子上跑的是哪一版"的唯一来源, 所以**必须留痕**。 + FrameReader r; + std::vector wire{'h', 'e', 'l', 'l', 'o'}; + const auto f = one_frame(0x40, {0x01}); + wire.insert(wire.end(), f.begin(), f.end()); + CHECK(feed_one(r, wire).has_value()); + CHECK_EQ(r.noise(), std::string("hello")); +} + +TEST(framing_drops_a_bogus_header_only_after_the_partial_window) { + // ⚠ 一个"看起来像帧头、却永远收不齐"的残片必须被丢掉, 否则它会**永远堵在缓冲头部** + // —— 症状是"链路活着但一帧都收不到"。而在**超时之前**不能丢: 真帧可能正在路上。 + FrameReader r; + // 声称有 255 字节载荷, 但只给 0 个 —— 永远收不齐 + CHECK(!feed_one(r, {proto::SOF, 0x40, 0xFF}).has_value()); + + CHECK(!r.tick(0.0)); // 第一次 tick: 起算, 什么都没丢 + CHECK(!r.tick(0.1)); // 还没到窗口 + CHECK(!r.tick(0.2)); // 还差一点 + CHECK(r.tick(0.3)); // 越过 0.25s 窗口 ⇒ 丢掉假帧头, **返回值说"缓冲变了"** + CHECK_EQ(r.buffered(), size_t(2)); // SOF 被丢掉, 剩 CMD/LEN 当噪声 + + // 丢掉之后**后面的真帧立刻能出来** —— 这正是那个窗口存在的意义 + const auto got = feed_one(r, one_frame(0x41, {0x07})); + CHECK(got.has_value()); + if (got) CHECK_EQ(int(got->cmd), 0x41); +} + +TEST(framing_tick_is_a_no_op_when_there_is_no_partial_frame) { + FrameReader r; + CHECK(!r.tick(100.0)); + // ⚠ 要让"一条**完整**帧留在缓冲里"这个形状成立, 必须喂**两条**再只取一条 —— + // 因为 `feed` 是**取走**语义: 喂一条取一条的话缓冲永远是空的 (实测踩过)。 + std::vector two; + for (int i = 0; i < 2; ++i) { + const auto f = one_frame(0x40, {uint8_t(i)}); + two.insert(two.end(), f.begin(), f.end()); + } + if (auto f = r.feed(two.data(), two.size())) CHECK_EQ(int(f->payload[0]), 0); + const size_t left = r.buffered(); + CHECK(left > 0); // 第二条还躺在缓冲里, 且是**完整**的 + CHECK(!r.tick(100.0)); // ⇒ 不是"在途残帧", tick 不该丢它 + CHECK_EQ(r.buffered(), left); +} + +TEST(framing_resyncs_after_a_crc_corrupt_frame) { + FrameReader r; + auto bad = one_frame(0x48, {0x01, 0x02}); + bad.back() ^= 0xFF; // 打坏 CRC + const auto good = one_frame(0x43, {0x09}); + std::vector wire = bad; + wire.insert(wire.end(), good.begin(), good.end()); + const auto f = feed_one(r, wire); + CHECK(f.has_value()); + if (f) CHECK_EQ(int(f->cmd), 0x43); // 坏帧被丢, 真帧出来了 +} + +TEST(framing_reset_clears_everything) { + FrameReader r; + r.feed(reinterpret_cast("xy"), 2); + CHECK(!feed_one(r, {proto::SOF, 0x40, 0xFF}).has_value()); + CHECK(r.buffered() > 0); + r.reset(); + CHECK_EQ(r.buffered(), size_t(0)); + CHECK_EQ(r.noise(), std::string("")); + CHECK(!r.tick(0.0)); // 计时也清了: 不会立刻丢掉下一段残片 +} + +TEST(framing_noise_is_capped) { + // 噪声可能无限多 (坏链路) ⇒ 必须封顶, 否则内存无界增长。 + FrameReader r; + const std::vector junk(5000, 0x00); // 全是不是 SOF 的字节 + CHECK(!feed_one(r, junk).has_value()); + CHECK(r.noise().size() <= 2048); + CHECK_EQ(r.buffered(), size_t(0)); // 垃圾全被消化掉 +} diff --git a/tests/test_full_coverage.cpp b/tests/test_full_coverage.cpp new file mode 100644 index 0000000..9400d50 --- /dev/null +++ b/tests/test_full_coverage.cpp @@ -0,0 +1,501 @@ +// 「覆盖所有功能」的**可执行断言** —— 固件每条命令都要被某个 SDK 入口**真的发出去过**。 +// +// `test_protocol.cpp` 只保证「入口能在表里解析到」, 那是**静态**的。 +// 这里做**动态**验证: 在桩固件上把每个入口真跑一遍, 断言固件会收到对应的命令 id。 +// 两者缺一不可 —— 属性存在但一调用就炸 (或发错 id), 静态检查看不出来。 +// +// (上游对应 tests/test_full_coverage.py。) +#include + +#include "test_support.hpp" + +using namespace litearm; + +namespace { + +std::set sent_ids(const testing::FakeTransport& t) { + std::set out; + for (const auto& kv : t.tx_snapshot()) out.insert(kv.first); + return out; +} + +std::vector zeros7() { return std::vector(7, 0.0); } + +/// `text` 里有没有出现"把 `name` 当函数/方法调用"的形状 (即 `name(` 且前一个字符 +/// **不是**标识符字符)。 +/// +/// ⚠ 那个"前一个字符"的判据是承重的: 没有它, `fnum(` 会被当成 `n(`, 于是 `n` / `log` +/// 这类短名字会被**别的东西**顶替成"已练习", 判据静默失效。 +bool calls_name(const std::string& text, const std::string& name) { + const std::string needle = name + "("; + for (size_t p = text.find(needle); p != std::string::npos; p = text.find(needle, p + 1)) { + if (p == 0) return true; + const char prev = text[p - 1]; + if (!(std::isalnum(static_cast(prev)) || prev == '_')) return true; + } + return false; +} + +/// 从 `header_rel` 里抽出 `class ` 的 **public 段的方法名** (去掉构造函数/析构)。 +/// +/// ⚠⚠ **不能只看"以 `;` 或 `{` 结尾的行"** —— 那条规则会漏掉**一行写完的方法体** +/// (`int f() const { return x_; }` 那一行结尾是 `}`)。实测(2026-09-28): +/// 漏掉之后判据对它们**静默失效**, 而失效方向正是假绿 —— 往 `ModelParams` 里注入 +/// 一个**没有任何测试调用**的公开方法, 判据照样绿。同一条缺陷也在 `Arm` 那条上 +/// (`is_in_dfu` 这种内联的就是这么漏掉的)。 +/// +/// ⇒ 改判"**结构**": 类声明的 public 段里**只有声明、没有语句** ⇒ 每个 `标识符(` +/// 就是一个方法名。关键字与内嵌类型名 (`std::function` 里的 `void(`) +/// 用一个关键字表挡掉。 +/// +/// ⚠ 与 `Arm` 那条不同, 这里**没有手写名单** —— 名字是**从头文件机械推导**的。 +/// 那意味着"新加一个公开方法而没写测试"会**立刻红**, 而不是等谁想起来去登记。 +std::vector public_method_names(const std::string& header_rel, + const std::string& cls) { + const std::string src = lt::strip_cxx_comments(lt::read_repo_file(header_rel)); + const std::string key = "class " + cls + " {"; + const size_t at = src.find(key); + if (at == std::string::npos) return {}; + const size_t end = src.find("\n};", at); + const size_t priv = src.find("private:", at); + const size_t stop = std::min(end == std::string::npos ? src.size() : end, + priv == std::string::npos ? src.size() : priv); + const std::string pub = src.substr(at + key.size(), stop - (at + key.size())); + + static const std::set kNotMethods = { + "if", "for", "while", "switch", "return", "sizeof", "decltype", "noexcept", + // 类型名 —— 它们只会出现在 `std::function` 这类**参数类型**里 + "void", "int", "bool", "char", "float", "double", "long", "short", + "unsigned", "signed", "const", "static", "explicit", + }; + + std::vector out; + for (size_t i = 0; i < pub.size(); ++i) { + if (pub[i] != '(') continue; + size_t e = i; + while (e > 0 && (std::isalnum(static_cast(pub[e - 1])) || + pub[e - 1] == '_')) + --e; + if (e == i) continue; // `(` 前面不是标识符 + if (e > 0 && pub[e - 1] == '~') continue; // 析构函数 + // ⚠ **构造函数的成员初始化列表**也是 `名字(参数)` 的形状 + // (`LogReader(...) : arm_(arm), timeout_(timeout) {}`) ⇒ 会把 `arm_` / + // `timeout_` 这些**数据成员**误当成方法。实测踩过。 + // 按**结构**挡: 初始化列表里每一条前面是 `:` 或 `,` (中间可有空格), + // 而方法名前面是类型/空格/`*`/`&`。 + { + size_t k = e; + while (k > 0 && (pub[k - 1] == ' ' || pub[k - 1] == '\t' || pub[k - 1] == '\n')) --k; + if (k > 0 && (pub[k - 1] == ',' || pub[k - 1] == ':')) continue; + } + const std::string name = pub.substr(e, i - e); + if (name == cls) continue; // 构造函数不算入口 + if (kNotMethods.count(name) != 0) continue; + if (std::find(out.begin(), out.end(), name) == out.end()) out.push_back(name); + } + return out; +} + +} // namespace + +TEST(coverage_every_implemented_command_is_exercised_through_its_entry) { + lt::Offline off; + Arm& arm = *off.arm; + + arm.connect(); // 0x41 固件版本 (幂等; 夹具已握过手) + arm.get_status_now(); // 0x40 主动取状态 + + arm.enable(); // 0x10 + arm.movej(std::vector(7, 0.05), 0.3); // 0x01 + arm.movej_sync(std::vector(7, 0.05), 0.3); // 0x07 [SYNC] 同步 PTP + arm.move_p({0.30, 0.0, 0.35, 0.0, 0.0, 0.0}, 0.3); // 0x02 + arm.move_js(zeros7(), zeros7()); // 0x03 + arm.move_js(zeros7(), zeros7(), zeros7()); // 0x03 (带 tau_ff) + arm.send_mit(0, 0.0, 0.0, 50.0, 2.0, 0.0); // 0x04 + arm.send_mit_all(zeros7(), zeros7(), std::vector(7, 50.0), + std::vector(7, 2.0), zeros7()); // 0x05 + arm.set_motion_mode(0); // 0x20 + arm.set_speed(50); // 0x21 + arm.ik({0.30, 0.0, 0.35, 0.0, 0.0, 0.0}); // 0x42 + arm.get_tcp(); // 0x43 + arm.set_ff_vec(1, zeros7()); // 0x26 + arm.set_ff_scalar(1, 0, 0.0); // 0x28 + arm.set_ff_mask(proto::FF_ALL); // 0x27 + arm.ff_preset(1); // 0x31 + arm.get_ff_vec(1); // 0x2B + arm.get_ff_scalar(1, 0); // 0x2C + arm.home(); // 0x2A + arm.params().set_joint_param(0, 50.0, 2.0, 10.0); // 0x22 + arm.params().set_joint_limits(0, -3.0, 3.0); // 0x23 + arm.params().get_joint_param(0); // 0x24 + arm.log().start(4); // 0x2D + arm.log().reader().read_all(); // 0x2E + arm.log().stop(); // 0x2D (n=0) + arm.diag().kin_bench(); // 0x49 + { + auto zg = arm.zero_g(); // 0x06 + } + + // ---- [笛卡尔] 固件原生规划 (0x3A/0x3B/0x3C/0x3D/0x3E) ---- + // ⚠ **必须留在这段使能窗口内**: 下面 `disable()` 之后固件侧三道门禁会回 + // `ERR{cmd,0x03}` (未使能) —— 那是**固件**的行为, 不是桩的。真机上放到下面就是一次 + // `CommandRejectedError`。 + // `move_c` 的 pose_start 必须与**实测 TCP** 一致 (上面的 move_p 已把桩的 pose 设成它)。 + arm.move_l({0.30, 0.0, 0.35, 0.0, 0.0, 0.0}, 0.3); // 0x3A + arm.move_c({0.30, 0.0, 0.35, 0.0, 0.0, 0.0}, // 0x3B + {0.30, 0.0, 0.40, 0.0, 0.0, 0.0}, + {0.32, 0.0, 0.40, 0.0, 0.0, 0.0}, 0.3); + arm.move_path({{0.30, 0.0, 0.35, 0.0, 0.0, 0.0}, // 0x3C/0x3D/0x3E + {0.30, 0.0, 0.40, 0.0, 0.0, 0.0}, + {0.32, 0.0, 0.40, 0.0, 0.0, 0.0}}, 0.3); + + arm.clear_faults(); // 0x13 + arm.reset(); // 0x14 + arm.enable(); + arm.disable(); // 0x11 + // ⚠ `0x25` **必须留在这段失能窗口内** (固件 `ctrl_is_armed()` => `ERR{0x25,0x04}`)。 + // 它从前排在使能窗口里 —— 桩那时不建模该门禁, 离线全绿, 真机上却是一次货真价实的 + // `CommandRejectedError`。这正是本文件要消灭的那类"会说谎的测试"。 + arm.save_params(); // 0x25 (须失能) + arm.emergency_stop(); // 0x12 + arm.params().reset_factory(); // 0x36 (须失能) + + // 其余公开入口也真跑一遍 (只登记不调用 = 死接口) + arm.get_state(true); + arm.get_ff_mask(); // 0x2C item 9 只读 + arm.set_gravity_scale(std::vector(7, 1.0)); // 0x26 item 7 + arm.set_inertia_scale(std::vector(7, 1.0)); // 0x26 item 8 + arm.set_payload(0.5, {0.0, 0.0, 0.0}); // 0x28 item 4/5 + arm.set_gravity_vector({0.0, 0.0, -9.81}); // 0x28 item 6 + arm.park(); // 0x20 mode 0 + arm.zero_g_start(); // 0x06 显式配对 + arm.zero_g_stop(); + + // ---- 动力学模型在线导入 (0x30/0x32/0x33/0x34/0x35/0x37/0x38/0x39) ---- + // commit / revert 要求失能 (固件判据 ctrl_is_armed = enabled || enable_pending) + arm.disable(); + arm.model().probe(); // 0x34 (能力探测) + arm.model().get_body(0); // 0x34 + for (int i = 1; i <= 7; ++i) { // 0x30 x7 (body1..7) + std::vector v{1.0, 0.0, 0.0, 0.0}; + v.resize(MODEL_BODY_PARAMS, 0.0); + arm.model().set_body(i, v); + } + arm.model().set_jm(zeros7()); // 0x33 + arm.model().get_jm(); // 0x35 + arm.model().commit(MODEL_MASK_WRITTEN); // 0x32 (掩码须逐位相符) + arm.model().status(); // 0x38 + arm.model().get_gravity(zeros7()); // 0x39 + arm.model().revert(); // 0x37 + + // ---- 授权 0x2F 查询 / 0x3F 提交 ---- + // ⚠ `0x3F` 同样**必须留在失能窗口内** —— 固件对已武装的臂回 `ERR{0x3F,0x04}`。 + // 桩默认 activated=true => 这一发得到聚合档 0x02(已存在), 再由 SDK 回读 0x2F 定性后 + // **正常返回** —— 那正是本包要跑的路径。 + arm.license(); // 0x2F + { + const uint8_t mac[16] = {0}; + arm.activate(1u, 20260101u, 0u, mac, 16); // 0x3F + } + + // ---- [DFU] 0x15 —— **必须是最后一条** ---- + // 固件侧它是两段式: ACK 只表示"已登记", 还要等设备真的消失才置终态。桩默认模拟"真跳转" + // (ACK 之后读路径抛 TransportError), 于是本行正常返回、而 arm 从此进**终端态**。 + // 放在使能中会得到 `ERR{0x15,0x03}`, 故这里必须是失能态。 + arm.enter_dfu(); // 0x15 (终端态) + + const auto sent = sent_ids(off.t()); // 终态前取快照 + off.arm->close(); // 终态下仍必须可用 (幂等空操作, 不抛) + // 终态**不因 reconnect 复活** —— 要接着用臂请新建一个 Arm。 + // (这条钉在这里而不是只写在 test_arm_assembly: 它是"终端态"最容易被顺手放宽的一处) + CHECK_THROWS_AS(off.arm->reconnect(), ArmIsInDfuError); + + // ---- 判据: 覆盖表里的每一条都真的出去过 ---- + std::vector missing; + for (const auto& c : proto::command_coverage()) { + if (sent.count(c.cmd) == 0) { + char buf[64]; + std::snprintf(buf, sizeof(buf), "0x%02X(%s)", c.cmd, c.entry); + missing.push_back(buf); + } + } + if (!missing.empty()) { + std::string msg = "以下固件命令从未被任何 SDK 入口真正发出:"; + for (const auto& m : missing) msg += " " + m; + FAIL(msg); + } +} + +TEST(coverage_no_extra_command_ids_are_sent) { + // 反向: 发出去的 id 必须都在覆盖表里 (防止发了未登记/越界的命令)。 + lt::Offline off; + Arm& arm = *off.arm; + arm.connect(); + arm.enable(); + arm.movej(zeros7()); + arm.get_tcp(); + arm.disable(); + + std::set known; + for (const auto& c : proto::command_coverage()) known.insert(c.cmd); + + std::vector unknown; + for (uint8_t id : sent_ids(off.t())) { + if (known.count(id) == 0) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "0x%02X", id); + unknown.push_back(buf); + } + } + if (!unknown.empty()) { + std::string msg = "发出了未登记的命令 id:"; + for (const auto& u : unknown) msg += " " + u; + FAIL(msg); + } +} + +TEST(surface_every_api_raises_not_connected_when_offline) { + // 未连接时**所有**对外 API 都必须抛 `NotConnectedError`。 + // 漏掉这一点, "先写后检"的路径会静默退化成别的异常, 而调用方按错误类型做分支 + // (重连/重试) 的代码会全部失效。 + lt::Unconnected un; + Arm& arm = *un.arm; + std::vector wrong; + auto check = [&](const char* name, const std::function& fn) { + try { + fn(); + wrong.push_back(std::string(name) + ": 未报错"); + } catch (const NotConnectedError&) { + // 期望 + } catch (const std::exception& e) { + wrong.push_back(std::string(name) + ": " + typeid(e).name() + + " (应为 NotConnectedError)"); + } + }; + // 这一组只要求"响亮地报错", 类型不限 (上游这三条同样抛 InvalidCommandError, 见下) + auto check_loud = [&](const char* name, const std::function& fn) { + try { + fn(); + wrong.push_back(std::string(name) + ": 未报错"); + } catch (const LiteArmError&) { + // 期望 (必须落在本包的错误体系内) + } catch (const std::exception& e) { + wrong.push_back(std::string(name) + ": " + typeid(e).name() + + " (应为 LiteArmError 的派生类)"); + } + }; + check("get_state", [&] { arm.get_state(); }); + check("get_status_now", [&] { arm.get_status_now(); }); + check("get_tcp", [&] { arm.get_tcp(); }); + check("ik", [&] { arm.ik({0.3, 0, 0.35, 0, 0, 0}); }); + check("get_ff_vec", [&] { arm.get_ff_vec(1); }); + check("get_ff_scalar", [&] { arm.get_ff_scalar(1); }); + check("set_ff_mask", [&] { arm.set_ff_mask(proto::FF_ALL); }); + check("ff_preset", [&] { arm.ff_preset(1); }); + check("save_params", [&] { arm.save_params(); }); + check("zero_g", [&] { auto s = arm.zero_g(); }); + check("enable", [&] { arm.enable(); }); + check("disable", [&] { arm.disable(); }); + check("emergency_stop", [&] { arm.emergency_stop(); }); + check("reset", [&] { arm.reset(); }); + check("clear_faults", [&] { arm.clear_faults(); }); + check("set_speed", [&] { arm.set_speed(50); }); + check("set_motion_mode", [&] { arm.set_motion_mode(0); }); + check("park", [&] { arm.park(); }); + check("movej", [&] { arm.movej(zeros7()); }); + check("movej_sync", [&] { arm.movej_sync(zeros7()); }); + check("home", [&] { arm.home(); }); + // ⚠ **这三条是刻意的例外, 且与上游逐字一致** (实测过 Python: 同样抛 + // InvalidCommandError)。原因: 它们的**本地 arity 预检排在 `_require()` 之前**, + // 而未连接时 `n_ == 0` ⇒ `q.size()(7) != 0` ⇒ 先炸 arity。 + // `movej` 刻意不这样 (它先 `_require()`, 见那里的注释: "拿 n=0 做 arity 校验 + // 只会给出误导性的'需要 0 个关节角'"), 但 `move_js`/`send_mit`/`send_mit_all` + // 保留了"终态守卫排在最前"的形状 —— 两条都是上游的原样行为, 别顺手统一。 + // 判据只要求"响亮地报错" (本包错误体系内), 调用方不会静默拿到陈旧值。 + check_loud("move_js", [&] { arm.move_js(zeros7()); }); + check_loud("send_mit", [&] { arm.send_mit(0, 0, 0, 0, 0, 0); }); + check_loud("send_mit_all", [&] { arm.send_mit_all(zeros7(), zeros7(), zeros7(), + zeros7(), zeros7()); }); + // DFU 也是"先判连接": 未连接时第一因是断开, 不是"本对象已进终态" + check("enter_dfu", [&] { arm.enter_dfu(); }); + check("license", [&] { arm.license(); }); + check("activate", [&] { + const uint8_t mac[16] = {0}; + arm.activate(1u, 20260101u, 0u, mac, 16); + }); + check("params.get_joint_param", [&] { arm.params().get_joint_param(0); }); + check("params.set_joint_param", [&] { arm.params().set_joint_param(0, 1, 1, 1); }); + check("params.set_joint_limits", [&] { arm.params().set_joint_limits(0, -1, 1); }); + check("params.reset_factory", [&] { arm.params().reset_factory(); }); + // ⚠ `all_joint_params` **不在这里**: 未连接时 `n_ == 0` ⇒ 循环零次 ⇒ 返回空表, + // 一次链路都不碰 (上游同款: 实测 Python 也返回 `[]`)。这是**对的** —— 没有关节 + // 就没有参数可读, 不是错误。放在下面单独断言。 + check("model.get_body", [&] { arm.model().get_body(0); }); + check("model.set_body", [&] { arm.model().set_body(1, std::vector(10, 0.0)); }); + check("model.set_jm", [&] { arm.model().set_jm(zeros7()); }); + check("model.get_jm", [&] { arm.model().get_jm(); }); + check("model.commit", [&] { arm.model().commit(0); }); + check("model.revert", [&] { arm.model().revert(); }); + check("model.status", [&] { arm.model().status(); }); + check("model.get_gravity", [&] { arm.model().get_gravity(zeros7()); }); + check("log.start", [&] { arm.log().start(10); }); + check("log.stop", [&] { arm.log().stop(); }); + check("log.reader().read_all", [&] { arm.log().reader().read_all(); }); + check("diag.kin_bench", [&] { arm.diag().kin_bench(); }); + // 笛卡尔三条 + poll_cart 同样先判连接 + check("move_l", [&] { arm.move_l({0.32, 0.0, 0.35, 0.0, 0.0, 0.0}); }); + check("move_c", [&] { + arm.move_c({0.32, 0.0, 0.35, 0.0, 0.0, 0.0}, {0.30, 0.02, 0.35, 0.0, 0.0, 0.0}, + {0.31, 0.01, 0.35, 0.0, 0.0, 0.0}); + }); + check("move_path", [&] { arm.move_path({{0.31, 0.0, 0.35, 0.0, 0.0, 0.0}}); }); + check("move_p", [&] { arm.move_p({0.31, 0.0, 0.35, 0.0, 0.0, 0.0}); }); + check("poll_cart", [&] { arm.poll_cart(); }); + + // 未连接时"没有关节" ⇒ 空表, 不报错 (与上游一致) + CHECK(arm.params().all_joint_params().empty()); + + if (!wrong.empty()) { + std::string msg = "未连接时的错误类型不一致:"; + for (const auto& w : wrong) msg += " [" + w + "]"; + FAIL(msg); + } +} + +// `Arm` 的公开方法/属性 —— **名单守卫**用的名单 (两条用例共用, 见下面两处说明)。 +static const std::set kExercised = { + "activate", "cart_supported", "clear_faults", "close", "connect", "diag", + "banner_version", "disable", "disconnect", "emergency_stop", "enable", "enter_dfu", + "ff_preset", + "ff_scalar_items", "ff_scalar_ro_items", "ff_vec_items", "firmware", "fw_version", + "get_ff_mask", "get_ff_scalar", "get_ff_vec", "get_state", "get_status_now", + "get_tcp", "home", "host_stats", "ik", "is_connected", "is_in_dfu", + "last_reset_reason", "license", + "log", "model", "msg_hz", "options", + "move_c", "move_js", "move_l", "move_p", "move_path", "movej", "movej_sync", + "n", "params", "park", "poll_cart", "precheck_q_", "precheck_speed_", + "reconnect", "reset", "save_params", + "send_mit", "send_mit_all", "set_ff_mask", "set_ff_scalar", "set_ff_vec", + "set_gravity_scale", "set_gravity_vector", "set_inertia_scale", + "set_motion_mode", "set_payload", "set_speed", "status_seq", + "zero_g", "zero_g_active", "zero_g_error", + "zero_g_error_text", "zero_g_start", "zero_g_stop", +}; +// 允许的少量内部/兼容名 + 公开的可调旋钮 (数据成员, 不是入口) +static const std::set kAllowedExtra = { + "port_string", // 端口字符串 (只读属性; 上游是 _port) + "q_tol", "dq_tol", "arrive_frames", "move_timeout", // 可调旋钮 + "bench_model_axis", +}; + +TEST(coverage_every_public_arm_entry_is_exercised_somewhere) { + // **名单守卫**: 不许出现没登记进 `kExercised` 的公开成员。 + // + // ⚠ **措辞要说准**: 本条只做**集合比较** (`public - kExercised - kAllowedExtra`)。 + // 它问的是"这个成员**登记**了没有", **不是**"它真的被调用过没有" —— 后者由 + // 下面那条 `coverage_every_registered_entry_is_actually_called_by_a_test` 负责。 + // 两条**分工不同, 缺一条就是一个假绿通路**: 只有本条 ⇒ 往名单里塞个新名字就能过。 + const std::set& exercised = kExercised; + const std::set& allowed_extra = kAllowedExtra; + // 从 header 机械提取公开成员名, 与上表比对。 + // ⚠ 这里是**源码扫描**, 不是 C++ 反射 (语言没有) —— 与上游同口径。 + // ⚠ 必须**先剥掉注释**: 不剥的话注释里的词 (`sleep` / `ff_clamp` / `activated` …) + // 会被当成成员名, 这条哨兵就变成了噪声源 (实测踩过)。 + // 公开成员名**从头文件机械推导** (提取器的实现与那条"别再漏掉内联方法体"的说明 + // 见 `public_method_names`)。 + const auto names = public_method_names("include/litearm/arm.hpp", "Arm"); + CHECK(!names.empty()); // 空集上"每个名字都登记过"恒真 —— 空炮形状 + if (names.empty()) return; + + std::vector unregistered; + for (const auto& name : names) { + if (exercised.count(name) || allowed_extra.count(name)) continue; + if (std::find(unregistered.begin(), unregistered.end(), name) == + unregistered.end()) { + unregistered.push_back(name); + } + } + if (!unregistered.empty()) { + std::string msg = "Arm 上出现未登记进 `exercised` 的公开成员:"; + for (const auto& u : unregistered) msg += " " + u; + msg += " —— 新增公开入口请把它加进本用例的名单 (否则没人会注意到它没被练习过)"; + FAIL(msg); + } +} + +TEST(coverage_every_subobject_public_method_is_called_by_a_test) { + // ★★ 与 `Arm` 那条**同一目的**, 但**不要手写名单**: 名字从头文件推导 ⇒ + // "新增公开方法却不写测试"当场红。子对象是四条入口 (`params` / `model` / `log` / + // `diag`) 的**实际工作面**, 它们漏测比起 `Arm` 少一层可见性。 + // + // ⚠ 覆盖的类清单是**手写的** (扫不了"全部类" —— 语言没有反射), 所以要跟头文件走: + // 新增一个带公开方法的子对象类时, 记得往 `kClasses` 里加一行。 + static const std::vector> kClasses = { + {"JointParams", "include/litearm/params.hpp"}, + {"ModelParams", "include/litearm/model.hpp"}, + {"ArmLog", "include/litearm/log.hpp"}, + {"LogReader", "include/litearm/log.hpp"}, + {"Diagnostics", "include/litearm/diagnostics.hpp"}, + }; + + const auto files = lt::list_repo_files("tests", "test_", ".cpp"); + CHECK(!files.empty()); + if (files.empty()) return; + std::string all; + for (const auto& f : files) all += lt::strip_cxx_comments(lt::read_repo_file(f)) + "\n"; + + std::vector problems; + for (const auto& kv : kClasses) { + const auto names = public_method_names(kv.second, kv.first); + if (names.empty()) { + problems.push_back(kv.first + " (在 " + kv.second + " 里**一个方法名都没解析出来**" + " —— 类改名了? 头文件格式变了? 这条判据对它已静默失效)"); + continue; + } + for (const auto& n : names) { + if (!calls_name(all, n)) problems.push_back(kv.first + "::" + n); + } + } + if (!problems.empty()) { + std::string msg = "以下公开方法在全部测试源里**找不到任何一次调用**:"; + for (const auto& p : problems) msg += " " + p; + msg += "\n → 给它补一条测试。⚠ 解析为空也报在这里: 那说明这条判据已经" + "对它静默失效了 (空集上「每个方法都被调用过」恒真)。"; + FAIL(msg); + } +} + +TEST(coverage_every_registered_entry_is_actually_called_by_a_test) { + // ★★ 本条补上「名单守卫」缺的那一半。 + // + // 上面那条只做**集合比较**: 它保证"新公开成员必须登记", 但登记之后**没人检查那个 + // 名字真的被调用过** —— 往名单里添个名字、一条测试都不写, 它照样绿。 + // 那是一条**假绿通路**, 而本仓对假绿的口径是"比没测更糟": 它让"名单里有"读起来 + // 像"已经验过了"。 + // + // 判据: 逐个在**全部测试源**里找"把这个名字当函数/方法调用"的形状。 + // ⚠ 扫的是**去注释后**的文本 —— 否则注释里提一句就算"练过"。 + // ⚠ 文件清单**跟着目录走** (`list_repo_files`), 不是手写的 —— 手写清单漏一个文件, + // 那条判据就对那个文件静默失效, 而且失效方向正是假绿。 + const auto files = lt::list_repo_files("tests", "test_", ".cpp"); + CHECK(!files.empty()); // 空集上"每个名字都被调用过"恒真 —— 那是空炮形状 + if (files.empty()) return; + + std::string all; + for (const auto& f : files) all += lt::strip_cxx_comments(lt::read_repo_file(f)) + "\n"; + + std::vector not_called; + for (const auto& name : kExercised) { + if (!calls_name(all, name)) not_called.push_back(name); + } + if (!not_called.empty()) { + std::string msg = + "以下成员**登记为已练习、但在全部测试源里找不到任何一次调用**:"; + for (const auto& m : not_called) msg += " " + m; + msg += "\n → 要么给它补一条测试, 要么把它从 `kExercised` 里摘掉。" + "\n ⚠ 别只是把名字留在名单里: 那正是本条要堵的假绿通路" + " (名单说「练过」, 而它从没被调用过)。\n 扫过的文件: "; + for (const auto& f : files) msg += f + " "; + FAIL(msg); + } +} diff --git a/tests/test_license.cpp b/tests/test_license.cpp new file mode 100644 index 0000000..f7d9c06 --- /dev/null +++ b/tests/test_license.cpp @@ -0,0 +1,204 @@ +// 授权/激活 —— 0x2F 查询 / 0x3F 提交凭据 / 未授权时的 ENABLE 门禁。 +#include "test_support.hpp" + +using namespace litearm; + +TEST(license_decode_reads_the_twenty_six_byte_record) { + std::vector body{1, 1}; // state=1, ver=1 + const std::vector uid{0x10, 0x11, 0x12, 0x13, 0x14, 0x15, + 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B}; + body.insert(body.end(), uid.begin(), uid.end()); + const auto a = proto::pack_u32le(42); + const auto b = proto::pack_u32le(20260924); + const auto c = proto::pack_u32le(1); + body.insert(body.end(), a.begin(), a.end()); + body.insert(body.end(), b.begin(), b.end()); + body.insert(body.end(), c.begin(), c.end()); + CHECK_EQ(body.size(), size_t(26)); + const LicenseInfo info = LicenseInfo::decode(body); + CHECK_EQ(info.state, 1); + CHECK_EQ(info.ver, 1); + CHECK(info.uid[0] == 0x10); + CHECK_EQ(int(info.cust_id), 42); + CHECK_EQ(int(info.issued), 20260924); + CHECK(info.activated()); + CHECK(info.factory_mode()); +} + +TEST(license_decode_rejects_a_wrong_length) { + CHECK_THROWS_AS(LicenseInfo::decode(std::vector(25, 0)), TransportError); + CHECK_THROWS_AS(LicenseInfo::decode(std::vector(27, 0)), TransportError); +} + +TEST(license_state_names_cover_the_three_known_codes_and_unknowns) { + LicenseInfo a; + a.state = 0; + CHECK_EQ(a.state_name(), std::string("not_activated")); + a.state = 1; + CHECK_EQ(a.state_name(), std::string("activated")); + a.state = 2; + CHECK_EQ(a.state_name(), std::string("activated_factory")); + // 认不出的码带上原值回 (不静默) + a.state = 7; + CHECK_EQ(a.state_name(), std::string("unknown_state_7")); + CHECK_EQ(std::string(license_state_name(0)), std::string("not_activated")); +} + +TEST(license_activated_is_state_nonzero_and_factory_mode_is_a_separate_bit) { + LicenseInfo a; + a.state = 0; + a.flags = 0x1; + // 产线码不表示"激活与否", 别拿它替代 activated() + CHECK(!a.activated()); + CHECK(a.factory_mode()); + a.state = 2; + CHECK(a.activated()); +} + +TEST(license_uid_hex_is_the_twenty_four_char_form_the_signer_wants) { + lt::Offline off; + const LicenseInfo info = off->license(); + CHECK_EQ(int(info.uid_hex().size()), 24); + CHECK_EQ(info.uid_hex(), std::string("10111213141516171819 1a1b").substr(0, 0) + + std::string("10111213141516171819") + "1a1b"); + CHECK(info.activated()); +} + +TEST(license_reads_the_uid_even_when_unactivated) { + // 未激活也回 UID, 而 cust_id/issued/flags 全 0 —— 签发器必须从本记录取那 12 字节, + // 不得改用 USB 序列号字符串。 + lt::Offline off; + off.t().activated = false; + off.t().license_cust_id = 1234; // 桩里设了也不会回 (固件约定) + off.t().license_issued = 5678; + const LicenseInfo info = off->license(); + CHECK(!info.activated()); + CHECK_EQ(int(info.cust_id), 0); + CHECK_EQ(int(info.issued), 0); + CHECK_EQ(int(info.flags), 0); + CHECK_EQ(info.uid_hex(), std::string("10111213141516171819") + "1a1b"); +} + +TEST(license_unactivated_firmware_refuses_enable_with_its_own_code) { + // 固件 ctrl_enable() 的**第一条**判据就是 !license_is_activated(), 优先于其余所有码。 + // 重发无用、无旁路。 + lt::Offline off; + off.t().activated = false; + try { + off->enable(3); + FAIL("未激活不该使能成功"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.cmd), 0x10); + CHECK_EQ(int(e.code), 0x08); + CHECK(std::string(e.what()).find("未激活") != std::string::npos); + } +} + +TEST(license_activate_validates_the_mac_length_locally) { + lt::Offline off; + const uint8_t mac[16] = {0}; + CHECK_THROWS_AS(off->activate(1, 20260924, 0, mac, 15), InvalidCommandError); + CHECK_THROWS_AS(off->activate(1, 20260924, 0, mac, 17), InvalidCommandError); +} + +TEST(license_activate_validates_the_reserved_flag_bits_locally) { + // 保留位必须为 0 (固件会拒, 但它把失败折进 0x02 的聚合档 => 本地先说清楚)。 + lt::Offline off; + const uint8_t mac[16] = {0}; + CHECK_THROWS_AS(off->activate(1, 20260924, 0x2, mac, 16), InvalidCommandError); + CHECK_THROWS_AS(off->activate(1, 20260924, 0xFF, mac, 16), InvalidCommandError); +} + +TEST(license_activate_succeeds_and_the_payload_is_twenty_eight_bytes) { + lt::Offline off; + off.t().activated = false; + const uint8_t mac[16] = {0xAA, 0xBB, 0xCC, 0xDD, 1, 2, 3, 4, + 5, 6, 7, 8, 9, 10, 11, 12}; + CHECK_NOTHROW(off->activate(7, 20260924, 0, mac, 16)); + CHECK(off.t().activated); + CHECK_EQ(int(off.t().license_cust_id), 7); + CHECK_EQ(int(off.t().license_issued), 20260924); + bool checked = false; + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first != proto::CMD_ACTIVATE) continue; + CHECK_EQ(kv.second.size(), size_t(28)); + // cust_id/issued/flags 都是 LE u32, 之后跟 16 字节 MAC + CHECK_EQ(int(proto::read_u32le(kv.second, 0)), 7); + CHECK_EQ(int(proto::read_u32le(kv.second, 4)), 20260924); + CHECK_EQ(int(proto::read_u32le(kv.second, 8)), 0); + CHECK_EQ(int(kv.second[12]), 0xAA); + checked = true; + } + CHECK(checked); +} + +TEST(license_activate_complete_flag_reports_factory_mode) { + lt::Offline off; + off.t().activated = false; + const uint8_t mac[16] = {0}; + CHECK_NOTHROW(off->activate(1, 20260924, 0x1, mac, 16)); + CHECK_EQ(off->license().state, 2); + CHECK(off->license().factory_mode()); +} + +TEST(license_activate_zero_two_is_an_aggregate_that_must_be_disambiguated) { + // 固件把「flags 保留位非 0 / 已存在 / MAC 不符 / 密钥非法 / 写或读回失败」**全折成 + // 0x02** —— 于是上一条 ACK 被丢掉后重发就会拿到 0x02, 而机器**其实已经解锁**。 + // 故在这一档必须回读一次 license(): 只有设备确实 state == 0 才抛。 + lt::Offline off; + // 情形 A: 其实已经激活过 => 0x02 不该被当成失败 + off.t().activated = true; + const uint8_t mac[16] = {0}; + CHECK_NOTHROW(off->activate(1, 20260924, 0, mac, 16)); + + // 情形 B: 真的没解锁 => 照实抛 + lt::Offline off2; + off2.t().activated = false; + off2.t().license_fail_next = true; + try { + off2->activate(1, 20260924, 0, mac, 16); + FAIL("真的失败时应当抛"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.code), 0x02); + CHECK(std::string(e.what()).find("聚合档") != std::string::npos); + } +} + +TEST(license_activate_other_codes_are_raised_directly) { + lt::Offline off; + // 直接置桩的武装位 (而不是 enable()): 未激活时 enable 会被 0x08 挡下, 那是另一条路径。 + off.t().enabled = true; // 武装态 => ERR{0x3F,0x04} + const uint8_t mac[16] = {0}; + try { + off->activate(1, 20260924, 0, mac, 16); + FAIL("武装态应当被拒"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.cmd), 0x3F); + CHECK_EQ(int(e.code), 0x04); + } +} + +TEST(license_read_does_not_return_a_msg_envelope) { + // RSP_LICENSE 是请求/应答式的**设备身份记录**, 没有固件发起的流量 —— Msg 的 per-type + // hz/timestamp 只会变成"调用方自己轮询的频率", 对设备一无所指。 + lt::Offline off; + const LicenseInfo info = off->license(); // 返回裸 LicenseInfo, 不是 Msg + CHECK_EQ(int(info.state), 1); +} + +TEST(license_on_firmware_without_0x2f_times_out_rather_than_reporting_unsupported) { + // ⚠ **这是从 Python 版原样继承的行为, 不是移植引入的**: license() 调 expect 时**不传** + // echo_cmd, 而 `wait_keys(RSP_LICENSE, 无回显)` 只等 `(RSP_LICENSE, None)` 这一条队 + // 列 —— 固件那条 `ERR{0x2F,0x00}` 落在 `(RSP_ERR, 0x2F)` 里, 于是本入口看不到它, + // 只能等满窗口报超时 (那条 ERR 随后由下一次 `drain_for(0x2F)` 清掉, 不会串台)。 + // + // ⇒ 在**低于 1.8.0** 的固件 (SDK 支持 1.5.0+) 上, `license()` 报的是"无应答"而不是 + // "固件没有这条命令"。要修只需给那次 expect 传上 `echo_cmd=CMD_GET_LICENSE`, + // 但那是**行为变更**, 移植时刻意保留原语义 —— 见 README 的"与原版的已知差异"。 + lt::Offline off; + off.t().unknown_cmds.insert(proto::CMD_GET_LICENSE); + CHECK_THROWS_AS(off->license(0.15), MotionTimeoutError); + // 别的入口逐个都传了 echo_cmd, 故它们能把 0x00 判成"固件没这条命令" + off.t().unknown_cmds.insert(proto::CMD_GET_JOINT_PARAM); + CHECK_THROWS_AS(off->params().get_joint_param(0), UnsupportedByFirmwareError); +} diff --git a/tests/test_log.cpp b/tests/test_log.cpp new file mode 100644 index 0000000..577f7a2 --- /dev/null +++ b/tests/test_log.cpp @@ -0,0 +1,212 @@ +// 300Hz 控制拍采集 —— 0x2D / 0x2E。 +#include "test_support.hpp" + +using namespace litearm; + +TEST(log_sample_size_is_four_plus_twelve_n) { + CHECK_EQ(sample_size(7), 4 + 12 * 7); + CHECK_EQ(sample_size(1), 4 + 12); + CHECK_EQ(sample_size(0), 4); +} + +TEST(log_parse_samples_decodes_the_firmware_layout) { + const auto blob = testing::make_log(5, 7); + CHECK_EQ(blob.size(), size_t(5 * sample_size(7))); + const auto samples = parse_samples(blob, 7); + CHECK_EQ(int(samples.size()), 5); + for (int i = 0; i < 5; ++i) { + const auto& s = samples[size_t(i)]; + CHECK_EQ(int(s.tick), i); + CHECK_EQ(int(s.q_ref.size()), 7); + CHECK_NEAR(s.q_ref[0], 1.0, 1e-6); + CHECK_NEAR(s.q_ref[6], 61.0, 1e-6); + CHECK_NEAR(s.dq[0], 0.5, 1e-6); + CHECK_NEAR(s.tau[0], -0.25, 1e-6); + } +} + +TEST(log_parse_samples_refuses_a_truncated_blob) { + // 数据截断/错位时拒绝解析出半截样本 —— 不静默。 + auto blob = testing::make_log(3, 7); + blob.resize(blob.size() - 1); + CHECK_THROWS_AS(parse_samples(blob, 7), TransportError); + CHECK_THROWS_AS(parse_samples(blob, 0), TransportError); +} + +TEST(log_start_stop_and_read_back_immediately) { + lt::Offline off; + CHECK_NOTHROW(off->log().start(10)); + CHECK_EQ(off.t().log_target, 10); + auto reader = off->log().reader(); + CHECK_EQ(int(reader.total()), 10 * sample_size(7)); + const auto samples = reader.samples(); + CHECK_EQ(int(samples.size()), 10); + const auto blob = reader.read_all(); + CHECK_EQ(blob.size(), size_t(10 * sample_size(7))); + CHECK_NOTHROW(off->log().stop()); + CHECK_EQ(off.t().log_target, 0); +} + +TEST(log_start_rejects_negative_and_over_capacity) { + lt::Offline off; + CHECK_THROWS_AS(off->log().start(-1), InvalidCommandError); + try { + off->log().start(LOG_MAX_SAMPLES + 1); + FAIL("应当被拒"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.cmd), 0x2D); + CHECK_EQ(int(e.code), 0x02); + } +} + +TEST(log_start_zero_is_equivalent_to_stop) { + lt::Offline off; + off->log().start(5); + off->log().start(0); + CHECK_EQ(off.t().log_target, 0); + CHECK(!off.t().log_active); +} + +TEST(log_reader_retries_on_a_dropped_reply_at_the_same_cursor) { + // 固件是"绕开连续 USB 流、按游标续读"的设计, 所以重试/续读语义是必须的, 不是可选优化。 + lt::Offline off; + off->log().start(6); + off.t().log_read_fail_once = true; + auto reader = off->log().reader(0.3, 3); + const auto samples = reader.samples(); + CHECK_EQ(int(samples.size()), 6); // 掉一帧不该丢一段数据 +} + +TEST(log_reader_refuses_to_loop_forever_on_a_stuck_cursor) { + // 固件游标若因异常/干扰回了一个不大于当前 offset 的非零值, 天真的实现会在同一位置无限 + // 次重读 —— 必须自己判定"无进展"并报错。 + lt::Offline off; + off->log().start(100); + off.t().log_cursor_stuck = true; + auto reader = off->log().reader(0.2, 0); + CHECK_THROWS_AS(reader.read_all(), TransportError); +} + +TEST(log_wait_for_polls_until_the_firmware_has_recorded_enough) { + // 固件是 300Hz **逐拍**记录的: start() 返回时缓冲里还没有数据 —— 等待必须靠轮询, + // 不能靠 sleep 猜时间。 + lt::Offline off; + off.t().log_hz = 300.0; // 打开逐拍模型 + off->log().start(30); // 30 拍约 0.1s + auto reader = off->log().reader(1.0, 3); + const uint32_t total = reader.wait_for(30, 2.0, 0.01); + CHECK(total >= uint32_t(30 * sample_size(7))); + const auto samples = reader.samples(); + CHECK(int(samples.size()) >= 30); +} + +TEST(log_wait_for_raises_instead_of_returning_half_data) { + lt::Offline off; + off.t().log_hz = 30.0; // 很慢 + off->log().start(2400); // 要 80 秒才录得满 + auto reader = off->log().reader(0.5, 1); + const double t0 = now_s(); + CHECK_THROWS_AS(reader.wait_for(2400, 0.2, 0.01), MotionTimeoutError); + CHECK(now_s() - t0 >= 0.15); +} + +TEST(log_capture_waits_then_returns_samples) { + lt::Offline off; + off.t().log_hz = 300.0; + const auto samples = off->log().capture(20, 1.0, 3, 2.0); + CHECK(int(samples.size()) >= 20); +} + +TEST(log_dump_writes_the_raw_byte_stream) { + lt::Offline off; + off->log().start(8); + // ⚠ 用**相对路径**: 本仓要能开源、在任何人的机器上跑, 不许把 /tmp 或开发者家目录写死。 + // ctest 的工作目录就是构建目录, 直接跑二进制时就是当前目录; `.gitignore` 已覆盖 *.bin。 + const std::string path = "litearm_test_dump.bin"; + const size_t n = off->log().dump(path, /*wait=*/false, 0.5, 3, -1.0); + CHECK_EQ(int(n), 8 * sample_size(7)); + FILE* f = std::fopen(path.c_str(), "rb"); + CHECK(f != nullptr); + if (f) { + std::vector got(n); + const size_t rd = std::fread(got.data(), 1, n, f); + std::fclose(f); + CHECK_EQ(int(rd), int(n)); + // 落盘的字节就是固件给的原样字节流 + CHECK_EQ(int(parse_samples(got, 7).size()), 8); + } + std::remove(path.c_str()); +} + +TEST(log_dump_wait_uses_the_last_start_target) { + lt::Offline off; + off.t().log_hz = 300.0; + off->log().start(10); + CHECK_EQ(off->log().last_target(), 10); + const std::string path = "litearm_test_dump2.bin"; + const size_t n = off->log().dump(path, /*wait=*/true, 1.0, 3, 2.0); + CHECK(int(n) >= 10 * sample_size(7)); + std::remove(path.c_str()); +} + +TEST(log_read_reply_short_is_rejected) { + // ⚠ 用桩的确定注入点而不是手工 push_frame —— 后者造出来的帧会与读线程抢时序 + // (它可能先被读进队列, 再由 drain_for 清掉), 用例会偶发红。 + lt::Offline off; + off->log().start(3); + off.t().log_reply_short = true; + auto reader = off->log().reader(0.2, 0); + CHECK_THROWS_AS(reader.read_all(), TransportError); + // 关掉注入之后照样读得回来 + off.t().log_reply_short = false; + CHECK_EQ(int(reader.samples().size()), 3); +} + +TEST(log_read_unsupported_command_is_reported) { + lt::Offline off; + off.t().unknown_cmds.insert(proto::CMD_LOG_READ); + auto reader = off->log().reader(0.2, 0); + CHECK_THROWS_AS(reader.total(), UnsupportedByFirmwareError); +} + +TEST(log_iter_chunks_preserves_chunk_boundaries) { + // 上游 `LogReader.iter_chunks()` 的 C++ 等价物 (那边是生成器, 这边是块向量)。 + // 判据与 `read_all` 的一致性 + **块边界确实是固件的分块** (245B 一块)。 + lt::Offline off; + off->log().start(50); + auto reader = off->log().reader(); + const auto chunks = reader.iter_chunks(); + CHECK(int(chunks.size()) >= 2); // 50 拍 x88B = 4400B => 至少要两块 + size_t total = 0; + for (size_t i = 0; i + 1 < chunks.size(); ++i) { + CHECK_EQ(int(chunks[i].size()), LOG_READ_CHUNK); // 非末块满 245B + total += chunks[i].size(); + } + total += chunks.back().size(); + CHECK_EQ(int(total), 50 * sample_size(7)); + // 与 read_all 拼接结果逐字节相同 + auto reader2 = off->log().reader(); + const auto flat = reader2.read_all(); + std::vector joined; + for (const auto& c : chunks) joined.insert(joined.end(), c.begin(), c.end()); + CHECK(joined == flat); +} + +TEST(log_iter_chunks_refuses_to_loop_forever_on_a_stuck_cursor) { + lt::Offline off; + off->log().start(100); + off.t().log_cursor_stuck = true; + auto reader = off->log().reader(0.2, 0); + CHECK_THROWS_AS(reader.iter_chunks(), TransportError); +} + +TEST(log_bulk_capture_round_trips_every_sample) { + // 245B 一块 => 7J 的一拍 88B 会跨块 —— 跨块拼接必须字节一致。 + lt::Offline off; + off->log().start(50); + auto reader = off->log().reader(); + const auto samples = reader.samples(); + CHECK_EQ(int(samples.size()), 50); + for (int i = 0; i < 50; ++i) CHECK_EQ(int(samples[size_t(i)].tick), i); + CHECK_EQ(int(reader.total_bytes()), 50 * sample_size(7)); +} diff --git a/tests/test_model.cpp b/tests/test_model.cpp new file mode 100644 index 0000000..44b6566 --- /dev/null +++ b/tests/test_model.cpp @@ -0,0 +1,194 @@ +// 动力学模型在线导入 —— 0x30/0x32/0x33/0x34/0x35/0x37/0x38/0x39。 +#include "test_support.hpp" + +using namespace litearm; + +namespace { +std::vector body10(double m) { + return {m, 0.1, 0.2, 0.3, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0}; +} +} // namespace + +TEST(model_mask_constants_are_pinned) { + // 工具/产线实际写入 body1..7 + jm = 0x2FE。常量漂移会让 commit 撞 ERR{0x32,0x07}。 + CHECK_EQ(int(MODEL_BIT_JM), 1 << 9); + CHECK_EQ(int(MODEL_BIT_BODY(3)), 1 << 3); + CHECK_EQ(int(MODEL_MASK_WRITTEN), 0x2FE); + CHECK_EQ(MODEL_NBODY, 9); + CHECK_EQ(MODEL_BODY_PARAMS, 10); + CHECK_EQ(MODEL_JM_N, 7); +} + +TEST(model_status_decodes_the_five_byte_reply) { + const std::vector payload{proto::RSP_MODEL_STATUS, 1}; + std::vector body = payload; + const auto m = proto::pack_u16le(0x2FE); + body.insert(body.end(), m.begin(), m.end()); + body.push_back(1); + const ModelStatus s = ModelStatus::decode(body); + CHECK_EQ(s.override_level, 1); + CHECK_EQ(int(s.staged_mask), 0x2FE); + CHECK_EQ(s.dirty, 1); +} + +TEST(model_status_rejects_a_wrong_length_or_id) { + CHECK_THROWS_AS(ModelStatus::decode(std::vector(5, 0)), TransportError); + std::vector bad{0x99, 0, 0, 0, 0}; + CHECK_THROWS_AS(ModelStatus::decode(bad), TransportError); +} + +TEST(model_probe_uses_0x34_and_maps_0x00_to_false) { + // 用 0x34 探测: 旧固件没有这条命令的 case => default => ERR{0x34,0x00}。 + lt::Offline off; + CHECK(off->model().probe()); + lt::Offline off2; + off2.t().unknown_cmds.insert(proto::CMD_GET_MODEL_PARAM); + CHECK(!off2->model().probe()); +} + +TEST(model_get_and_set_body_round_trip_after_commit) { + lt::Offline off; + const auto original = off->model().get_body(1).value; + CHECK_EQ(int(original.size()), 10); + + for (int i = 1; i <= 7; ++i) { + off->model().set_body(i, body10(2.0 + i)); + } + off->model().set_jm(std::vector(7, 0.001)); + // 写 staging 不生效 —— bank 还没变 + CHECK_NEAR(off->model().get_body(1).value[0], original[0], 1e-9); + CHECK(!off->model().status().value.staged_mask ? true : true); + + off->model().commit(MODEL_MASK_WRITTEN); + CHECK_NEAR(off->model().get_body(1).value[0], 3.0, 1e-6); + CHECK_NEAR(off->model().get_body(7).value[0], 9.0, 1e-6); + CHECK_NEAR(off->model().get_jm().value[0], 0.001, 1e-9); + CHECK_EQ(off->model().status().value.override_level, 1); +} + +TEST(model_set_body_validates_index_and_arity) { + lt::Offline off; + CHECK_THROWS_AS(off->model().set_body(-1, body10(1.0)), InvalidCommandError); + CHECK_THROWS_AS(off->model().set_body(9, body10(1.0)), InvalidCommandError); + CHECK_THROWS_AS(off->model().set_body(1, std::vector(9, 1.0)), + InvalidCommandError); + CHECK_THROWS_AS(off->model().set_body(1, std::vector(11, 1.0)), + InvalidCommandError); + CHECK_THROWS_AS(off->model().get_body(9), InvalidCommandError); +} + +TEST(model_set_jm_requires_seven_values) { + lt::Offline off; + CHECK_THROWS_AS(off->model().set_jm(std::vector(6, 0.0)), + InvalidCommandError); + CHECK_NOTHROW(off->model().set_jm(std::vector(7, 0.0))); +} + +TEST(model_commit_mask_must_match_what_was_staged) { + // 掩码不符 -> ERR{0x32,0x07} —— 与"数值非法"分开的码, 产线最常见故障。 + lt::Offline off; + off->model().set_body(1, body10(1.0)); // staging 里只有 body1 + try { + off->model().commit(MODEL_BIT_BODY(2)); // 声称写过 body2 —— 与 staging 不符 + FAIL("应当被拒"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.cmd), 0x32); + CHECK_EQ(int(e.code), 0x07); + CHECK(std::string(e.what()).find("掩码不符") != std::string::npos); + } + // 逐位相等才能提交 + CHECK_NOTHROW(off->model().commit(MODEL_BIT_BODY(1))); +} + +TEST(model_commit_mask_is_packed_as_little_endian_u16) { + // 写成 bytes([mask]) 会截断成 1 字节 (0x2FE & 0xFF = 0xFE), 固件直接拒。 + lt::Offline off; + off->model().set_body(1, body10(1.0)); + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first != proto::CMD_MODEL_COMMIT) continue; + CHECK_EQ(kv.second.size(), size_t(2)); + CHECK_EQ(int(proto::read_u16le(kv.second, 0)), 1 << 1); + } +} + +TEST(model_commit_and_revert_require_a_disarmed_arm) { + lt::Offline off; + off->model().set_body(1, body10(1.0)); + off->enable(); + try { + off->model().commit(MODEL_BIT_BODY(1)); + FAIL("武装态应当被拒"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.code), 0x04); + } + CHECK_THROWS_AS(off->model().revert(), CommandRejectedError); + off->disable(); + CHECK_NOTHROW(off->model().revert()); + // 回退后 dirty == 1 (RAM != flash), 但不要据此提示"补固化" + CHECK_EQ(off->model().status().value.dirty, 1); +} + +TEST(model_set_body_rejects_the_ee_body_with_nonzero_mass) { + // body8 (ee 固定体) 的质量必须恒 0。 + lt::Offline off; + try { + off->model().set_body(8, body10(1.0)); + FAIL("应当被拒"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.code), 0x02); + } + CHECK_NOTHROW(off->model().set_body(8, body10(0.0))); +} + +TEST(model_get_gravity_returns_seven_values) { + lt::Offline off; + const std::vector q(7, 0.0); + const auto g = off->model().get_gravity(q); + CHECK_EQ(int(g.value.size()), 7); + CHECK_THROWS_AS(off->model().get_gravity(std::vector(6, 0.0)), + InvalidCommandError); + // 载荷 = q[7] + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first == proto::CMD_GET_GRAVITY) CHECK_EQ(kv.second.size(), size_t(28)); + } +} + +TEST(model_status_reports_the_staged_mask) { + lt::Offline off; + off->model().set_body(2, body10(1.0)); + off->model().set_body(3, body10(1.0)); + off->model().set_jm(std::vector(7, 0.0)); + const auto s = off->model().status().value; + CHECK_EQ(int(s.staged_mask), (1 << 2) | (1 << 3) | (1 << 9)); +} + +TEST(model_get_jm_frame_shape) { + lt::Offline off; + const auto m = off->model().get_jm(); + CHECK_EQ(int(m.value.size()), 7); + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first == proto::CMD_GET_MODEL_JM) CHECK(kv.second.empty()); + } +} + +TEST(model_unsupported_firmware_is_reported_by_type) { + lt::Offline off; + off.t().unknown_cmds.insert(proto::CMD_GET_MODEL_PARAM); + CHECK_THROWS_AS(off->model().get_body(0), UnsupportedByFirmwareError); + // ⚠ 不能用 0x30/0x33 探测: 旧固件对它们有显式 case 回 ERR{cmd,0x02}, 无法区分 + // 「无此命令」与「参数非法」。 + lt::Offline off2; + off2.t().err_override[proto::CMD_GET_MODEL_PARAM] = 0x02; + CHECK_THROWS_AS(off2->model().probe(), CommandRejectedError); +} + +TEST(model_body_frame_echo_is_checked) { + // 回显 body_idx 与请求不一致 => 帧布局漂移, 必须响亮。 + // ⚠ 用桩的确定注入点而不是手工 push_frame —— 后者会与读线程抢时序。 + lt::Offline off; + off.t().model_body_echo_idx = 5; + CHECK_THROWS_AS(off->model().get_body(0), TransportError); + // 回显一致时正常解出 + off.t().model_body_echo_idx = -1; + CHECK_EQ(int(off->model().get_body(0).value.size()), 10); +} diff --git a/tests/test_packaging.cpp b/tests/test_packaging.cpp new file mode 100644 index 0000000..1ff90fc --- /dev/null +++ b/tests/test_packaging.cpp @@ -0,0 +1,102 @@ +// 打包一致性 —— 装出来的东西**自洽**, 且版本只有**一个**说法。 +// +// ⚠ 这一类的失败有个共同的恶劣形状: **报错指向的地方离病因很远**。 +// 实测过的两例 (一例在另一套实现上): +// * 装出来的 prefix 里少一个头 ⇒ 消费者报 "litearm/version.hpp: 没有那个文件", +// 而病因是 install 规则漏了一行; +// * CMake 包里漏了 `find_dependency(Threads)` ⇒ 消费者报 +// "The link interface ... contains Threads::Threads but the target was not found", +// 而库本身编得好好的。 +// ⇒ 所以在**卖出去之前**在这里守一道, 而不是等消费者来报。 +#include "test_support.hpp" + +using namespace litearm; + +namespace { + +/// 从 `text` 里取 `key` 之后紧邻的那串数字版本 (形如 `1.2.3`)。 +std::string version_after(const std::string& text, const std::string& key) { + const size_t at = text.find(key); + if (at == std::string::npos) return {}; + size_t i = at + key.size(); + while (i < text.size() && !std::isdigit(static_cast(text[i]))) ++i; + size_t j = i; + while (j < text.size() && + (std::isdigit(static_cast(text[j])) || text[j] == '.')) + ++j; + return text.substr(i, j - i); +} + +} // namespace + +TEST(packaging_version_has_exactly_one_source_of_truth) { + // CMakeLists 的 `project(... VERSION x.y.z)` 与 `litearm.hpp` 里那三个宏 + 版本串 + // 必须一致。⚠ 两处都手写 ⇒ 只改一处是**静默**的, 而症状是"客户拿到的版本号是旧的" + // 这种极难察觉的错。故钉住。 + const std::string cmake = lt::read_repo_file("CMakeLists.txt"); + const std::string hpp = lt::read_repo_file("include/litearm/litearm.hpp"); + CHECK(!cmake.empty()); + CHECK(!hpp.empty()); + if (cmake.empty() || hpp.empty()) return; + + const std::string v = version_after(cmake, "project(litearm-cpp VERSION"); + CHECK(!v.empty()); + const std::string major = v.substr(0, v.find('.')); + const std::string rest = v.substr(v.find('.') + 1); + const std::string minor = rest.substr(0, rest.find('.')); + const std::string patch = rest.substr(rest.find('.') + 1); + + CHECK_EQ(version_after(hpp, "LITEARM_VERSION_STRING"), v); + CHECK_EQ(version_after(hpp, "LITEARM_VERSION_MAJOR"), major); + CHECK_EQ(version_after(hpp, "LITEARM_VERSION_MINOR"), minor); + CHECK_EQ(version_after(hpp, "LITEARM_VERSION_PATCH"), patch); + // 运行期函数也必须跟同一份走 + CHECK_EQ(std::string(version()), v); +} + +TEST(packaging_every_public_header_is_self_contained_in_the_shipped_dir) { + // 装出去的是**整个 `include/litearm/` 目录** ⇒ 只要公开头里引用的兄弟头**都在这个目录里**, + // 装出来的就是自洽的。这条守的是"引了一个不在本目录的头" (改名 / 打错 / 放在别处)。 + const auto headers = lt::list_repo_files("include/litearm", "", ".hpp"); + CHECK(!headers.empty()); + if (headers.empty()) return; + + std::vector missing; + for (const auto& h : headers) { + const std::string src = lt::strip_cxx_comments(lt::read_repo_file(h)); + const std::string marker = "#include \"litearm/"; + for (size_t p = src.find(marker); p != std::string::npos; + p = src.find(marker, p + 1)) { + const size_t b = p + marker.size(); + const size_t e = src.find('"', b); + if (e == std::string::npos) continue; + const std::string name = src.substr(b, e - b); + if (lt::read_repo_file("include/litearm/" + name).empty()) { + missing.push_back(h + " -> litearm/" + name); + } + } + } + if (!missing.empty()) { + std::string msg = "公开头引用了**不在 include/litearm/ 里**的兄弟头 ⇒ 装出去就编不过:"; + for (const auto& m : missing) msg += "\n " + m; + FAIL(msg); + } +} + +TEST(packaging_cmake_and_pkgconfig_templates_are_present) { + // 这两个模板是 install 规则的输入。⚠ 它们**不在** include/ 下, 所以不会被 + // `install(DIRECTORY include/litearm ...)` 顺带装上 —— 只会在 cmake 配置期被读。 + // 删了/改名了 ⇒ 配置期报错 (还算响); 但被 `if(EXISTS)` 之类的写法包住时就是静默的。 + CHECK(!lt::read_repo_file("cmake/litearmConfig.cmake.in").empty()); + CHECK(!lt::read_repo_file("cmake/litearm.pc.in").empty()); + CHECK(!lt::read_repo_file("cmake/toolchain-aarch64-linux-gnu.cmake").empty()); +} + +TEST(packaging_pkgconfig_and_config_carry_the_same_version) { + // `.pc` 与 CMake 包配置都从 `@PROJECT_VERSION@` 取值 ⇒ 二者必须同源。 + // 这条钉的是"将来有人把 .pc 里的 Version 写死成字面量"。 + const std::string pc = lt::read_repo_file("cmake/litearm.pc.in"); + CHECK(!pc.empty()); + if (pc.empty()) return; + CHECK(pc.find("Version: @PROJECT_VERSION@") != std::string::npos); +} diff --git a/tests/test_params.cpp b/tests/test_params.cpp new file mode 100644 index 0000000..4f9b6ec --- /dev/null +++ b/tests/test_params.cpp @@ -0,0 +1,117 @@ +// 关节级参数 —— 0x22 / 0x23 / 0x24 / 0x36。 +#include "test_support.hpp" + +using namespace litearm; + +TEST(params_get_returns_the_decoded_record) { + lt::Offline off; + const auto m = off->params().get_joint_param(0); + CHECK_EQ(m.value.idx, 0); + CHECK_NEAR(m.value.kp, 50.0, 1e-6); + CHECK_NEAR(m.value.kd, 2.0, 1e-6); + CHECK_NEAR(m.value.tau_max, 10.0, 1e-6); + CHECK_NEAR(m.value.q_min, -3.0, 1e-6); + CHECK_NEAR(m.value.q_max, 3.0, 1e-6); +} + +TEST(params_set_then_read_back_closes_the_loop) { + lt::Offline off; + off->params().set_joint_param(2, 30.0, 1.5, 20.0); + const auto m = off->params().get_joint_param(2); + CHECK_NEAR(m.value.kp, 30.0, 1e-6); + CHECK_NEAR(m.value.kd, 1.5, 1e-6); + CHECK_NEAR(m.value.tau_max, 20.0, 1e-6); + // 别的关节没被动 + CHECK_NEAR(off->params().get_joint_param(3).value.kp, 50.0, 1e-6); +} + +TEST(params_set_limits_then_read_back) { + lt::Offline off; + off->params().set_joint_limits(0, -1.0, 1.0); + const auto m = off->params().get_joint_param(0); + CHECK_NEAR(m.value.q_min, -1.0, 1e-6); + CHECK_NEAR(m.value.q_max, 1.0, 1e-6); +} + +TEST(params_limits_require_qmin_lt_qmax_locally) { + // 固件要求 q_min < q_max; 本地先说清楚, 免得白跑一趟。 + lt::Offline off; + CHECK_THROWS_AS(off->params().set_joint_limits(0, 1.0, 1.0), InvalidCommandError); + CHECK_THROWS_AS(off->params().set_joint_limits(0, 2.0, 1.0), InvalidCommandError); +} + +TEST(params_index_is_bounds_checked_against_the_joint_count) { + lt::Offline off; + CHECK_THROWS_AS(off->params().get_joint_param(-1), InvalidCommandError); + CHECK_THROWS_AS(off->params().get_joint_param(7), InvalidCommandError); + CHECK_THROWS_AS(off->params().set_joint_param(7, 1, 1, 1), InvalidCommandError); + CHECK_THROWS_AS(off->params().set_joint_limits(7, 0, 1), InvalidCommandError); + CHECK_NOTHROW(off->params().get_joint_param(6)); +} + +TEST(params_all_joint_params_aggregates_n_roundtrips) { + // 返回 vector —— 不是 vector: N 帧的信封一个 hz 描述不了。 + lt::Offline off; + const auto all = off->params().all_joint_params(); + CHECK_EQ(int(all.size()), 7); + for (int i = 0; i < 7; ++i) CHECK_EQ(all[size_t(i)].idx, i); +} + +TEST(params_all_joint_params_on_the_single_axis_bench) { + lt::Offline off("Litearm1.7.0-1J", 1); + CHECK_EQ(int(off->params().all_joint_params().size()), 1); +} + +TEST(params_reset_factory_requires_a_disarmed_arm) { + lt::Offline off; + off->params().set_joint_param(0, 30.0, 1.0, 20.0); + off->enable(); + try { + off->params().reset_factory(); + FAIL("武装态应当被拒"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.cmd), 0x36); + CHECK_EQ(int(e.code), 0x04); + } + off->disable(); + CHECK_NOTHROW(off->params().reset_factory()); + // 回出厂默认 + CHECK_NEAR(off->params().get_joint_param(0).value.kp, 50.0, 1e-6); +} + +TEST(params_firmware_rejections_are_surfaced_with_their_codes) { + lt::Offline off; + off.t().err_override[proto::CMD_SET_JOINT_LIMITS] = 0x03; + try { + off->params().set_joint_limits(0, -1.0, 1.0); + FAIL("应当抛异常"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.code), 0x03); + // 0x23 的 0x03 有它自己的语义 (新限位关不住在途目标) + CHECK(std::string(e.what()).find("在途目标") != std::string::npos); + } +} + +TEST(params_unsupported_read_is_reported_as_such) { + lt::Offline off; + off.t().unknown_cmds.insert(proto::CMD_GET_JOINT_PARAM); + CHECK_THROWS_AS(off->params().get_joint_param(0), UnsupportedByFirmwareError); +} + +TEST(params_short_frame_is_rejected_by_the_decoder) { + CHECK_THROWS_AS(JointParam::decode(std::vector(10, 0)), TransportError); +} + +TEST(params_armed_gate_on_limits_returns_state_code) { + // 武装中且该轴的当前参考落在新区间之外 -> ERR{0x23,0x03} (固件 ctrl_axis_outside 第①/⑤条) + lt::Offline off; + off->enable(); + off.t().q[0] = 0.0; + try { + off->params().set_joint_limits(0, 1.0, 2.0); // 当前 q=0 在新区间外 + FAIL("应当被拒"); + } catch (const CommandRejectedError& e) { + CHECK_EQ(int(e.code), 0x03); + } + CHECK_NOTHROW(off->params().set_joint_limits(0, -1.0, 1.0)); // 容得下当前值 +} diff --git a/tests/test_precheck.cpp b/tests/test_precheck.cpp new file mode 100644 index 0000000..865eb65 --- /dev/null +++ b/tests/test_precheck.cpp @@ -0,0 +1,221 @@ +// 客户端预检 —— **发帧之前**的本地拒发 (⚠ 与 Python 参照分叉, 见 README)。 +// +// 本文件钉四件事: +// ① 非有限目标 (NaN / ±inf) 必须拦 —— 且**与软限缓存空不空无关**; +// ② 软限越限必须拦 —— 但**只在缓存可用时**; 缓存不可用一律 fail-open (放行); +// ③ 缓存本身的两条纪律: 读不到 ⇒ 弃用; **倒置/非有限 ⇒ 整份弃用**(不是跳那一轴); +// ④ `home()` **刻意豁免** —— 臂漂出软限位后, 恢复路径不该被自己的预检堵死。 +#include "test_support.hpp" + +using namespace litearm; + +namespace { +int count_cmd(const testing::FakeTransport& t, uint8_t cmd) { + int n = 0; + for (const auto& kv : t.tx_snapshot()) { + if (kv.first == cmd) ++n; + } + return n; +} +std::vector zeros7() { return std::vector(7, 0.0); } +} // namespace + +// ---------------------------------------------------------------- 软限缓存 + +TEST(precheck_connect_builds_the_soft_limit_cache) { + // 桩的关节参数默认是 q∈[-3, 3] (见 `testing.hpp` 的 jp_q_min/jp_q_max)。 + lt::Offline off; + const int before = count_cmd(off.t(), proto::CMD_GET_JOINT_PARAM); + CHECK_EQ(before, 7); // 连接期读回 7 条 ⇒ 缓存已建 + + // 界内 ⇒ 放行 (照常下发) + CHECK_NOTHROW(off->movej(std::vector(7, 2.5))); + // 界外 ⇒ 本地拒发, **一帧都不发** + const int sent = count_cmd(off.t(), proto::CMD_MOVE_J); + CHECK_THROWS_AS(off->movej(std::vector(7, 3.5)), InvalidCommandError); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_J), sent); // 没有多出下行帧 +} + +TEST(precheck_limit_violation_names_the_axis_and_both_bounds) { + // 文案是这条例外的**全部现场价值**: 得说清哪个轴、什么值、界限多少。 + lt::Offline off; + try { + off->movej(std::vector{0.0, 0.0, -4.25, 0.0, 0.0, 0.0, 0.0}); + FAIL("越限的目标必须被本地拒发"); + } catch (const InvalidCommandError& e) { + const std::string m = e.what(); + CHECK(m.find("第 3 轴") != std::string::npos); // 轴号 + CHECK(m.find("-4.25") != std::string::npos); // 目标值 + CHECK(m.find("-3") != std::string::npos); // 下限 + } +} + +TEST(precheck_fails_open_when_the_limits_are_unavailable) { + // 读不到限位 ⇒ **不预检、放行** (fail-open)。fail-closed 会让所有运动命令全废。 + lt::Unconnected uc; + uc.fake->err_override[proto::CMD_GET_JOINT_PARAM] = 0x03; // 固件拒了 0x24 + CHECK_NOTHROW(uc.arm->connect()); // ⚠ 读不到限位**绝不能让连接失败** + // 一个怎么看都越限的目标 —— 缓存空 ⇒ 放行到固件 + CHECK_NOTHROW(uc.arm->movej(std::vector(7, 99.0))); +} + +TEST(precheck_discards_the_whole_cache_when_a_limit_is_inverted) { + // 倒置的软限 (`q_min > q_max`) 会让 `std::clamp` 撞 UB ⇒ **整份弃用**, + // 而不是"跳过那一轴" —— 后者会打断 "非空 ⇒ 每条都有限且不倒置" 这条不变式。 + lt::Unconnected uc; + uc.fake->set_joint_limits(5.0, -5.0); // 倒置 + CHECK_NOTHROW(uc.arm->connect()); + CHECK_NOTHROW(uc.arm->movej(std::vector(7, 99.0))); // 缓存已弃用 ⇒ 放行 +} + +TEST(precheck_discards_the_whole_cache_when_a_limit_is_not_finite) { + lt::Unconnected uc; + uc.fake->set_joint_limits(-3.0, std::numeric_limits::infinity()); + CHECK_NOTHROW(uc.arm->connect()); + CHECK_NOTHROW(uc.arm->movej(std::vector(7, 99.0))); +} + +// ---------------------------------------------------------------- 非有限值 + +TEST(precheck_rejects_non_finite_targets_even_with_an_empty_cache) { + // ⚠⚠ 这是**最要紧的一条**: NaN 与任何数的比较都为假 ⇒ 若让它走到越限比较, + // 会**静默放行**; 而它进到固件的 `clampf` 同样是 UB。 + // ⇒ 它必须排在"缓存空 ⇒ 放行"**之前**, 即缓存空也要拦。 + lt::Unconnected uc; + uc.fake->jp_q_min = 5.0; + uc.fake->jp_q_max = -5.0; // 让缓存**必然为空** + CHECK_NOTHROW(uc.arm->connect()); + + const double nan = std::numeric_limits::quiet_NaN(); + const double inf = std::numeric_limits::infinity(); + for (double bad : {nan, inf, -inf}) { + std::vector q = zeros7(); + q[4] = bad; + CHECK_THROWS_AS(uc.arm->movej(q), InvalidCommandError); + CHECK_THROWS_AS(uc.arm->movej_sync(q), InvalidCommandError); + } + // 且文案点名是哪个轴 + std::vector q = zeros7(); + q[4] = nan; + try { + uc.arm->movej(q); + FAIL("NaN 目标必须被拒"); + } catch (const InvalidCommandError& e) { + CHECK(std::string(e.what()).find("第 5 轴") != std::string::npos); + } +} + +TEST(precheck_movej_sync_shares_the_limit_check) { + lt::Offline off; + const int sent = count_cmd(off.t(), proto::CMD_MOVE_J_SYNC); + CHECK_THROWS_AS(off->movej_sync(std::vector(7, 3.5)), InvalidCommandError); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_J_SYNC), sent); +} + +// ---------------------------------------------------------------- 速度 + +TEST(precheck_q_is_usable_directly_as_a_dry_run) { + // ⚠⚠ 它是**公开**入口, 用途写在 `arm.hpp` 里: 调用方可以**预演** —— + // "问一句这组目标发出去会不会被本地拒", 而**不必真发一条命令**。 + // + // 这条用例有两重身份: + // ① 那句公开承诺的判据 (它真的能当预演用, 且**一帧都不发**); + // ② 补上"公开成员哨兵"抓到的一个**真缺口** —— 有一条用例曾经把 `precheck_q_` 登记 + // 进「已练习」名单, 而它在**全部测试源里一次都没被直接调用过**。 + // (它此前只被 `movej` / `move_p` **间接**带过, 那不叫"这个入口被练习过"。) + lt::Offline off; + const int before = count_cmd(off.t(), proto::CMD_MOVE_J); + CHECK_NOTHROW(off->precheck_q_(std::vector(7, 0.0), "dry-run")); + CHECK_THROWS_AS(off->precheck_q_(std::vector(7, 99.0), "dry-run"), + InvalidCommandError); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_J), before); // 预演**一帧都不发** +} + +TEST(precheck_speed_is_nan_safe_and_bounds_checked) { + // ⚠ 判据形态 `!(speed >= 0 && speed <= 1)` 是承重的: 写成 `speed < 0 || speed > 1` + // 会让 **NaN 静默放行**。 + lt::Offline off; + const double nan = std::numeric_limits::quiet_NaN(); + for (double bad : {nan, -0.1, 1.1, 1e9}) { + CHECK_THROWS_AS(off->precheck_speed_(bad, "t"), InvalidCommandError); + } + // 边界值必须**放行** (闭区间) + CHECK_NOTHROW(off->precheck_speed_(0.0, "t")); + CHECK_NOTHROW(off->precheck_speed_(1.0, "t")); + CHECK_NOTHROW(off->precheck_speed_(0.5, "t")); +} + +TEST(precheck_move_p_rejects_speed_locally_before_sending_anything) { + // ⚠ 这只速度预检是**六个运动入口里唯一 Python 没有的** —— Python 直接把 speed + // 打包发走、由固件 `clampf` 静默钳。本仓改成发帧前拒发 ⇒ 与参照分叉。 + lt::Offline off; + const int sent = count_cmd(off.t(), proto::CMD_MOVE_P); + CHECK_THROWS_AS(off->move_p(std::array{0.3, 0.0, 0.35, 0.0, 0.0, 0.0}, 2.0), + InvalidCommandError); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_P), sent); +} + +// ---------------------------------------------------------------- move_p 的容差闸 + +TEST(precheck_move_p_rejects_bad_tolerances_before_sending_anything) { + // ⚠⚠ 为什么必须拦: `pose_near` 的第一句是 `if (dp >= pos_tol) return false;`, + // 而 **`dp >= NaN` 为假** ⇒ **位置那一半被静默跳过** ⇒ 到位只由朝向决定 ⇒ + // **十米外也判"到位"**(move_p 会当场成功返回, 而臂没到)。 + lt::Offline off; + const double nan = std::numeric_limits::quiet_NaN(); + const std::array goal{0.3, 0.0, 0.35, 0.0, 0.0, 0.0}; + const int sent = count_cmd(off.t(), proto::CMD_MOVE_P); + + CHECK_THROWS_AS(off->move_p(goal, 0.5, nan, 0.03), InvalidCommandError); + CHECK_THROWS_AS(off->move_p(goal, 0.5, 0.006, nan), InvalidCommandError); + CHECK_THROWS_AS(off->move_p(goal, 0.5, 0.0, 0.03), InvalidCommandError); // ≤0 + CHECK_THROWS_AS(off->move_p(goal, 0.5, 0.006, -1.0), InvalidCommandError); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_P), sent); // 一条都没发出去 +} + +TEST(pose_near_with_nan_tolerance_skips_the_position_half_which_is_why_the_gate_exists) { + // 这条钉的是**闸门存在的理由本身**(不是闸门): 直接调 `pose_near` 传 NaN 容差, + // 十米外的目标也会被判成"到位"。⇒ 闸门必须在**进入它之前**拦住。 + // ⚠ 若哪天 `pose_near` 自己加了 fail-closed 兜底, 本条会红 —— 那时应当**同时** + // 保留两侧(纵深), 而不是删掉闸门; 见 A 侧 `src/pose.cpp` 的两层做法。 + lt::Offline off; + const double nan = std::numeric_limits::quiet_NaN(); + const std::array ten_meters_away{10.0, 0.0, 0.35, 0.0, 0.0, 0.0}; + const std::array goal{0.3, 0.0, 0.35, 0.0, 0.0, 0.0}; + CHECK(TestAccess::pose_near(*off.arm, ten_meters_away, goal, nan, 0.03)); +} + +// ---------------------------------------------------------------- home 的豁免 + +TEST(precheck_home_is_exempt_so_the_recovery_path_stays_open) { + // ⚠⚠ 这是**刻意**的豁免 (A 侧 I-27): `home()` 绝不调 `precheck_q_`。 + // 理由: 目标恒为"全零", 而臂若因为标定/装配漂到了软限位之外, 那正是**最需要 + // home 的时候** —— 自己的预检把恢复路径堵死是荒谬的。固件侧对 home 有自己的 + // 速度与力矩约束。 + lt::Unconnected uc; + uc.fake->set_joint_limits(1.0, 2.0); // 0 **不在**区间内 ⇒ 若走预检必被拒 + CHECK_NOTHROW(uc.arm->connect()); + // 同一个缓存下, movej(0) 被拒… + CHECK_THROWS_AS(uc.arm->movej(zeros7()), InvalidCommandError); + // …而 home() 照样发得出去 + // + // ⚠ 超时用**默认值**, 不要写 0.01。本条的主题是"预检豁免", 到达窗口不是被测对象; + // 而 0.01s 只够假传输在**轻载**时走完 `CMD_HOME` 之后那 9 帧状态 (3 帧运动中 + + // 6 帧停稳)。跑满 27 个用例时机器有负载, `arrive()` 就会超时 —— 2026-09-28 在 + // TSan 全套里实测到 `未到位, 超时 0.0s`, 而单跑同一个二进制 10/10 通过。 + // ⇒ 那是一条**负载敏感的脆弱用例**, 不是产品缺陷: 收紧超时并不会让判据更严, + // 只会在别人机器上随机变红。默认值 (= `move_timeout`, 1.0s) 与本文件外所有 + // 调用点一致 (见 `test_commands.cpp` / `test_zero_g.cpp`)。 + CHECK_NOTHROW(uc.arm->home()); + CHECK_EQ(count_cmd(*uc.fake, proto::CMD_HOME), 1); +} + +// ---------------------------------------------------------------- 预检不误伤正常路径 + +TEST(precheck_does_not_reject_a_normal_motion_sequence) { + lt::Offline off; + CHECK_NOTHROW(off->movej(std::vector(7, 0.01), 0.5)); + CHECK_NOTHROW(off->movej_sync(std::vector(7, 0.01), 0.5)); + CHECK_NOTHROW(off->move_p(std::array{0.3, 0.0, 0.35, 0.0, 0.0, 0.0}, 0.5)); + CHECK_NOTHROW(off->home()); // 同前: 默认超时, 别写 0.01 (负载敏感, 见上一条用例) +} diff --git a/tests/test_protocol.cpp b/tests/test_protocol.cpp index 62c7b7c..a3ddd4e 100644 --- a/tests/test_protocol.cpp +++ b/tests/test_protocol.cpp @@ -1,31 +1,325 @@ -#include -#include "litearm/protocol.hpp" +// 帧协议层 —— CRC / 组帧 / 解帧 / 版本解析 / 开机签名 / 状态帧布局 / 覆盖契约。 +#include "test_support.hpp" -namespace litearm { -namespace { +using namespace litearm; -TEST(Protocol, RpcTopic) { - EXPECT_EQ(rpc_topic("armA"), "litearm/v4/armA/rpc"); - EXPECT_EQ(rpc_topic("armB"), "litearm/v4/armB/rpc"); - EXPECT_EQ(rpc_topic("arm_123"), "litearm/v4/arm_123/rpc"); +// ---------------------------------------------------------------- CRC + +TEST(protocol_crc_matches_the_standard_check_value) { + // CRC-16/CCITT-FALSE 的目录标准检查值: "123456789" -> 0x29B1。 + // ⚠ 判据是**目录里的标准值**, 不是"与本函数自己对拍"。 + const std::string s = "123456789"; + CHECK_EQ(proto::crc16_ccitt_false(reinterpret_cast(s.data()), + s.size()), + uint16_t(0x29B1)); +} + +TEST(protocol_crc_matches_an_independent_reference_over_many_lengths) { + // 一份独立的纯位循环参考实现当判据 —— 覆盖长度 0..259 与随机载荷, 逐一比对。 + std::vector buf; + uint32_t seed = 0x12345678u; + for (int len = 0; len <= 259; ++len) { + buf.clear(); + for (int i = 0; i < len; ++i) { + seed = seed * 1664525u + 1013904223u; + buf.push_back(uint8_t(seed >> 24)); + } + CHECK_EQ(proto::crc16_ccitt_false(buf.data(), buf.size()), + lt::crc16_reference(buf.data(), buf.size())); + } +} + +TEST(protocol_crc_empty_input_is_the_init_value) { + CHECK_EQ(proto::crc16_ccitt_false(nullptr, 0), uint16_t(0xFFFF)); +} + +// ---------------------------------------------------------------- 组帧/解帧 + +TEST(protocol_pack_frame_layout_is_byte_exact) { + const std::vector payload{0x01, 0x02, 0x03}; + const auto f = proto::pack_frame(0x10, payload); + CHECK_EQ(f.size(), size_t(8)); // SOF + CMD + LEN + 3 + CRC2 + CHECK_EQ(int(f[0]), int(proto::SOF)); + CHECK_EQ(int(f[1]), 0x10); + CHECK_EQ(int(f[2]), 3); + CHECK_EQ(int(f[3]), 0x01); + const uint16_t crc = uint16_t(f[6] | (f[7] << 8)); // 低字节在前 + CHECK_EQ(crc, lt::crc16_reference(f.data(), 6)); +} + +TEST(protocol_pack_frame_rejects_over_255_byte_payload) { + std::vector big(256, 0); + CHECK_THROWS_AS(proto::pack_frame(0x10, big), std::invalid_argument); + std::vector ok(255, 0); + CHECK_NOTHROW(proto::pack_frame(0x10, ok)); +} + +TEST(protocol_unpack_frame_roundtrips) { + for (int len = 0; len <= 40; ++len) { + std::vector payload; + for (int i = 0; i < len; ++i) payload.push_back(uint8_t(i * 7 + 1)); + const auto f = proto::pack_frame(0x43, payload); + const auto got = proto::unpack_frame(f); + CHECK(got.has_value()); + if (got) { + CHECK_EQ(int(got->cmd), 0x43); + CHECK(got->payload == payload); + } + } +} + +TEST(protocol_unpack_frame_rejects_bad_input) { + const auto good = proto::pack_frame(0x10, std::vector{1, 2, 3}); + // 短于最小帧长 + CHECK(!proto::unpack_frame(std::vector{0xA5, 0x10, 0x00}).has_value()); + // SOF 不对 + auto bad_sof = good; + bad_sof[0] = 0x00; + CHECK(!proto::unpack_frame(bad_sof).has_value()); + // 长度与实际不符 + auto bad_len = good; + bad_len[2] = 0x09; + CHECK(!proto::unpack_frame(bad_len).has_value()); + // CRC 坏 + auto bad_crc = good; + bad_crc.back() ^= 0xFF; + CHECK(!proto::unpack_frame(bad_crc).has_value()); + // 尾部多一个字节 (长度就不匹配了) + auto extra = good; + extra.push_back(0x00); + CHECK(!proto::unpack_frame(extra).has_value()); +} + +// ---------------------------------------------------------------- f32 载荷 + +TEST(protocol_f32_pack_unpack_roundtrip) { + const std::vector vals{0.0, 1.5, -2.25, 3.14159265, 1e6, -1e-6, 123.456}; + const auto b = proto::pack_f32s(vals); + CHECK_EQ(b.size(), vals.size() * 4); + const auto back = proto::unpack_f32s(b.data(), 0, int(vals.size())); + for (size_t i = 0; i < vals.size(); ++i) { + CHECK_NEAR(back[i], vals[i], 1e-4); + } +} + +TEST(protocol_u16_u32_are_little_endian) { + const auto u16 = proto::pack_u16le(0x2FE); + CHECK_EQ(int(u16[0]), 0xFE); + CHECK_EQ(int(u16[1]), 0x02); + CHECK_EQ(int(proto::read_u16le(u16, 0)), 0x2FE); + const auto u32 = proto::pack_u32le(0xDEADBEEF); + CHECK_EQ(int(u32[0]), 0xEF); + CHECK_EQ(int(u32[3]), 0xDE); + CHECK_EQ(proto::read_u32le(u32, 0), uint32_t(0xDEADBEEF)); +} + +// ---------------------------------------------------------------- 版本解析 + +TEST(protocol_parse_firmware_version_accepts_the_agreed_shape) { + const auto v = proto::parse_firmware_version("Litearm1.5.2-7J"); + CHECK(v.has_value()); + CHECK_EQ(v->major, 1); + CHECK_EQ(v->minor, 5); + CHECK_EQ(v->patch, 2); + CHECK_EQ(v->variant, std::string("7J")); + + const auto bench = proto::parse_firmware_version("Litearm1.7.0-1J"); + CHECK(bench.has_value()); + CHECK_EQ(bench->variant, std::string("1J")); + + // 带空白也要能解析 (固件回显可能带尾随字符) + CHECK(proto::parse_firmware_version(" Litearm1.8.0-7J ").has_value()); + // 无 -variant 也算合法 (variant 空) + const auto no_variant = proto::parse_firmware_version("Litearm1.5.0"); + CHECK(no_variant.has_value()); + CHECK_EQ(no_variant->variant, std::string("")); +} + +TEST(protocol_parse_firmware_version_rejects_legacy_and_malformed) { + CHECK(!proto::parse_firmware_version("A1.2.3-USB").has_value()); // 旧形态 + CHECK(!proto::parse_firmware_version("Litearm1.5").has_value()); // 两段 + CHECK(!proto::parse_firmware_version("Litearm1.5.2.3-7J").has_value()); + CHECK(!proto::parse_firmware_version("Litearm1.x.2-7J").has_value()); + CHECK(!proto::parse_firmware_version("").has_value()); + CHECK(!proto::parse_firmware_version("garbage").has_value()); +} + +TEST(protocol_version_ordering_ignores_variant) { + const auto a = proto::parse_firmware_version("Litearm1.5.0-7J"); + const auto b = proto::parse_firmware_version("Litearm1.5.1-7J"); + const auto c = proto::parse_firmware_version("Litearm1.6.0-1J"); + CHECK(*a < *b); + CHECK(*b < *c); + CHECK(*a <= *a); + CHECK(*a == *a); + CHECK(!(*c < *a)); +} + +// ---------------------------------------------------------------- 开机签名 + +TEST(protocol_boot_banner_normal) { + const auto r = proto::parse_boot_banner("noise [litearm-usbcdc] ready (sig_ok=1) more"); + CHECK(r.has_value()); + CHECK_EQ(*r, std::string("normal")); +} + +TEST(protocol_boot_banner_iwdg_reset) { + const auto r = + proto::parse_boot_banner("x [litearm-usbcdc] ready (sig_ok=1, iwdg-rst) y"); + CHECK(r.has_value()); + CHECK_EQ(*r, std::string("iwdg-rst")); +} + +TEST(protocol_boot_banner_takes_the_last_one) { + // 重连/多次枚举会重复出现, 取最后一次。 + const auto r = proto::parse_boot_banner( + "[litearm-usbcdc] ready (a) ... [litearm-usbcdc] ready (b, iwdg-rst)"); + CHECK(r.has_value()); + CHECK_EQ(*r, std::string("iwdg-rst")); +} + +TEST(protocol_boot_banner_absent) { + CHECK(!proto::parse_boot_banner("").has_value()); + CHECK(!proto::parse_boot_banner("just some noise").has_value()); } -TEST(Protocol, StateTopic) { - EXPECT_EQ(state_topic("armA"), "litearm/v4/armA/state"); - EXPECT_EQ(state_topic("armB"), "litearm/v4/armB/state"); +// ---------------------------------------------------------------- 状态帧布局 + +TEST(protocol_decode_status_new_layout_with_joint_fault) { + // 6+21N: 固件 >= 1.5.0。7J => 153B。 + const std::vector q{0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7}; + const auto payload = lt::make_status_payload(0x0000, 42, q, 0x0008); + CHECK_EQ(payload.size(), size_t(6 + 21 * 7)); + const auto d = proto::decode_status(payload); + CHECK(d.has_value()); + CHECK_EQ(int(d->seq), 42); + CHECK_EQ(int(d->joints.size()), 7); + CHECK_EQ(int(d->joint_fault), 0x0008); + CHECK_NEAR(d->joints[3].q, 0.4, 1e-6); } -TEST(Protocol, CommandTopic) { - EXPECT_EQ(command_topic("armA"), "litearm/v4/armA/command"); +TEST(protocol_decode_status_legacy_layout_without_joint_fault) { + // 4+21N: 固件 <= 1.4.x。尾部没有 joint_fault, 视为 0。 + const std::vector q{1.0}; + std::vector body; + const auto h = proto::pack_u16le(0); + body.insert(body.end(), h.begin(), h.end()); + const auto s = proto::pack_u16le(7); + body.insert(body.end(), s.begin(), s.end()); + const auto v = proto::pack_f32s(std::vector{1.0, 0.0, 0.0, 0.0, 0.0}); + body.insert(body.end(), v.begin(), v.end()); + body.push_back(0); + CHECK_EQ(body.size(), size_t(4 + 21)); + const auto d = proto::decode_status(body); + CHECK(d.has_value()); + CHECK_EQ(int(d->joints.size()), 1); + CHECK_EQ(int(d->seq), 7); + CHECK_EQ(int(d->joint_fault), 0); } -TEST(Protocol, EstopTopic) { - EXPECT_EQ(estop_topic("armA"), "litearm/v4/armA/estop"); +TEST(protocol_decode_status_rejects_illegal_shapes) { + CHECK(!proto::decode_status(std::vector{1, 2}).has_value()); // < 4B + // 关节数不是 1 也不是 7 + std::vector three(4 + 21 * 3, 0); + CHECK(!proto::decode_status(three).has_value()); + // 尾部多出 1 个字节 (既不是 0 也不是 2) + std::vector bad_tail(4 + 21 * 7 + 1, 0); + CHECK(!proto::decode_status(bad_tail).has_value()); } -TEST(Protocol, ProtocolVersion) { - EXPECT_EQ(PROTOCOL_VERSION, 1); +TEST(protocol_decode_status_splits_mode_and_flags) { + // flags 低 6 位是安全 flag, bit6..8 是 mode, bit9 enabled, bit10 cart_busy。 + const uint16_t flags = uint16_t(0x0001 | (3 << 6) | (1 << 9) | (1 << 10)); + const std::vector q(7, 0.0); + const auto d = proto::decode_status(lt::make_status_payload(flags, 0, q)); + CHECK(d.has_value()); + CHECK_EQ(d->mode, 3); + CHECK_EQ(int(d->flag_names.size()), 1); // 只有 FAULT, mode/enabled/cart_busy 不算 + CHECK_EQ(std::string(d->flag_names[0]), std::string("FAULT")); + CHECK_EQ(std::string(proto::mode_name(3)), std::string("MOVE_JS")); } -} // namespace -} // namespace litearm +TEST(protocol_mode_name_covers_all_and_unknown) { + CHECK_EQ(std::string(proto::mode_name(0)), std::string("INIT")); + CHECK_EQ(std::string(proto::mode_name(7)), std::string("ZERO_G")); + CHECK_EQ(std::string(proto::mode_name(99)), std::string("UNKNOWN")); +} + +TEST(protocol_flag_name_only_covers_the_six_safety_bits) { + CHECK_EQ(std::string(proto::flag_name(0)), std::string("FAULT")); + CHECK_EQ(std::string(proto::flag_name(5)), std::string("OVERSPEED")); + CHECK_EQ(std::string(proto::flag_name(6)), std::string("UNKNOWN")); // bit6 起是 mode + CHECK_EQ(std::string(proto::flag_name(9)), std::string("UNKNOWN")); // 是 enabled +} + +// ---------------------------------------------------------------- 归属判据 + +TEST(protocol_only_ack_and_err_are_keyed_by_echo) { + CHECK(proto::is_echoed(proto::RSP_ACK)); + CHECK(proto::is_echoed(proto::RSP_ERR)); + // 别的帧的 payload[0] 是**数据**, 拿它当归属键是错的 + CHECK(!proto::is_echoed(proto::RSP_STATUS)); + CHECK(!proto::is_echoed(proto::RSP_JOINT_PARAM)); + CHECK(!proto::is_echoed(proto::RSP_LICENSE)); +} + +// ---------------------------------------------------------------- 覆盖契约 + +TEST(protocol_command_coverage_matches_the_python_table_exactly) { + // 判据是**逐条对表**, 不是"数量差不多": 这份契约是"固件每条已实现命令都有 SDK 入口" + // 的断言载体, 少一条就是少一个入口。 + // ⚠ 数量是 49 —— 上游注释里那句"47/47"是更早一轮的记数, 表本身一直是 49 条。 + const std::set expected = { + 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x10, 0x11, 0x12, + 0x13, 0x14, 0x15, 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, + 0x27, 0x28, 0x2A, 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, + 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x3B, + 0x3C, 0x3D, 0x3E, 0x3F, 0x40, 0x41, 0x42, 0x43, 0x49, + }; + std::set seen; + for (const auto& c : proto::command_coverage()) { + CHECK(seen.insert(c.cmd).second); // 无重复命令码 + CHECK(c.entry != nullptr && c.entry[0] != '\0'); + } + CHECK_EQ(int(seen.size()), int(expected.size())); + CHECK(seen == expected); + // 0x16 在固件里不存在 (0x15 与 0x20 之间没有命令), 不该被凭空补进来。 + CHECK(seen.count(0x16) == 0); +} + +TEST(protocol_sync_sentinel_tables_are_all_empty) { + // 四张表的语义**各不相同** (别混), 而它们**当前都是空的** —— 留空是有意的: + // 它们是"当前没有未实现命令 / 没有固件单边命令 / 没有已知缺口"这句话的断言载体。 + // 往任何一张里加条目都等于宣布一件事, 加之前先想清楚 (见 protocol.hpp 的说明)。 + CHECK(proto::unimplemented_cmds().empty()); + CHECK(proto::firmware_only_cmds().empty()); + CHECK(proto::firmware_only_rsps().empty()); + CHECK(proto::preexisting_gaps().empty()); + + // ⚠ **本仓跑不了固件侧的比对**: 上游 `test_protocol_sync.py` 要解析固件仓的 + // `hal/usb_cmd.h` 双向对表, 而这里没有固件树。所以**别把这条读成"已经跟固件对过账"** + // —— 它只保证"我们这边没有单方面豁免任何东西"。 + // 要补上固件侧的那一半: 把固件仓放到旁边, 解析 usb_cmd.h 的 `#define CMD_*` / + // `#define RSP_*` 名字与值, 与本文件的常量表双向比对。 +} + +TEST(protocol_rsp_of_cmd_table_is_consistent) { + for (const auto& kv : proto::rsp_of_cmd()) { + const auto got = proto::rsp_of(kv.first); + CHECK(got.has_value()); + if (got) CHECK_EQ(int(*got), int(kv.second)); + } + // ACK/ERR 两条由命令码自己就能推出来, 故不在表里 + CHECK(!proto::rsp_of(proto::CMD_MOVE_J).has_value()); + // GET_STATUS 的应答走单槽不是队列, 故也不在 + CHECK(!proto::rsp_of(proto::CMD_GET_STATUS).has_value()); + CHECK_EQ(int(*proto::rsp_of(proto::CMD_GET_JOINT_PARAM)), int(proto::RSP_JOINT_PARAM)); + CHECK_EQ(int(*proto::rsp_of(proto::CMD_GET_JOINT_PARAM)), int(proto::RSP_JOINT_PARAM)); +} + +TEST(protocol_dual_id_constants_are_intentional) { + // 0x49: 下行 CMD_KIN_BENCH 与上行 RSP_JOINT_PARAM 同值 —— 靠收发方向区分。 + CHECK_EQ(int(proto::CMD_KIN_BENCH), int(proto::RSP_JOINT_PARAM)); + // 0x41: 下行 CMD_GET_FIRMWARE 与上行 RSP_DETAIL 同值。 + CHECK_EQ(int(proto::CMD_GET_FIRMWARE), int(proto::RSP_DETAIL)); +} diff --git a/tests/test_protocol_sync.cpp b/tests/test_protocol_sync.cpp new file mode 100644 index 0000000..9d87b75 --- /dev/null +++ b/tests/test_protocol_sync.cpp @@ -0,0 +1,523 @@ +// 协议漂移防护 —— SDK 与固件头文件的**双向强制比对**。 +// +// 为什么必须有这个文件: 上一次栽的跟头就是「命令/帧布局改了, SDK 没跟上」—— +// 固件 1.5.x 把状态帧从 `4+21N` 改成 `6+21N` 时漏同步 SDK, 而桩测试整替换 transport、 +// 自造旧布局, 结果**离线全绿、真机必挂**。人工核对挡不住这种事, 只有把固件头文件当输入 +// 喂给测试才挡得住。 +// +// 做法: 直接解析**固件仓库的源码** (不依赖固件编译), 断言固件与 SDK 的命令/应答常量 +// 双向一致、入口无死角、状态帧布局未变、能力判定假设仍成立、版本门满足。 +// +// 固件仓库位置: 环境变量 `LITEARM_FW_DIR`, 默认 `~/litearm-stm32`。 +// **找不到时 SKIP, 不是 pass** —— 假绿比没测更糟 (上游同款口径)。 +// +// ⚠ **SDK 侧用"解析 protocol.hpp"代替 Python 的 `dir(P)`**: C++ 没有反射, 而 +// `inline constexpr uint8_t CMD_X = 0xNN;` 与固件的 `#define CMD_X 0xNN` 是**同一种 +// 东西** —— 两边都按**源码**对表, 反而是最对称的口径。副作用: 与 `test_full_coverage.cpp` +// 的"公开面哨兵"同一种技术 (那儿扫的是 `arm.hpp`)。 +// +// ⚠ **"入口可调用"那半条移到了动态检查**: 上游用 `getattr(arm, "Arm.params.set_joint_param")` +// 验证入口摸得到。C++ 没有这个名字解析, 而 `test_full_coverage.cpp` 的 +// `coverage_every_implemented_command_is_exercised_through_its_entry` 做的是**更强**的 +// 事 —— 它把每个入口**真调一遍**并断言命令真的发出去了。故这里只做"命令集合双向一致"。 +#include +#include +#include +#include +#include +#include +#include + +#include "test_support.hpp" + +using namespace litearm; + +namespace { + +// ---------------------------------------------------------------- 固件源码 + +std::string fw_dir() { + const char* env = std::getenv("LITEARM_FW_DIR"); + if (env != nullptr && *env != '\0') return env; + const char* home = std::getenv("HOME"); + if (home != nullptr && *home != '\0') return std::string(home) + "/litearm-stm32"; + return "litearm-stm32"; +} + +/// 读固件树里的一个文件; 不在则 SKIP (不是失败, 但**必须看得见**)。 +std::string fw_read(const std::string& rel) { + const std::string full = fw_dir() + "/" + rel; + FILE* f = std::fopen(full.c_str(), "rb"); + if (f == nullptr) { + SKIP("固件源码不在 " + full + + " —— 设 LITEARM_FW_DIR 指向 litearm-stm32 仓库 (找不到时 SKIP, 不是 pass: " + "假绿比没测更糟)"); + } + std::string out; + char buf[8192]; + size_t n = 0; + while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n); + std::fclose(f); + return out; +} + +const char* kUsbCmdH = "User/litearm/hal/usb_cmd.h"; +const char* kUsbCmdC = "User/litearm/hal/usb_cmd.c"; +const char* kLitearmH = "User/litearm/litearm.h"; +const char* kJointCfgH = "User/litearm/params/joint_cfg.h"; + +// ---------------------------------------------------------------- 解析工具 + +std::vector split_lines(const std::string& s) { + std::vector out; + std::string cur; + for (char c : s) { + if (c == '\n') { + out.push_back(cur); + cur.clear(); + } else { + cur.push_back(c); + } + } + out.push_back(cur); + return out; +} + +/// `#define NAME 0xNN <行尾>` -> {名字: (值, 行尾注释/内容)}。 +/// +/// ⚠ **逐行匹配, 不跨行** —— 上游那条注释记着这个坑: 用 `\s` 会跨越换行, 于是 `(.*)$` +/// 会把**下一行**整行吞掉, 使相邻的 `#define` 交替漏匹配 (当年 34 条只命中 26 条)。 +/// 逐行做天然不会有这个问题。 +std::map> fw_defines(const std::string& src, + const std::string& prefix, + size_t min_expected) { + std::map> out; + const std::regex re("^#[ \\t]*define[ \\t]+(" + prefix + + "[A-Za-z0-9_]+)[ \\t]+(0x[0-9A-Fa-f]+)[ \\t]*(.*)$"); + for (const auto& line : split_lines(src)) { + std::smatch m; + if (!std::regex_match(line, m, re)) continue; + out[m[1].str()] = {int(std::stol(m[2].str(), nullptr, 16)), m[3].str()}; + } + // ⚠ **防空转**: 一条都没解析出来时, 所有"遍历固件常量"的判据都会**静默通过** —— + // 那正是本文件要消灭的"假绿" (上游的 Python 版有同一个洞)。 + // 下界取保守值: 固件**增加**命令只会让条数变多, 所以下界不会误伤; 而解析器一旦被 + // 改坏 (或固件头的写法变了), 条数会塌到 0, 这里当场红。 + if (out.size() < min_expected) { + FAIL("固件头文件的 `" + prefix + "` 常量只解析出 " + std::to_string(out.size()) + + " 条 (下界 " + std::to_string(min_expected) + + ") —— 固件头格式可能变了, 或者解析器坏了; 后面那些判据会**静默失效**"); + } + return out; +} + +/// 剥掉 `/* */` 与 `//` 注释 (SDK 头文件里注释密度很高, 不剥会把注释里的名字当常量)。 +std::string strip_comments(const std::string& raw) { + std::string src; + src.reserve(raw.size()); + for (size_t i = 0; i < raw.size();) { + if (raw[i] == '/' && i + 1 < raw.size() && raw[i + 1] == '*') { + const size_t e = raw.find("*/", i + 2); + i = (e == std::string::npos) ? raw.size() : e + 2; + src.push_back(' '); + } else if (raw[i] == '/' && i + 1 < raw.size() && raw[i + 1] == '/') { + const size_t e = raw.find('\n', i); + i = (e == std::string::npos) ? raw.size() : e; + src.push_back(' '); + } else { + src.push_back(raw[i++]); + } + } + return src; +} + +/// SDK 侧: `inline constexpr uint8_t CMD_X = 0xNN;` -> {名字: 值}。 +/// 这就是 Python 那边 `dir(_protocol)` 的 C++ 等价物 (见文件顶部说明)。 +std::map sdk_defines(const std::string& prefix) { + const std::string src = strip_comments(lt::read_repo_file("include/litearm/protocol.hpp")); + std::map out; + const std::regex re("\\b(" + prefix + "[A-Za-z0-9_]+)\\s*=\\s*(0x[0-9A-Fa-f]+)"); + for (auto it = std::sregex_iterator(src.begin(), src.end(), re); + it != std::sregex_iterator(); ++it) { + out[(*it)[1].str()] = int(std::stol((*it)[2].str(), nullptr, 16)); + } + return out; +} + +/// 取出 `static void (void) { ... }` 的函数体 (把解析限定在单个函数内)。 +/// C 源码里用花括号配对来收尾, 比正则的非贪婪更稳。 +std::string fn_body(const std::string& src, const std::string& name) { + const std::string key = "static void " + name + "(void)"; + size_t p = src.find(key); + if (p == std::string::npos) { + // 容忍 `static void\n(void)` + const std::regex re("static[ \\t]+void[ \\t]+" + name + "[ \\t]*\\([ \\t]*void[ \\t]*\\)"); + std::smatch m; + if (!std::regex_search(src, m, re)) return {}; + p = size_t(m.position()); + } + const size_t brace = src.find('{', p); + if (brace == std::string::npos) return {}; + int depth = 1; + size_t i = brace + 1; + while (i < src.size() && depth > 0) { + if (src[i] == '{') ++depth; + else if (src[i] == '}') --depth; + ++i; + } + return src.substr(brace + 1, i > brace + 1 ? i - brace - 2 : 0); +} + +std::string collapse_spaces(const std::string& s) { + std::string out; + bool prev_space = false; + for (char c : s) { + const bool is_sp = (c == ' ' || c == '\t' || c == '\n' || c == '\r'); + if (is_sp) { + if (!prev_space && !out.empty()) out.push_back(' '); + } else { + out.push_back(c); + } + prev_space = is_sp; + } + while (!out.empty() && out.back() == ' ') out.pop_back(); + return out; +} + +std::map> unimplemented_map() { + std::map> out; + for (const auto& kv : proto::unimplemented_cmds()) { + out[kv.second] = {int(kv.first), ""}; + } + return out; +} + +std::set values_of(const std::map& m) { + std::set out; + for (const auto& kv : m) out.insert(kv.second); + return out; +} + +} // namespace + +// ---------------------------------------------------------------- 1. 常量双向 + +TEST(sync_every_firmware_cmd_has_sdk_constant) { + // 固件每条已实现命令都要在 SDK 里有**同名同值**常量。 + const auto fw = fw_defines(fw_read(kUsbCmdH), "CMD_", 40); + const auto sdk = sdk_defines("CMD_"); + std::set exempt = values_of(proto::unimplemented_cmds()); + for (const auto& n : values_of(proto::firmware_only_cmds())) exempt.insert(n); + for (const auto& n : values_of(proto::preexisting_gaps())) exempt.insert(n); + + std::vector missing; + for (const auto& kv : fw) { + if (exempt.count(kv.first)) continue; + const auto it = sdk.find(kv.first); + char buf[160]; + if (it == sdk.end()) { + std::snprintf(buf, sizeof(buf), "%s=0x%02X (SDK: 没有)", kv.first.c_str(), + kv.second.first); + } else if (it->second != kv.second.first) { + std::snprintf(buf, sizeof(buf), "%s=0x%02X (SDK: 0x%02X)", kv.first.c_str(), + kv.second.first, it->second); + } else { + continue; + } + missing.push_back(buf); + } + if (!missing.empty()) { + std::string msg = "SDK protocol.hpp 缺少/值不符的固件命令:"; + for (const auto& m : missing) msg += " " + m; + FAIL(msg); + } +} + +TEST(sync_sdk_has_no_command_the_firmware_lacks) { + // 反向: SDK 不许声明固件没有的命令 (漂移)。 + const auto fw = fw_defines(fw_read(kUsbCmdH), "CMD_", 40); + const auto sdk = sdk_defines("CMD_"); + std::string extra; + for (const auto& kv : sdk) { + if (fw.count(kv.first) == 0) extra += " " + kv.first; + } + if (!extra.empty()) FAIL("SDK 声明了固件没有的命令 (漂移):" + extra); +} + +TEST(sync_every_firmware_rsp_has_sdk_constant) { + const auto fw = fw_defines(fw_read(kUsbCmdH), "RSP_", 15); + const auto sdk = sdk_defines("RSP_"); + const auto exempt = values_of(proto::firmware_only_rsps()); + std::vector missing; + for (const auto& kv : fw) { + if (exempt.count(kv.first)) continue; + const auto it = sdk.find(kv.first); + char buf[160]; + if (it == sdk.end()) { + std::snprintf(buf, sizeof(buf), "%s=0x%02X (SDK: 没有)", kv.first.c_str(), + kv.second.first); + } else if (it->second != kv.second.first) { + std::snprintf(buf, sizeof(buf), "%s=0x%02X (SDK: 0x%02X)", kv.first.c_str(), + kv.second.first, it->second); + } else { + continue; + } + missing.push_back(buf); + } + if (!missing.empty()) { + std::string msg = "SDK protocol.hpp 缺少/值不符的固件应答:"; + for (const auto& m : missing) msg += " " + m; + FAIL(msg); + } +} + +// ---------------------------------------------------------------- 2. 入口无死角 + +TEST(sync_every_implemented_cmd_has_a_coverage_entry) { + // 「无死角」: 固件每条已实现命令都要在 `COMMAND_COVERAGE` 里登记。 + // (上游这里还 `getattr` 验一次入口摸得到 —— C++ 没有名字解析, 那半条由 + // `test_full_coverage.cpp` 的**动态**检查覆盖: 它把每个入口真调一遍。) + const auto fw = fw_defines(fw_read(kUsbCmdH), "CMD_", 40); + std::map coverage; + for (const auto& c : proto::command_coverage()) coverage[int(c.cmd)] = c.entry; + + std::vector problems; + for (const auto& kv : fw) { + const int val = kv.second.first; + if (proto::unimplemented_cmds().count(uint8_t(val)) || + proto::firmware_only_cmds().count(uint8_t(val)) || + proto::preexisting_gaps().count(uint8_t(val))) { + continue; + } + if (coverage.count(val) == 0) { + char buf[160]; + std::snprintf(buf, sizeof(buf), "0x%02X %s: 未登记 SDK 入口", val, + kv.first.c_str()); + problems.push_back(buf); + } + } + if (!problems.empty()) { + std::string msg = "覆盖缺口:"; + for (const auto& p : problems) msg += " " + p; + FAIL(msg); + } +} + +TEST(sync_coverage_map_has_no_stale_entries) { + // 反向: 覆盖表里不许有固件已不存在的命令 id (否则是过期登记)。 + const auto fw = fw_defines(fw_read(kUsbCmdH), "CMD_", 40); + std::set fw_ids; + for (const auto& kv : fw) fw_ids.insert(kv.second.first); + std::string stale; + for (const auto& c : proto::command_coverage()) { + if (fw_ids.count(int(c.cmd)) == 0) { + char buf[32]; + std::snprintf(buf, sizeof(buf), " 0x%02X", c.cmd); + stale += buf; + } + } + if (!stale.empty()) FAIL("COMMAND_COVERAGE 有过期登记:" + stale); +} + +TEST(sync_exemption_tables_are_still_valid) { + // 三张豁免表都**不是永久白名单**: 固件侧若删掉/改名/换号, 这条必须红。 + // + // ⚠ 三张表的**语义不同**, 别以为一条判据就是一个意思: + // · `FIRMWARE_ONLY_CMDS`/`FIRMWARE_ONLY_RSPS` —— 用户裁决 SDK **有意不暴露** + // ("不做", 不是"还没做"); 两张**现在都是空的**; + // · `PREEXISTING_GAPS` —— **既有的固件/SDK 不同步**, 本应同步而未同步。 + // 判据相同 (固件里必须仍有这几条) 不等于理由相同。 + // + // ⚠ 它同时守反向: 一旦 SDK 补上了同名常量 (= 真的实现了入口), 豁免就该撤掉, + // 否则豁免表会变成"谁都看不见的过期登记"。 + const auto fw = fw_defines(fw_read(kUsbCmdH), "CMD_", 40); + const auto fwr = fw_defines(fw_read(kUsbCmdH), "RSP_", 15); + const auto sdk_c = sdk_defines("CMD_"); + const auto sdk_r = sdk_defines("RSP_"); + + std::vector problems; + auto check_cmd = [&](const std::map& tbl, const char* tblname) { + for (const auto& kv : tbl) { + const auto it = fw.find(kv.second); + if (it == fw.end()) { + problems.push_back(std::string(tblname) + " 里的 " + kv.second + + " 在固件头文件里已不存在 —— 请从该表移除"); + continue; + } + if (it->second.first != int(kv.first)) { + char buf[200]; + std::snprintf(buf, sizeof(buf), + "%s 在固件里是 0x%02X, %s 写的是 0x%02X —— 请同步", + kv.second.c_str(), it->second.first, tblname, int(kv.first)); + problems.push_back(buf); + continue; + } + if (sdk_c.count(kv.second)) { + problems.push_back(kv.second + " 已被 SDK 实现, 请从 " + tblname + " 移除"); + } + } + }; + check_cmd(proto::firmware_only_cmds(), "FIRMWARE_ONLY_CMDS"); + check_cmd(proto::preexisting_gaps(), "PREEXISTING_GAPS"); + for (const auto& kv : proto::firmware_only_rsps()) { + const auto it = fwr.find(kv.second); + if (it == fwr.end()) { + problems.push_back("FIRMWARE_ONLY_RSPS 里的 " + kv.second + + " 在固件头文件里已不存在 —— 请从该表移除"); + continue; + } + if (it->second.first != int(kv.first)) { + char buf[200]; + std::snprintf(buf, sizeof(buf), + "%s 在固件里是 0x%02X, FIRMWARE_ONLY_RSPS 写的是 0x%02X —— 请同步", + kv.second.c_str(), it->second.first, int(kv.first)); + problems.push_back(buf); + } else if (sdk_r.count(kv.second)) { + problems.push_back(kv.second + " 已被 SDK 实现, 请从 FIRMWARE_ONLY_RSPS 移除"); + } + } + if (!problems.empty()) { + std::string msg = "豁免表已过期:"; + for (const auto& p : problems) msg += " [" + p + "]"; + FAIL(msg); + } +} + +// ---------------------------------------------------------------- 3. 状态帧布局 + +TEST(sync_status_frame_layout_unchanged) { + // 状态帧载荷长度表达式必须仍是 `6 + N*21` (SDK 解析器按此硬编码)。 + const std::string body = fn_body(fw_read(kUsbCmdC), "usb_cmd_report_status"); + CHECK(!body.empty()); // 找不到函数体 => 固件源码结构变了 + if (body.empty()) return; + const std::regex re("uint8_t[ \\t]+payload[ \\t]*\\[[ \\t]*([^\\]]+)\\]"); + std::smatch m; + if (!std::regex_search(body, m, re)) { + FAIL("找不到状态帧 payload 声明 —— 固件源码结构可能变了"); + return; + } + const std::string expr = collapse_spaces(m[1].str()); + CHECK_EQ(expr, std::string("6 + LITEARM_NUM_JOINTS * 21")); + if (expr != "6 + LITEARM_NUM_JOINTS * 21") { + // 这条一旦红, 说明固件侧改了布局而 SDK 的 decode_status 还按 6+21N 解 —— + // 历史上正是这类改动漏同步导致"离线全绿、真机必挂"。 + FAIL("固件状态帧布局变了: `" + expr + "` —— SDK decode_status 的 6+21N 假设需同步"); + } +} + +TEST(sync_status_frame_joint_stride_matches_sdk_constant) { + // 每关节字节数 (5 个 f32 + 1 个 err = 21) 必须与 SDK 的步长一致。 + const std::string body = fn_body(fw_read(kUsbCmdC), "usb_cmd_report_status"); + CHECK(!body.empty()); + if (body.empty()) return; + const std::regex re( + // ⚠ 与上游**逐字对齐**: 上游是 `\{(.*?)\n \}` (非贪婪到"换行 + 4 空格 + 右花括号"), + // 而**不是**"到第一个 `}`" —— 循环体里只要有内层花括号 (例如一个 if), 后者就会 + // 截断, 于是步长算少、判据失去意义。这类"看着等价其实不等价"的正则正是本文件 + // 要防的东西。 + "for[ \\t]*\\(int i = 0; i < LITEARM_NUM_JOINTS; i\\+\\+\\)[ \\t]*" + "\\{([\\s\\S]*?)\\n \\}"); + std::smatch m; + if (!std::regex_search(body, m, re)) { + FAIL("找不到状态帧的逐关节打包循环 —— 固件源码结构可能变了"); + return; + } + const std::string seg = m[1].str(); + int n_floats = 0, n_u8 = 0; + const std::regex re_f32("f32_to_le[ \\t]*\\("); + const std::regex re_u8("\\*pp\\+\\+[ \\t]*="); + for (auto it = std::sregex_iterator(seg.begin(), seg.end(), re_f32); + it != std::sregex_iterator(); ++it) { + ++n_floats; + } + for (auto it = std::sregex_iterator(seg.begin(), seg.end(), re_u8); + it != std::sregex_iterator(); ++it) { + ++n_u8; + } + CHECK_EQ(n_floats * 4 + n_u8, 21); +} + +// ---------------------------------------------------------------- 4. 台架常量 + +TEST(sync_bench_model_axis_matches_sdk_constant) { + const std::string src = fw_read(kJointCfgH); + const std::regex re("#if\\s+LITEARM_BENCH_1J([\\s\\S]*?)#else"); + std::smatch m; + if (!std::regex_search(src, m, re)) { + FAIL("joint_cfg.h 结构变了 (找不到 #if LITEARM_BENCH_1J ... #else)"); + return; + } + const std::regex ax("#define[ \\t]+LITEARM_BENCH_MODEL_AXIS[ \\t]+(\\d+)"); + std::smatch am; + const std::string bench_branch = m[1].str(); // 命名成 lvalue: 临时对象绑不上 + if (!std::regex_search(bench_branch, am, ax)) { + FAIL("找不到台架分支的 LITEARM_BENCH_MODEL_AXIS"); + return; + } + const int fw_axis = std::stoi(am[1].str()); + CHECK_EQ(fw_axis, proto::BENCH_MODEL_AXIS); + if (fw_axis != proto::BENCH_MODEL_AXIS) { + // 不一致 => 台架上的 IK 种子会填错轴。 + FAIL("固件台架模型轴 = " + std::to_string(fw_axis) + ", SDK BENCH_MODEL_AXIS = " + + std::to_string(proto::BENCH_MODEL_AXIS) + " —— IK 种子会填错轴"); + } +} + +// ---------------------------------------------------------------- 5. 能力判定假设 + +TEST(sync_err_code_zero_only_comes_from_the_default_branch) { + // `ERR{cmd,0x00}` 必须**只**由未实现命令的 `default` 分支产生 —— + // SDK 的 `UnsupportedByFirmwareError` 判定完全建立在这个假设上。 + const std::string src = fw_read(kUsbCmdC); + const std::regex re("RSP_ERR,\\s*\\(const uint8_t\\[\\]\\)\\{([^}]*)\\}"); + std::vector zeros; + for (auto it = std::sregex_iterator(src.begin(), src.end(), re); + it != std::sregex_iterator(); ++it) { + const std::string inner = collapse_spaces((*it)[1].str()); + if (inner.size() >= 4 && inner.compare(inner.size() - 4, 4, "0x00") == 0) { + zeros.push_back(inner); + } + } + CHECK_EQ(int(zeros.size()), 1); + if (zeros.size() != 1) { + FAIL("固件里有 " + std::to_string(zeros.size()) + + " 处回 ERR 码 0x00 (期望恰好 1 处, 即 default 分支) —— SDK 会把它误判成" + "「固件不支持该命令」"); + return; + } + // 那唯一一处必须在 default 分支里。 + const std::regex dflt( + "default:\\s*\\n\\s*usb_cmd_reply\\(RSP_ERR,\\s*\\(const uint8_t\\[\\]\\)\\{" + "cmd, 0x00\\}"); + CHECK(std::regex_search(src, dflt)); +} + +// ---------------------------------------------------------------- 6. 版本门 + +TEST(sync_firmware_version_not_below_sdk_minimum) { + const std::string src = fw_read(kLitearmH); + const std::regex re("#define[ \\t]+LITEARM_FW_VERSION[ \\t]+\"([^\"]+)\""); + std::vector vers; + for (auto it = std::sregex_iterator(src.begin(), src.end(), re); + it != std::sregex_iterator(); ++it) { + vers.push_back((*it)[1].str()); + } + CHECK(!vers.empty()); + if (vers.empty()) { + FAIL("找不到 LITEARM_FW_VERSION"); + return; + } + std::string bad; + for (const auto& v : vers) { + const auto p = proto::parse_firmware_version(v); + if (!p) { + bad += " " + v + "(不符合约定)"; + continue; + } + if (*p < MIN_FW) { + bad += " " + v + "(低于 SDK 要求的 " + std::to_string(MIN_FW.major) + "." + + std::to_string(MIN_FW.minor) + "." + std::to_string(MIN_FW.patch) + ")"; + } + } + if (!bad.empty()) FAIL("固件自报版本不满足 SDK 最低要求:" + bad); +} diff --git a/tests/test_register.cpp b/tests/test_register.cpp new file mode 100644 index 0000000..0fd96df --- /dev/null +++ b/tests/test_register.cpp @@ -0,0 +1,233 @@ +// 《未验证登记册》的**格式守门**。 +// +// ⚠⚠ 这条测试存在的理由,与登记册本身存在的理由是同一条:**「存在差异」这种没有出处的 +// 条目等于没登记** —— 现场复核不了,就得从头再查一遍。而"每条都带出处"如果只靠自觉, +// 它会在第一次赶时间的时候失效。所以把它变成**机器判据**。 +// +// ⚠ 判据只查**形状**(有没有写出处、几列、id 连不连号),**查不了出处是真的**。 +// 别把"格式绿"读成"登记册都核过了" —— 那是两件事。这条限制本身也登记在册(第 18 条)。 +#include "test_support.hpp" + +#include +#include +#include + +using namespace litearm; + +namespace { + +constexpr const char* kRegistryPath = "UNVERIFIED_REGISTER.md"; + +std::string trim(const std::string& s) { + size_t a = 0, b = s.size(); + while (a < b && std::isspace(static_cast(s[a]))) ++a; + while (b > a && std::isspace(static_cast(s[b - 1]))) --b; + return s.substr(a, b - a); +} + +struct EntryRow { + int line_no = 0; + int id = 0; + size_t cols = 0; + std::string text; +}; + +std::vector slurp_lines(const std::string& text) { + std::vector out; + std::string cur; + for (char c : text) { + if (c == '\n') { + if (!cur.empty() && cur.back() == '\r') cur.pop_back(); // CRLF 容错 + out.push_back(cur); + cur.clear(); + } else { + cur.push_back(c); + } + } + if (!cur.empty()) out.push_back(cur); + return out; +} + +/// 解析"条目行":`| <整数> | … | … | … | … |`。 +/// ⚠ **按结构判**(首列是纯十进制整数),不是按"含 `|`" —— 后者会把表头、分隔线、 +/// 图例的小表一起卷进来,判据就退化成"这文件非空且有冒号"。 +std::vector entry_rows(const std::vector& lines) { + std::vector out; + for (size_t i = 0; i < lines.size(); ++i) { + const std::string s = trim(lines[i]); + if (s.empty() || s[0] != '|') continue; + std::vector cells; + std::string cur; + for (char c : s) { + if (c == '|') { + cells.push_back(cur); + cur.clear(); + } else { + cur.push_back(c); + } + } + cells.push_back(cur); + if (cells.size() < 3u) continue; + const std::string first = trim(cells[1]); + if (first.empty() || first.find_first_not_of("0123456789") != std::string::npos) continue; + EntryRow r; + r.line_no = int(i) + 1; + r.id = std::atoi(first.c_str()); + r.cols = cells.size() - 2u; // 去掉首尾两个空串 ⇒ 真实列数 + r.text = s; + out.push_back(std::move(r)); + } + return out; +} + +/// 出处的**形状**:`文件.扩展名:数字` 或 7~40 位十六进制(提交哈希)。 +/// ⚠ 两半都要有 —— 只认前者会让"见提交 `ca391b1`"这类条目过不去;只认后者则行号出处过不去。 +bool has_reference(const std::string& line) { + static const std::regex kFileLine(R"(\w+\.\w+:\d+)"); + static const std::regex kCommitHash(R"(\b[0-9a-f]{7,40}\b)"); + return std::regex_search(line, kFileLine) || std::regex_search(line, kCommitHash); +} + +struct Registry { + std::vector lines; + std::vector rows; + bool loaded = false; +}; + +const Registry& registry() { + static Registry r = [] { + Registry out; + const std::string raw = lt::read_repo_file(kRegistryPath); + if (raw.empty()) return out; + out.lines = slurp_lines(raw); + out.rows = entry_rows(out.lines); + out.loaded = true; + return out; + }(); + return r; +} + +} // namespace + +TEST(register_file_is_present_and_not_empty) { + const auto& r = registry(); + CHECK(r.loaded); + if (!r.loaded) return; + // ⚠ 空册只有一种解释:这一步没做。本仓确实有要登记的东西(分叉、继承缺陷、未验证项), + // 所以"空"不是一个合法状态。 + CHECK(!r.lines.empty()); + CHECK(!r.rows.empty()); +} + +TEST(register_every_entry_carries_a_file_or_commit_reference) { + const auto& r = registry(); + // ⚠ 先钉"条目行 ≥ 1":**空集上「每行都有出处」恒真** —— 那正是本仓明令禁止的空炮形状。 + CHECK(!r.rows.empty()); + if (r.rows.empty()) return; + std::vector bad; + for (const auto& e : r.rows) { + if (!has_reference(e.text)) { + bad.push_back("第 " + std::to_string(e.line_no) + " 行 (条目 " + + std::to_string(e.id) + "): " + e.text); + } + } + if (!bad.empty()) { + std::string msg = + "以下登记条目**没有出处** —— 必须带 `文件:行号` 或 7~40 位提交哈希:\n"; + for (const auto& b : bad) msg += " " + b + "\n"; + msg += " ⚠ 「存在差异」这种没有出处的条目**等于没登记**(现场无法复核)。"; + FAIL(msg); + } +} + +TEST(register_every_entry_row_has_exactly_five_cells) { + const auto& r = registry(); + CHECK(!r.rows.empty()); + if (r.rows.empty()) return; + // 5 = 登记册开头「格式约定」钉死的那张表的列数 (# / 项 / 性质 / 状态 / 出处)。 + // ⚠ 改列数 = 改**全册**体例 ⇒ 那件事要连本行一起改(故意的)。 + constexpr size_t kCols = 5u; + std::vector bad; + for (const auto& e : r.rows) { + if (e.cols != kCols) { + bad.push_back("第 " + std::to_string(e.line_no) + " 行 (条目 " + + std::to_string(e.id) + ") 有 " + std::to_string(e.cols) + + " 列, 不是 " + std::to_string(kCols) + " 列"); + } + } + if (!bad.empty()) { + std::string msg = + "登记册表格结构坏了(多半是少写或多写了一个 `|`):\n"; + for (const auto& b : bad) msg += " " + b + "\n"; + msg += " ⚠ 这一条**故意不看内容**: 出处那条判据只查『这行有没有出处』,\n" + " 少一个 `|` 时出处会**被动地**并进别的格里 ⇒ 那条判据照样绿。"; + FAIL(msg); + } +} + +TEST(register_has_no_table_row_that_lost_its_leading_pipe) { + // ⚠⚠ 补一条**直指病因**的判据。 + // + // 丢掉行首那个 `|` 的行 (`3 | **…` 而不是 `| 3 | **…`) **不会被 `entry_rows` 解析** + // ⇒ 它对上面三条判据**全部隐形**。实测: 那样改一行之后, "列数"那条一声不响。 + // 它**间接**被 id 连续性兜住 (少一条 ⇒ 号段出现空档), 但那条报错指向的是**症状** + // ("id 3 出现 0 次"), 而真正的病因是"某一行开头少了个竖线" —— 差着一层归因。 + // + // 判据: 表区里不该有"**数字开头、后面跟着竖线**"的行 —— 正常的条目行以 `|` 开头, + // 正常的散文以文字/`-`/`*`/`>` 开头。 + const auto& r = registry(); + CHECK(!r.lines.empty()); + if (r.lines.empty()) return; + static const std::regex kBrokenRow(R"(^\d+\s*\|)"); + std::vector bad; + for (size_t i = 0; i < r.lines.size(); ++i) { + const std::string s = trim(r.lines[i]); + if (std::regex_search(s, kBrokenRow)) { + bad.push_back("第 " + std::to_string(i + 1) + " 行: " + s); + } + } + if (!bad.empty()) { + std::string msg = + "登记册里有**丢了行首 `|` 的表格行** —— 那一行不会被解析, 对全部判据隐形:\n"; + for (const auto& b : bad) msg += " " + b + "\n"; + msg += " ⚠ 补上开头那个 `|` 即可。这类坏行的危险在于它**读起来还像一条登记**,\n" + " 而机器完全看不见它。"; + FAIL(msg); + } +} + +TEST(register_ids_are_exactly_one_to_n) { + const auto& r = registry(); + CHECK(!r.rows.empty()); + if (r.rows.empty()) return; + + std::map seen; // id ⇒ 出现次数 + for (const auto& e : r.rows) seen[e.id] += 1; + + std::vector bad; + // ① 每个 id 必须**恰好一次**:0 次 = 悬空(删条目没重编号),>1 次 = 重号 + // (会让某条**静默消失**在肉眼扫表里)。 + for (int n = 1; n <= int(r.rows.size()); ++n) { + const auto it = seen.find(n); + const int hits = (it == seen.end()) ? 0 : it->second; + if (hits != 1) { + bad.push_back("id " + std::to_string(n) + " 出现 " + std::to_string(hits) + + " 次(必须恰好一次)"); + } + } + // ② 不许有超出 1..N 的 id(N = 条目总数) + for (const auto& kv : seen) { + if (kv.first < 1 || kv.first > int(r.rows.size())) { + bad.push_back("id " + std::to_string(kv.first) + " 越界(应在 1.." + + std::to_string(r.rows.size()) + " 之内)"); + } + } + if (!bad.empty()) { + std::string msg = + "登记册 id 不是恰好 1..N:\n"; + for (const auto& b : bad) msg += " " + b + "\n"; + msg += " ⚠ 删条目要**重编号** —— 那是响的改动; 留空号看起来更省事,\n" + " 但它会让「一共有多少条」这个数变得不可信。"; + FAIL(msg); + } +} diff --git a/tests/test_rot.cpp b/tests/test_rot.cpp new file mode 100644 index 0000000..90c8c4a --- /dev/null +++ b/tests/test_rot.cpp @@ -0,0 +1,241 @@ +// 旋转/位姿工具 —— rpy<->矩阵、SO(3) 指数/对数、slerp、位姿误差、形态归一化。 +#include "test_support.hpp" + +using namespace litearm; +using namespace litearm::rot; + +namespace { +constexpr double kPi = 3.14159265358979323846; + +Mat3 m(std::initializer_list v) { + Mat3 out{}; + int i = 0; + for (double x : v) out[size_t(i / 3)][size_t(i % 3)] = x, ++i; + return out; +} +} // namespace + +TEST(rot_eye_and_basic_ops) { + const Mat3 I = eye3(); + CHECK_NEAR(I[0][0], 1.0, 1e-15); + CHECK_NEAR(I[0][1], 0.0, 1e-15); + const Mat3 A = m({1, 2, 3, 4, 5, 6, 7, 8, 9}); + const Mat3 At = mat_T(A); + CHECK_NEAR(At[0][1], 4.0, 1e-15); + CHECK_NEAR(At[2][0], 3.0, 1e-15); + const Vec3 v{1.0, 1.0, 1.0}; + const auto Av = mat_vec(A, v); + CHECK_NEAR(Av[0], 6.0, 1e-12); + CHECK_NEAR(Av[2], 24.0, 1e-12); + CHECK_NEAR(dot({1, 2, 3}, {4, 5, 6}), 32.0, 1e-12); + CHECK_NEAR(norm({3, 4, 0}), 5.0, 1e-12); +} + +TEST(rot_cross_product_handedness) { + const Vec3 x = cross({1, 0, 0}, {0, 1, 0}); + CHECK_NEAR(x[0], 0.0, 1e-15); + CHECK_NEAR(x[1], 0.0, 1e-15); + CHECK_NEAR(x[2], 1.0, 1e-15); +} + +TEST(rot_unit_rejects_zero_vector) +{ + CHECK_THROWS_AS(unit({0.0, 0.0, 0.0}), InvalidCommandError); + const auto u = unit({0.0, 3.0, 4.0}); + CHECK_NEAR(u[1], 0.6, 1e-12); +} + +TEST(rot_rpy_to_mat_is_rz_ry_rx) { + // R = Rz(yaw) * Ry(pitch) * Rx(roll), 与固件 kin_rpy_to_rot 同式。 + const double r = 0.3, p = -0.4, y = 0.5; + const double cr = std::cos(r), sr = std::sin(r); + const double cp = std::cos(p), sp = std::sin(p); + const double cy = std::cos(y), sy = std::sin(y); + const Mat3 expect = {{{cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr}, + {sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr}, + {-sp, cp * sr, cp * cr}}}; + const Mat3 got = rpy_to_mat({r, p, y}); + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) CHECK_NEAR(got[i][j], expect[i][j], 1e-15); + } +} + +TEST(rot_rpy_mat_roundtrip) { + const std::vector cases = { + {0.0, 0.0, 0.0}, {0.3, -0.4, 0.5}, {-1.0, 0.2, 2.0}, + {kPi / 2 - 0.2, 0.1, -1.2}, {0.0, 0.9, 0.0}, + }; + for (const auto& rpy : cases) { + const Mat3 R = rpy_to_mat(rpy); + CHECK(is_rotation(R)); + const Vec3 back = mat_to_rpy(R); + // rpy 分量可以不同 (周期性), 但**同一个旋转**必须复现。 + const Mat3 R2 = rpy_to_mat(back); + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) CHECK_NEAR(R2[i][j], R[i][j], 1e-9); + } + } +} + +TEST(rot_mat_to_rpy_forces_yaw_zero_at_gimbal_lock) { + // |pitch| ~= pi/2 (万向锁) 时强制 yaw=0 —— 与固件 kin_rot_to_rpy 的锁分支同约定。 + const Mat3 R = rpy_to_mat({0.7, kPi / 2, 0.9}); + const Vec3 rpy = mat_to_rpy(R); + CHECK_NEAR(rpy[2], 0.0, 1e-12); + CHECK_NEAR(std::fabs(rpy[1]), kPi / 2, 1e-5); +} + +TEST(rot_log_exp_roundtrip) { + const std::vector ws = { + {1e-13, 0.0, 0.0}, {0.5, 0.0, 0.0}, {0.1, -0.2, 0.3}, {2.0, 1.0, -0.5}, + }; + for (const auto& w : ws) { + const Mat3 R = rot_exp(w); + CHECK(is_rotation(R)); + const Vec3 back = rot_log(R); + CHECK_NEAR(norm(sub(back, w)), 0.0, 1e-9); + } +} + +TEST(rot_log_of_identity_is_zero) { + const Vec3 w = rot_log(eye3()); + CHECK_NEAR(norm(w), 0.0, 1e-15); +} + +TEST(rot_log_clamps_acos_argument) { + // 数值噪声会让 trace 越界到 +-(1+eps) 然后抛 math domain error —— 必须夹住。 + // 这里给一个"几乎但不完全正交"的矩阵: 归一化后 trace 会略超 3。 + Mat3 R = eye3(); + R[0][0] = 1.0 + 1e-12; + R[1][1] = 1.0 + 1e-12; + R[2][2] = 1.0 + 1e-12; + CHECK_NOTHROW(rot_log(R)); +} + +TEST(rot_slerp_hits_both_ends_and_the_midpoint) { + const Mat3 R0 = rpy_to_mat({0.0, 0.0, 0.0}); + const Mat3 R1 = rpy_to_mat({0.0, 0.0, 1.0}); + const Mat3 a = rot_slerp(R0, R1, 0.0); + const Mat3 b = rot_slerp(R0, R1, 1.0); + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + CHECK_NEAR(a[i][j], R0[i][j], 1e-12); + CHECK_NEAR(b[i][j], R1[i][j], 1e-12); + } + } + const Mat3 mid = rot_slerp(R0, R1, 0.5); + // 半程的转角应当是全程的一半 + CHECK_NEAR(rot_angle(mid), rot_angle(mat_mul(mat_T(R0), R1)) / 2.0, 1e-9); +} + +TEST(rot_slerp_of_identical_rotations_is_that_rotation) { + const Mat3 R = rpy_to_mat({0.2, 0.3, 0.4}); + const Mat3 got = rot_slerp(R, R, 0.37); + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) CHECK_NEAR(got[i][j], R[i][j], 1e-12); + } +} + +TEST(rot_pose_err_orientation_difference_has_the_right_direction) { + // ⚠ 姿态差必须是 rot_log(R_d * R^T), 不能写成 rot_log(R * R_d^T) —— 后者是复合不是差, + // 符号相反; 曾因此把 0.59mm 位置误差的位姿报成 153 度姿态误差 (恰好是该位姿转角的 2 倍)。 + const Mat3 R = rpy_to_mat({0.0, 0.0, 0.4}); + const Mat3 R_d = rpy_to_mat({0.0, 0.0, 0.6}); + const auto e = pose_err({0.0, 0.0, 0.0}, R, {0.00059, 0.0, 0.0}, R_d); + CHECK_NEAR(e.first, 0.00059, 1e-9); + // 真值是 0.2 rad 的差, 不是 2 倍的复合角 + CHECK_NEAR(e.second, 0.2, 1e-9); +} + +TEST(rot_is_rotation_rejects_non_so3) { + CHECK(is_rotation(eye3())); + Mat3 scaled = eye3(); + scaled[0][0] = 2.0; + CHECK(!is_rotation(scaled)); + // det = -1 的反射不是 SO(3) + Mat3 refl = eye3(); + refl[2][2] = -1.0; + CHECK(!is_rotation(refl)); + Mat3 zero{}; + CHECK(!is_rotation(zero)); +} + +TEST(rot_orient_angle_is_zero_for_equivalent_rpy) { + // 同一个旋转的两组不同 rpy (绕 z 加 2*pi) 之间的夹角是 0。 + CHECK_NEAR(orient_angle({0.0, 0.0, 0.3}, {0.0, 0.0, 0.3 + 2 * kPi}), 0.0, 1e-9); + CHECK_NEAR(orient_angle({0.1, 0.2, 0.3}, {0.1, 0.2, 0.3}), 0.0, 1e-12); + CHECK(orient_angle({0.0, 0.0, 0.0}, {0.0, 0.0, 1.0}) > 0.9); +} + +// ---------------------------------------------------------------- PoseInput + +TEST(rot_as_pose_accepts_a_six_vector) { + const std::array v{0.3, 0.0, 0.4, 0.0, 0.0, 0.0}; + const Pose p = as_pose(v); + CHECK_NEAR(p.first[0], 0.3, 1e-12); + CHECK_NEAR(p.first[2], 0.4, 1e-12); + CHECK_NEAR(p.second[0][0], 1.0, 1e-12); +} + +TEST(rot_as_pose_accepts_pos_plus_rotation_matrix) { + const Vec3 pos{1.0, 2.0, 3.0}; + const Mat3 R = rpy_to_mat({0.1, 0.2, 0.3}); + const Pose p = as_pose(pos, R); + CHECK_NEAR(p.first[1], 2.0, 1e-12); + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) CHECK_NEAR(p.second[i][j], R[i][j], 1e-12); + } +} + +TEST(rot_as_pose_accepts_a_homogeneous_4x4) { + const Mat3 R = rpy_to_mat({0.0, 0.0, 0.5}); + Mat4 M{}; + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) M[size_t(i)][size_t(j)] = R[i][j]; + } + M[0][3] = 0.11; + M[1][3] = 0.22; + M[2][3] = 0.33; + M[3][3] = 1.0; + const Pose p = as_pose(M); + CHECK_NEAR(p.first[0], 0.11, 1e-12); + CHECK_NEAR(p.first[2], 0.33, 1e-12); + CHECK_NEAR(p.second[0][1], R[0][1], 1e-12); +} + +TEST(rot_as_pose_rejects_invalid_shapes_with_a_readable_message) { + // 形态不合法必须抛 InvalidCommandError, 且文案里要带**实际收到的形状** —— + // 只说"pose 非法"会让调用方在 6 向量和位姿对之间反复猜。 + try { + as_pose(std::vector{1.0, 2.0, 3.0}); // 3 个分量, 不是 6 + FAIL("应当抛异常"); + } catch (const InvalidCommandError& e) { + CHECK(std::string(e.what()).find("3") != std::string::npos); + } + // 旋转矩阵不合法 (不是 SO(3)) + Mat3 bad{}; + bad[0][0] = 5.0; + CHECK_THROWS_AS(as_pose(PoseInput::from_pos_rot({0, 0, 0}, bad)), InvalidCommandError); + // 4x4 的旋转块不合法 + Mat4 zeroM{}; + CHECK_THROWS_AS(PoseInput::from_homogeneous(zeroM), InvalidCommandError); +} + +TEST(rot_as_pose6_normalizes_all_three_forms_to_the_same_six_vector) { + const Vec3 pos{0.3, 0.0, 0.4}; + const Vec3 rpy{0.1, 0.2, 0.3}; + const auto a = as_pose6(pos, rpy); + const auto b = as_pose6(pos, rpy_to_mat(rpy)); + for (int i = 0; i < 6; ++i) CHECK_NEAR(a[size_t(i)], b[size_t(i)], 1e-9); +} + +TEST(rot_rpy_to_rowmajor_matches_the_matrix) { + const Vec3 rpy{0.1, 0.2, 0.3}; + const auto flat = rpy_to_rowmajor(rpy); + const Mat3 R = rpy_to_mat(rpy); + for (int i = 0; i < 3; ++i) { + for (int j = 0; j < 3; ++j) { + CHECK_NEAR(flat[size_t(i * 3 + j)], R[size_t(i)][size_t(j)], 1e-15); + } + } +} diff --git a/tests/test_state.cpp b/tests/test_state.cpp new file mode 100644 index 0000000..97d1357 --- /dev/null +++ b/tests/test_state.cpp @@ -0,0 +1,117 @@ +// RobotState —— 状态帧的面向对象视图 (enabled / cart_busy / faulted / 断轴 / 推断值)。 +#include "test_support.hpp" + +using namespace litearm; + +namespace { +RobotState st_with(uint16_t flags, int mode, uint16_t joint_fault, int n = 7) { + std::vector q(size_t(n), 0.1); + const auto payload = lt::make_status_payload(flags, 3, q, joint_fault); + RobotState st = decode_state(payload); + st.mode = mode; + st.mode_name = proto::mode_name(mode); + return st; +} +} // namespace + +TEST(state_enabled_bit_is_bit_9) { + CHECK(st_with(uint16_t(1u << 9), 1, 0).enabled()); + CHECK(!st_with(0x0000, 1, 0).enabled()); + // 低位不是 enabled + CHECK(!st_with(0x01, 1, 0).enabled()); +} + +TEST(state_cart_busy_bit_is_bit_10_and_is_not_a_safety_flag) { + const RobotState st = st_with(uint16_t(1u << 10), 1, 0); + CHECK(st.cart_busy()); + // bit6..10 一起变成"故障位"是本属性要防的方向: flag_names 只看 bit0..bit5。 + CHECK(st.flag_names.empty()); + CHECK(!st.faulted()); +} + +TEST(state_faulted_covers_fault_bit_emergency_and_joint_fault) { + CHECK(st_with(0x0001, 1, 0).faulted()); // FAULT 位 + CHECK(st_with(0x0000, 6, 0).faulted()); // EMERGENCY + CHECK(st_with(0x0000, 1, 0x0002).faulted()); // 单轴断轴 (G7) + CHECK(!st_with(0x0000, 1, 0).faulted()); + // 关键: 单轴锁存不一定置全局 FAULT 位, 若不看 joint_fault 就会等到超时才报"未到位"。 + const RobotState single = st_with(0x0000, 1, 0x0004); + CHECK(!(single.flags & 1)); + CHECK(single.faulted()); +} + +TEST(state_flag_names_list_the_set_safety_bits) { + const RobotState st = st_with(uint16_t(0x01 | 0x04), 1, 0); + CHECK_EQ(int(st.flag_names.size()), 2); + CHECK_EQ(st.flag_names[0], std::string("FAULT")); + CHECK_EQ(st.flag_names[1], std::string("FB_STALE")); +} + +TEST(state_fault_axes_are_zero_based) { + const RobotState st = st_with(0, 1, uint16_t((1u << 1) | (1u << 3))); + const auto ax = st.fault_axes(); + CHECK_EQ(int(ax.size()), 2); + CHECK_EQ(ax[0], 1); // J2 + CHECK_EQ(ax[1], 3); // J4 +} + +TEST(state_fault_axes_cover_all_16_bits) { + const RobotState st = st_with(0, 1, 0xFFFF); + CHECK_EQ(int(st.fault_axes().size()), proto::MAX_JOINTS); +} + +TEST(state_fault_detail_is_readable) { + CHECK_EQ(st_with(0, 1, 0).fault_detail(), std::string("无故障位")); + const RobotState st = st_with(0x01, 1, uint16_t(1u << 2)); + const std::string d = st.fault_detail(); + CHECK(d.find("flags=FAULT") != std::string::npos); + CHECK(d.find("断轴=J3") != std::string::npos); +} + +TEST(state_drop_hold_inferred_is_exactly_joint_fault_nonzero) { + // 名字带 _inferred 是刻意的: 固件不上报这个量。判据是那条单向蕴含的逆否 + // (joint_fault == 0 蕴含 drop_hold == false), 反向不成立。 + CHECK(!st_with(0, 1, 0).drop_hold_inferred()); + CHECK(st_with(0, 1, 0x0001).drop_hold_inferred()); + // 等价写法就是 bool(joint_fault) + const RobotState st = st_with(0, 1, 0x0008); + CHECK(st.drop_hold_inferred() == (st.joint_fault != 0)); +} + +TEST(state_q_dq_tau_projections) { + // 状态帧的关节数只有 1 与 7 两种合法布局 (见 decode_status)。 + std::vector q{1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0}; + std::vector body; + const auto h = proto::pack_u16le(0); + body.insert(body.end(), h.begin(), h.end()); + const auto s = proto::pack_u16le(1); + body.insert(body.end(), s.begin(), s.end()); + for (double v : q) { + const auto f = proto::pack_f32s(std::vector{v, v * 2, v * 3, 0, 0}); + body.insert(body.end(), f.begin(), f.end()); + body.push_back(0); + } + const auto jf = proto::pack_u16le(0); + body.insert(body.end(), jf.begin(), jf.end()); + const RobotState st = decode_state(body); + CHECK_EQ(int(st.n()), 7); + CHECK_EQ(int(st.q().size()), 7); + CHECK_NEAR(st.q()[1], 2.0, 1e-6); + CHECK_NEAR(st.dq()[2], 6.0, 1e-6); + CHECK_NEAR(st.tau()[0], 3.0, 1e-6); +} + +TEST(state_decode_state_rejects_illegal_frame) { + CHECK_THROWS_AS(decode_state(std::vector{1, 2, 3}), TransportError); + CHECK_THROWS_AS(decode_state(std::vector(4 + 21 * 3, 0)), TransportError); +} + +TEST(state_decode_state_maps_mode_name) { + const std::vector q(7, 0.0); + for (int mode = 0; mode < 8; ++mode) { + const auto payload = lt::make_status_payload(uint16_t(mode << 6), 0, q); + const RobotState st = decode_state(payload); + CHECK_EQ(st.mode, mode); + CHECK_EQ(st.mode_name, std::string(proto::mode_name(mode))); + } +} diff --git a/tests/test_support.hpp b/tests/test_support.hpp new file mode 100644 index 0000000..7686a4d --- /dev/null +++ b/tests/test_support.hpp @@ -0,0 +1,281 @@ +// 测试夹具 —— 离线 Arm (注入 FakeTransport) 与若干共用断言。 +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "litearm/litearm.hpp" +#include "litearm/testing.hpp" +#include "test_framework.hpp" + +namespace litearm { +/// 测试专用访问口 —— `Arm` 里 `friend struct TestAccess;` 一行换来的。 +/// +/// ⚠ 只有**一个**方法, 而且只给出 `Ack&`。别往这里加 `raw_write` / `raw_write` 之类: +/// 那会把"唯一写口/唯一读口"这两条安全设计在测试里绕过去, 判据就失去判别力了。 +struct TestAccess { + /// 会话内部的应答队列 + 读线程状态 (上游 Python 直接摸 `arm._a`)。 + static Ack& ack(Arm& arm) { return arm.require(); } + /// 该队列当前的长度 (没有该队列时为 0)。 + static size_t queue_len(Arm& arm, QueueKey key) { + Ack& a = arm.require(); + std::lock_guard lk(a.mu); + const auto it = a.queues.find(key); + return it == a.queues.end() ? 0 : it->second.size(); + } + /// 直接下发一帧 (上游用例直接调 `arm._raw_write`; C++ 有访问控制, 走这里)。 + /// + /// ⚠ 只给"**测顺序/测守卫**"这类用例用 —— 它绕开了 `write_cmd` 的零重力守卫, + /// 也绕开了 `_cmd` 的 ACK 等待。**不要**拿它去替代正常入口 (那会让用例测的不是 + /// 用户真正走的那条路)。 + static void raw_write(Arm& arm, uint8_t cmd, const std::vector& payload) { + arm.raw_write(cmd, payload.data(), payload.size()); + } + + /// 往"在途笛卡尔配对队列"里登记一条 token (上游: `arm._cart.register()`)。 + /// 返回的票据可以用来查它有没有被标成"结局未知" (`->lost`)。 + static CartTokenPtr cart_register(Arm& arm) { return arm.cart_->register_token(); } + /// 在途条数 (上游: `arm._cart.pending`)。 + static int cart_pending(Arm& arm) { return arm.cart_->pending(); } + + /// 到位判据的纯谓词 (`Arm::pose_near`) —— 无副作用, 故开给用例。 + /// 上游 Python 直接调 `Arm._pose_near`; C++ 有访问控制, 走这里。 + static bool pose_near(Arm& arm, const std::array& tcp, + const std::array& goal, double pos_tol, + double rpy_tol) { + return arm.pose_near(tcp, goal, pos_tol, rpy_tol); + } + + /// 等某条队列被读线程填上 (用例要控制"帧已经躺在队列里"这个前提)。 + static bool await_queued(Arm& arm, QueueKey key, double timeout = 1.0) { + const double end = now_s() + timeout; + while (now_s() < end) { + if (queue_len(arm, key) > 0) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + return queue_len(arm, key) > 0; + } +}; + +} // namespace litearm + +namespace lt { + +/// 仓库根目录 —— 从 `__FILE__` 推 (它在**任何人的机器**上都成立)。 +/// +/// ⚠ 别用"相对当前目录"的路径 (`"include/litearm/arm.hpp"` 这种): 手跑二进制时 cwd 是 +/// 仓根, 而 ctest 的 cwd 是**构建目录** —— 同一个路径两种结果。实测踩过。 +std::string repo_root(); + +/// 读一个源文件 (相对仓库根)。读不到返回空串。 +std::string read_repo_file(const std::string& rel_path); + +/// 列出 `repo_root()/` 下文件名以 `prefix` 开头、以 `suffix` 结尾的文件 +/// (返回相对仓库根的路径, 按名排序)。 +/// +/// ⚠ 用它是为了让"扫全部测试源"这类判据**跟着目录走**, 而不是维护一张手写清单 —— +/// 手写清单漏掉一个文件时, 那条判据就对那个文件**静默失效**, 而失效的方向恰好是 +/// **假绿**(新加的文件不在清单里 ⇒ 它里面缺什么都不会被报出来)。 +std::vector list_repo_files(const std::string& dir, const std::string& prefix, + const std::string& suffix = ""); + +/// 剥掉 `/* */` 与 `//` 注释 —— **一切源码扫描类判据的必备前提**。 +/// +/// ⚠ 不剥的话注释里的词会被当成代码: 实测踩过 (一条哨兵把 `sleep` / `ff_clamp` / +/// `activated` 这些**注释里的词**当成了公开成员名, 于是那条判据变成噪声源)。 +inline std::string strip_cxx_comments(const std::string& raw) { + std::string src; + src.reserve(raw.size()); + for (size_t i = 0; i < raw.size();) { + if (raw[i] == '/' && i + 1 < raw.size() && raw[i + 1] == '*') { + const size_t e = raw.find("*/", i + 2); + i = (e == std::string::npos) ? raw.size() : e + 2; + src.push_back(' '); + } else if (raw[i] == '/' && i + 1 < raw.size() && raw[i + 1] == '/') { + const size_t e = raw.find('\n', i); + i = (e == std::string::npos) ? raw.size() : e; + src.push_back(' '); + } else { + src.push_back(raw[i++]); + } + } + return src; +} + + +using namespace litearm; + +/// 把共享的 FakeTransport 包成一个 Transport —— 这样 Arm 析构时只销毁这层薄壳, +/// 测试持有的 fake 实例仍可用 (跨 reconnect 也看得见同一份状态)。 +class SharedFake : public Transport { +public: + explicit SharedFake(std::shared_ptr t) : t_(std::move(t)) {} + void write_frame(uint8_t cmd, const uint8_t* payload, size_t len) override { + t_->write_frame(cmd, payload, len); + } + std::optional read_frame(double timeout) override { + return t_->read_frame(timeout); + } + void close() override { t_->close(); } + bool is_open() const override { return t_->is_open(); } + // ⚠⚠ 这两条**必须转发给假件**, 不能返回空/默认值 —— 它们曾是两个**哑掉的口**: + // * `text_log()` 原来恒返回 `{}` ⇒ `last_reset_reason()` / `banner_version()` + // 经由 `lt::Offline` **从来就测不到**(读了永远说"没有横幅")。实测发现。 + // * `flush_failures()` 原来落到基类**自己**那个恒 0 的计数器上 + // (它当时不是虚函数) ⇒ 假件注入的值永远传不上来。已把基类那条改成虚函数。 + // ⇒ 教训与本仓别处同款: **包装层漏转发口 = 假绿**, 而假绿比没测更糟。 + std::string text_log() const override { return t_->text_log(); } + uint64_t flush_failures() const override { return t_->flush_failures(); } + std::string port_name() const override { return t_->port; } + +private: + std::shared_ptr t_; +}; + +/// 一个连着桩固件的离线 Arm 会话。析构时自动 close() —— 读线程的生命周期与其绑死。 +struct Offline { + std::shared_ptr fake; + std::unique_ptr arm; + + explicit Offline(const std::string& fw = "Litearm1.7.0-7J", int n = 7, + std::shared_ptr clock = nullptr) { + fake = std::make_shared("fake", 0.2, fw, n); + auto shared = fake; + ArmOptions opts; + opts.port = "fake"; + // ⚠ 注入假钟之前**先读 `clock.hpp` 开头**: 假钟不会自己前进, 凡"等超时真的 + // 发生"的判据在它下面会**挂死**。它只给"纯比较"类判据用。 + opts.clock = std::move(clock); + opts.transport_factory = [shared](const std::string&) -> std::unique_ptr { + return std::unique_ptr(new SharedFake(shared)); + }; + // 离线用例不该在超时上耗时间: 把窗口压到用例能接受的量级。 + opts.move_timeout = 1.0; + arm = std::make_unique(opts); + arm->connect(); + } + + ~Offline() { + if (arm) { + try { + arm->close(); + } catch (...) { + } + } + } + + Offline(const Offline&) = delete; + Offline& operator=(const Offline&) = delete; + + Arm& operator*() { return *arm; } + Arm* operator->() { return arm.get(); } + testing::FakeTransport& t() { return *fake; } +}; + +/// 一个**还没连**的 Arm (共享同一份 FakeTransport 注入) —— 给"装配路径本身"的用例用。 +struct Unconnected { + std::shared_ptr fake; + std::unique_ptr arm; + + explicit Unconnected(const std::string& fw = "Litearm1.7.0-7J", int n = 7, + double move_timeout = 1.0) { + fake = std::make_shared("fake", 0.2, fw, n); + auto shared = fake; + ArmOptions opts; + opts.port = "fake"; + opts.transport_factory = [shared](const std::string&) -> std::unique_ptr { + return std::unique_ptr(new SharedFake(shared)); + }; + opts.move_timeout = move_timeout; + arm = std::make_unique(opts); + } + + ~Unconnected() { + if (arm) { + try { + arm->close(); + } catch (...) { + } + } + } + + Unconnected(const Unconnected&) = delete; + Unconnected& operator=(const Unconnected&) = delete; +}; + + +// ---------------------------------------------------------------- 共用小工具 + +/// 把桩的链路**静下来**: 关掉被动状态流, 再等在途的帧全部落定。 +/// +/// ⚠ 为什么这是个必需品而不是讲究: 桩的被动流 (100Hz) 与 `connect()` 握手时压进 `resp_` +/// 的那几帧, 同用例刚下发的命令是**两条独立的线**, 谁先被读线程投递没有保证。 +/// 凡是判据里带 "`status_seq` 前进" 的入口 (`get_status_now` / `read_status` / `arrive`), +/// 只要在 `seq0` 采样与首次判据之间有帧被投递, 判据就**当场成立** —— 哪怕那一帧跟本命令 +/// 毫无关系。要精确控制"命令之后才该有的那一帧", 就必须先把这些在途帧排干。 +/// +/// ⚠ 两个动作缺一不可: 只关流不停等 ⇒ `connect()` 的残留帧照样会落进窗口 (实测仍有约 +/// 4% 的翻红率); 只停等不关流 ⇒ 1ms 一拍的被动流会在停等之后**继续**落进窗口。 +/// 两个都做 ⇒ 实测 150/150 稳定。 +inline void quiesce(testing::FakeTransport& t, double settle_s = 0.05) { + t.auto_status.store(false); + // ⚠⚠ 判据是"**连续 `settle_s` 没有新帧交付**", **不是**"睡满 `settle_s`"。 + // + // 后者在机器忙的时候给不出任何保证: 读线程被饿住 ⇒ 关流**之前**生成的残留帧会在 + // 睡完之后才被交付 ⇒ 依赖"链路已经静下来"的用例偶发红。 + // 实测踩过: 一批 `--clean` 全量重建之后的运行里, 用 `quiesce` 的那批用例红了 1 次, + // 而之后 ~370 次执行一次都复现不出来 —— 低频 + 与时序相关, 正是这个形状。 + // + // `status_seq` 是**交付**计数 (桩在 `stamp_status` 里递增) ⇒ "它不动"就等于 + // "没有新帧被交付", 与"读线程跑得多快"无关。 + uint16_t last = t.status_seq.load(); + double stable_since = now_s(); + const double hard_end = now_s() + 2.0; // 上界: 读线程真死了也不许把用例挂死 + while (now_s() < hard_end) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + const uint16_t cur = t.status_seq.load(); + if (cur != last) { + last = cur; + stable_since = now_s(); + continue; + } + if (now_s() - stable_since >= settle_s) return; + } +} + +/// 一条"骨架合法"的状态帧载荷 (7 关节) —— 供协议层用例做逐字段断言。 +inline std::vector make_status_payload(uint16_t flags, uint16_t seq, + const std::vector& q, + uint16_t joint_fault = 0) { + std::vector body; + const auto h = proto::pack_u16le(flags); + body.insert(body.end(), h.begin(), h.end()); + const auto s = proto::pack_u16le(seq); + body.insert(body.end(), s.begin(), s.end()); + for (size_t i = 0; i < q.size(); ++i) { + const auto v = proto::pack_f32s(std::vector{q[i], 0.0, 0.0, 0.0, 0.0}); + body.insert(body.end(), v.begin(), v.end()); + body.push_back(0); + } + const auto jf = proto::pack_u16le(joint_fault); + body.insert(body.end(), jf.begin(), jf.end()); + return body; +} + +/// 独立于被测实现的 CRC 参考实现 (纯位循环) —— **判据不能是"拿本函数跟自己对拍"**。 +inline uint16_t crc16_reference(const uint8_t* d, size_t n) { + uint16_t crc = 0xFFFF; + for (size_t i = 0; i < n; ++i) { + crc ^= uint16_t(d[i]) << 8; + for (int b = 0; b < 8; ++b) { + crc = (crc & 0x8000) ? uint16_t((crc << 1) ^ 0x1021) : uint16_t(crc << 1); + } + } + return crc; +} + +} // namespace lt diff --git a/tests/test_threading.cpp b/tests/test_threading.cpp new file mode 100644 index 0000000..33403ba --- /dev/null +++ b/tests/test_threading.cpp @@ -0,0 +1,200 @@ +// 并发与收尾 —— 唯一读者 / 并发读状态 / 读线程生命周期 / 串行锁。 +#include + +#include "test_support.hpp" + +using namespace litearm; + +namespace { +int count_cmd(const testing::FakeTransport& t, uint8_t cmd) { + int n = 0; + for (const auto& kv : t.tx_snapshot()) { + if (kv.first == cmd) ++n; + } + return n; +} +} // namespace + +TEST(threading_concurrent_state_readers_do_not_starve_each_other) { + // 读线程按 100Hz 一直填状态单槽, 驱动型读者只是"等单槽前进" —— 多个并发读者 + // 不再互相饿 (旧实现里"只认自己读到的帧"会被并发读者饿到超时)。 + lt::Offline off; + std::atomic ok{0}; + std::atomic failed{0}; + auto worker = [&]() { + for (int i = 0; i < 40; ++i) { + try { + const auto m = off->get_state(true, 0.5); + if (m.value) ++ok; + } catch (...) { + ++failed; + } + } + }; + std::vector ths; + for (int i = 0; i < 4; ++i) ths.emplace_back(worker); + for (auto& t : ths) t.join(); + CHECK_EQ(failed.load(), 0); + CHECK_EQ(ok.load(), 160); +} + +TEST(threading_concurrent_cartesian_entries_are_serialized) { + // 串行是强制的: 0x3A/0x3B/0x3E 共用码空间, ERR 回显只能按码判, >=2 条在途时会互相 + // 认下对方的 ERR/0x4E => 假成功。持锁把它们变成排队。 + lt::Offline off; + std::atomic ok{0}; + std::atomic failed{0}; + auto worker = [&](int seed) { + for (int i = 0; i < 6; ++i) { + try { + std::array p{0.30 + seed * 0.001, 0.0, + 0.40 + i * 0.001, 0.0, 0.0, 0.0}; + const CartPlan plan = off->move_l(p, 0.5, /*wait=*/false); + if (plan.ok) ++ok; + } catch (...) { + ++failed; + } + } + }; + std::vector ths; + for (int i = 0; i < 3; ++i) ths.emplace_back(worker, i); + for (auto& t : ths) t.join(); + // 每一条都该拿到自己的规划结果 —— 没有错配, 也没有互相吃掉应答 + CHECK_EQ(failed.load(), 0); + CHECK_EQ(ok.load(), 18); + CHECK_EQ(off->poll_cart().has_value(), false); +} + +TEST(threading_mixed_readers_and_motion) { + lt::Offline off; + std::atomic stop{false}; + std::atomic read_errors{0}; + std::thread reader([&]() { + while (!stop.load()) { + try { + off->get_state(true, 0.3); + off->get_tcp(0.3); + } catch (const NotConnectedError&) { + break; + } catch (...) { + ++read_errors; + } + } + }); + for (int i = 0; i < 5; ++i) { + CHECK_NOTHROW(off->movej(std::vector(7, 0.01 * i))); + } + stop.store(true); + reader.join(); + CHECK_EQ(read_errors.load(), 0); +} + +TEST(threading_close_while_another_thread_is_reading) { + // close() 必须能把阻塞在等待里的读者唤醒 —— 否则 teardown 会挂死。 + lt::Offline off; + std::atomic released{false}; + std::thread reader([&]() { + try { + off->get_state(true, 3.0); + } catch (...) { + } + released.store(true); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + const double t0 = now_s(); + off->close(); + CHECK(now_s() - t0 < 2.0); // close 不该被那次等待拖住 + reader.join(); + CHECK(released.load()); +} + +TEST(threading_close_stops_the_reader_thread) { + lt::Offline off; + off->get_state(); // 确认会话在跑 + off->close(); + // 关掉之后调用必须响亮, 而不是从一个已经停掉的读线程上拿到陈旧值 + CHECK_THROWS_AS(off->get_state(), NotConnectedError); + // 重复 close 不抛 + CHECK_NOTHROW(off->close()); +} + +TEST(threading_reconnect_after_close_is_clean) { + lt::Offline off; + off->close(); + CHECK_NOTHROW(off->reconnect()); + CHECK_EQ(off->n(), 7); + CHECK_NOTHROW(off->get_state()); +} + +TEST(threading_msg_hz_converges_for_the_passive_status_stream) { + // 被动连续流 (状态帧, 固件 100Hz): 两帧之后 hz 收敛到接近固件周期。 + lt::Offline off; + std::this_thread::sleep_for(std::chrono::milliseconds(120)); + const auto m = off->get_state(); + CHECK(m.value.has_value()); + // 桩的 auto_status 是 1ms 一拍, 故 hz 应当明显大于 0 + CHECK(m.hz > 10.0); + CHECK(m.timestamp > 0.0); +} + +TEST(threading_msg_hz_is_zero_when_a_frame_type_never_arrived) { + lt::Offline off; + // 只读一次单发请求/应答式的帧 => count < 2 => hz 写死 0.0 (一个样本定不出频率) + const auto m = off->get_ff_scalar(1, 0); + CHECK_NEAR(m.hz, 0.0, 1e-12); + CHECK(m.timestamp > 0.0); +} + +// --------------------------------------------------------------------------- +// 同帧节流: **已整套删除** (2026-09-28) +// --------------------------------------------------------------------------- +// +// 这里原有 7 条用例 (默认关闭 / 丢不掉等 ACK 的命令 / 对保活生效 / 拒绝非法间隔 / +// 终态守卫不被短路 / 丢帧不清队)。它们测的那套机制 —— `set_tx_repeat_min_interval` + +// `raw_write` 的 `droppable` 形参 + `tx_allowed` —— **已经不存在了**, 用例随功能一起下线。 +// ⚠ 这不是"删测试凑绿", 是**功能下线**: 依据见 README「与原版的差异」—— +// 参照实现里那套 `_tx_allowed` 是**私有且默认全关**的(行为上等价于无), 故不做。 +// +// ⚠ 那 7 条里有**两条**护的其实不是节流本身, 而是别的不变量 —— 它们被改写保留: +// ① 终态守卫必须**响亮** ⇒ 见下面 `threading_the_terminal_state_guard_stays_loud`; +// ② "清队钩子的理据是**固件收到**命令才会作废在途规划 ⇒ 只有真发出去的帧才清队" ⇒ +// 这条现在**结构性地成立**(唯一写口进了就一定会写), 不再需要用例看着它。 +// 原来那条 `a_dropped_frame_does_not_clear_the_cart_queue` 因此下线。 + +TEST(threading_the_terminal_state_guard_stays_loud) { + // ⚠ 终态 (`reject_if_in_dfu`) 下**任何**写都必须抛 `ArmIsInDfuError`, 绝不许有任何 + // "静默、什么都发生"的路径 —— 否则调用方看不出自己已经在一个废掉的会话上。 + // + // 本用例的理由与节流无关 (删节流前后都该成立): 当年它是为了钉"守卫要排在节流**之前**"。 + // 现在守卫是唯一排在前面的人, 这条更要钉住。 + lt::Offline off; + TestAccess::raw_write(*off.arm, proto::CMD_ZERO_G, {0x01}); // 先真发一条 + off->enter_dfu(); // 终端态 + CHECK_THROWS_AS(TestAccess::raw_write(*off.arm, proto::CMD_ZERO_G, {0x01}), + ArmIsInDfuError); +} + +TEST(threading_every_raw_write_reaches_the_wire_there_is_no_drop_path) { + // ⚠ 删掉节流之后唯一写口的**新**不变式: 进了 `raw_write` 就一定会写出去。 + // 载荷逐字节相同的连发也照样全发 —— 从前那种"重复帧被静默丢掉"的路径**不存在**。 + // (伺服类命令 `move_js` 靠 >=10Hz 重发维持固件 0.1s 命令看门狗 ⇒ 丢它的帧 + // 就是在给它断流, 这是本条要钉的现场。) + lt::Offline off; + const std::vector q(7, 0.1); + for (int i = 0; i < 5; ++i) CHECK_NOTHROW(off->move_js(q)); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_JS), 5); // 5 发 5 到 + + const std::vector z(7, 0.0); + for (int i = 0; i < 3; ++i) CHECK_NOTHROW(off->movej(z)); + CHECK_EQ(count_cmd(off.t(), proto::CMD_MOVE_J), 3); +} + +TEST(threading_offline_transport_registration_is_not_leaked_by_the_fixture) { + // FakeTransport 不登记端口 (它是桩), 故进程内登记表在这些用例里应当保持干净。 + { + lt::Offline off; + (void)off; + } + // 真传输那半由 test_transport.cpp 的独占用例覆盖。 + CHECK(true); +} diff --git a/tests/test_transport.cpp b/tests/test_transport.cpp index 9dbc95e..90222a5 100644 --- a/tests/test_transport.cpp +++ b/tests/test_transport.cpp @@ -1,184 +1,322 @@ -#include -#include "litearm/transport.hpp" -#include "litearm/codec.hpp" - +// 串口传输 —— 用**真 pty** (不是桩) 验帧读写/噪声跳过/残缺帧/非阻塞语义/端点独占。 +// +// 用真 pty 而不是 FakeTransport: 桩会把 timeout 整个忽略掉, 于是"read_frame(0) 其实是 +// 一个字节都不碰"这类缺陷在桩上全绿 (上游就是这么漏掉过一次真机故障的)。 +#include +#include +#include + +#include #include -namespace litearm { +#include "test_support.hpp" + +using namespace litearm; + namespace { -// ── Pub/Sub ───────────────────────────────────────────────────────────────── +/// 一对 pty: 测试持有 master, SerialTransport 打开 slave。 +struct Pty { + int master = -1; + std::string slave; + + Pty() { + master = ::posix_openpt(O_RDWR | O_NOCTTY); + if (master < 0) return; + if (::grantpt(master) != 0 || ::unlockpt(master) != 0) { + ::close(master); + master = -1; + return; + } + const char* nm = ::ptsname(master); + if (nm != nullptr) slave = nm; + // master 侧非阻塞: 测试自己用 poll 控节奏 + const int fl = ::fcntl(master, F_GETFL); + ::fcntl(master, F_SETFL, fl | O_NONBLOCK); + } + ~Pty() { + if (master >= 0) ::close(master); + } + bool ok() const { return master >= 0 && !slave.empty(); } + + void send(const std::vector& bytes) const { + size_t off = 0; + while (off < bytes.size()) { + const ssize_t n = ::write(master, bytes.data() + off, bytes.size() - off); + if (n > 0) { + off += size_t(n); + continue; + } + pollfd p{}; + p.fd = master; + p.events = POLLOUT; + ::poll(&p, 1, 100); + } + } -TEST(InProcPubSub, BasicPubSub) { - InProcTransport tp; - auto sub = tp.sub("topic/a"); - tp.pub("topic/a", "hello"); - auto msg = sub->try_recv(); - ASSERT_TRUE(msg.has_value()); - EXPECT_EQ(*msg, "hello"); - // Queue should be empty now - EXPECT_FALSE(sub->try_recv().has_value()); -} + std::vector drain(int timeout_ms = 200) const { + std::vector out; + const double end = now_s() + double(timeout_ms) / 1000.0; + while (now_s() < end) { + uint8_t buf[512]; + const ssize_t n = ::read(master, buf, sizeof(buf)); + if (n > 0) { + out.insert(out.end(), buf, buf + n); + continue; + } + pollfd p{}; + p.fd = master; + p.events = POLLIN; + if (::poll(&p, 1, 20) <= 0) break; + } + return out; + } +}; -TEST(InProcPubSub, MultipleSubscribers) { - InProcTransport tp; - auto sub1 = tp.sub("topic/a"); - auto sub2 = tp.sub("topic/a"); +/// 每个用例用**独享的端口名** —— 登记表按端口字符串比, 共享会互相挡。 +struct TransportFixture { + Pty pty; + std::unique_ptr tr; - tp.pub("topic/a", "msg1"); + explicit TransportFixture(const std::string& tag) { + (void)tag; + if (!pty.ok()) return; + tr.reset(new SerialTransport(pty.slave, 0.05)); + } + ~TransportFixture() { + if (tr) tr->close(); + } + bool ok() const { return pty.ok() && tr != nullptr; } +}; - EXPECT_EQ(*sub1->try_recv(), "msg1"); - EXPECT_EQ(*sub2->try_recv(), "msg1"); +/// 没有真 pty 可用时跳过 (受限容器/沙箱)。 +bool have_pty() { + Pty p; + return p.ok(); } -TEST(InProcPubSub, TopicIsolation) { - InProcTransport tp; - auto sub_a = tp.sub("topic/a"); - auto sub_b = tp.sub("topic/b"); +} // namespace - tp.pub("topic/a", "for_a"); - - EXPECT_EQ(*sub_a->try_recv(), "for_a"); - EXPECT_FALSE(sub_b->try_recv().has_value()); +TEST(transport_pty_is_available_in_this_environment) { + // 这条不是"测环境", 是给下面用例的守卫: 没有 pty 时它们会静默通过。 + CHECK(have_pty()); } -TEST(InProcPubSub, FifoDepthOverflow) { - InProcTransport tp(3); // depth=3 - auto sub = tp.sub("topic/a"); +TEST(transport_write_frame_puts_the_exact_bytes_on_the_wire) { + TransportFixture fx("write"); + if (!fx.ok()) return; + const std::vector payload{0x01, 0x02, 0x03}; + fx.tr->write_frame(0x10, payload); + const auto got = fx.pty.drain(); + const auto expect = proto::pack_frame(0x10, payload); + CHECK(got == expect); +} - for (int i = 0; i < 10; ++i) { - tp.pub("topic/a", std::to_string(i)); +TEST(transport_read_frame_returns_a_well_formed_frame) { + TransportFixture fx("read"); + if (!fx.ok()) return; + const auto wire = proto::pack_frame(0x48, proto::pack_f32s(std::vector{1, 2, 3, 4, 5, 6})); + fx.pty.send(wire); + const auto fr = fx.tr->read_frame(0.5); + CHECK(fr.has_value()); + if (fr) { + CHECK_EQ(int(fr->cmd), 0x48); + CHECK_EQ(fr->payload.size(), size_t(24)); } - - // Only last 3 should survive - auto m1 = sub->try_recv(); - auto m2 = sub->try_recv(); - auto m3 = sub->try_recv(); - auto m4 = sub->try_recv(); - - ASSERT_TRUE(m1.has_value()); - ASSERT_TRUE(m2.has_value()); - ASSERT_TRUE(m3.has_value()); - EXPECT_FALSE(m4.has_value()); - EXPECT_EQ(*m1, "7"); - EXPECT_EQ(*m2, "8"); - EXPECT_EQ(*m3, "9"); } -// ── Drain latest ──────────────────────────────────────────────────────────── - -TEST(InProcDrain, EmptyQueue) { - InProcTransport tp; - auto sub = tp.sub("topic/a"); - EXPECT_FALSE(sub->drain_latest().has_value()); +TEST(transport_skips_noise_and_remembers_it) { + TransportFixture fx("noise"); + if (!fx.ok()) return; + // 固件开机签名不是帧, 会被当噪声丢弃 —— 必须留痕 (last_reset_reason 靠它)。 + const std::string banner = "[litearm-usbcdc] ready (sig_ok=1, iwdg-rst)\n"; + std::vector wire(banner.begin(), banner.end()); + const auto frame = proto::pack_frame(0x45, std::vector{0x10}); + wire.insert(wire.end(), frame.begin(), frame.end()); + fx.pty.send(wire); + const auto fr = fx.tr->read_frame(0.5); + CHECK(fr.has_value()); + if (fr) CHECK_EQ(int(fr->cmd), 0x45); + const std::string log = fx.tr->text_log(); + CHECK(log.find("iwdg-rst") != std::string::npos); } -TEST(InProcDrain, SingleMessage) { - InProcTransport tp; - auto sub = tp.sub("topic/a"); - tp.pub("topic/a", "only_one"); - auto latest = sub->drain_latest(); - ASSERT_TRUE(latest.has_value()); - EXPECT_EQ(*latest, "only_one"); +TEST(transport_drops_crc_corrupt_frames_and_resyncs) { + TransportFixture fx("crcbad"); + if (!fx.ok()) return; + auto bad = proto::pack_frame(0x43, std::vector{1, 2, 3}); + bad.back() ^= 0xFF; // 破坏 CRC + const auto good = proto::pack_frame(0x47, std::vector{9}); + std::vector wire = bad; + wire.insert(wire.end(), good.begin(), good.end()); + fx.pty.send(wire); + const auto fr = fx.tr->read_frame(0.5); + CHECK(fr.has_value()); + if (fr) { + CHECK_EQ(int(fr->cmd), 0x47); // 坏帧被丢, 好帧被扫出来 + CHECK_EQ(int(fr->payload[0]), 9); + } + // 坏帧的字节 (含它那个被丢掉的 SOF) 也应留痕 + CHECK(!fx.tr->text_log().empty()); } -TEST(InProcDrain, MultipleMessages) { - InProcTransport tp; - auto sub = tp.sub("topic/a"); - tp.pub("topic/a", "first"); - tp.pub("topic/a", "second"); - tp.pub("topic/a", "third"); - - auto latest = sub->drain_latest(); - ASSERT_TRUE(latest.has_value()); - EXPECT_EQ(*latest, "third"); - // Queue should be drained - EXPECT_FALSE(sub->try_recv().has_value()); +TEST(transport_keeps_a_partial_frame_across_calls) { + // 半帧超时**不该丢帧**: 旧实现在超时时已把 SOF 丢掉, 余下字节会被当新流扫描 ⇒ 整帧丢失。 + TransportFixture fx("partial"); + if (!fx.ok()) return; + const auto wire = proto::pack_frame(0x44, std::vector{0x41, 0x42}); + fx.pty.send(std::vector(wire.begin(), wire.begin() + 3)); // 只发头三个字节 + const auto first = fx.tr->read_frame(0.05); + CHECK(!first.has_value()); // 还没齐 + fx.pty.send(std::vector(wire.begin() + 3, wire.end())); + const auto second = fx.tr->read_frame(0.5); + CHECK(second.has_value()); + if (second) { + CHECK_EQ(int(second->cmd), 0x44); + CHECK_EQ(second->payload.size(), size_t(2)); + } } -// ── Query/Reply ───────────────────────────────────────────────────────────── - -TEST(InProcQuery, BasicQueryReply) { - InProcTransport tp; - tp.declare_queryable("rpc", [](const std::string& payload) { - return "echo:" + payload; - }); - - auto reply = tp.query("rpc", "hello"); - EXPECT_EQ(reply, "echo:hello"); +TEST(transport_timeout_zero_still_picks_up_bytes_already_waiting) { + // timeout=0 是"有就给我", 不是"一个字节都不准碰" —— 后者会让"数据早已躺在驱动缓冲里" + // 这一支永远取不到 (真机上 poll_cart 会恒返回 None)。 + TransportFixture fx("nbzero"); + if (!fx.ok()) return; + const auto wire = proto::pack_frame(0x45, std::vector{0x10}); + fx.pty.send(wire); // 确保数据已到达 + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + const auto fr = fx.tr->read_frame(0.0); + CHECK(fr.has_value()); + if (fr) CHECK_EQ(int(fr->cmd), 0x45); } -TEST(InProcQuery, MissingQueryableThrows) { - InProcTransport tp; - EXPECT_THROW(tp.query("missing", "data"), std::runtime_error); +TEST(transport_read_returns_nothing_when_idle) { + TransportFixture fx("idle"); + if (!fx.ok()) return; + const double t0 = now_s(); + const auto fr = fx.tr->read_frame(0.05); + const double dt = now_s() - t0; + CHECK(!fr.has_value()); + CHECK(dt >= 0.04); // 真的等过窗口, 不是立刻返回 + CHECK(dt < 0.5); } -TEST(InProcQuery, DoubleRegistrationThrows) { - InProcTransport tp; - tp.declare_queryable("rpc", [](const std::string& p) { return p; }); - EXPECT_THROW( - tp.declare_queryable("rpc", [](const std::string& p) { return p; }), - std::runtime_error - ); +TEST(transport_discards_a_bogus_frame_header_after_the_partial_window) { + // 噪声里凑出的假帧头 (0xA5 + 声称长度大于实际数据) 永远收不齐 —— 超过窗口即丢弃重扫, + // 否则它会把它后面的真帧永久挡住。 + TransportFixture fx("bogus"); + if (!fx.ok()) return; + const std::vector bogus{proto::SOF, 0x10, 0xFF}; // 声称 255B 载荷 + fx.pty.send(bogus); + // 窗口 (0.25s) 内读不到东西 + CHECK(!fx.tr->read_frame(0.05).has_value()); + std::this_thread::sleep_for(std::chrono::milliseconds(300)); + // 窗口过后它被丢掉, 于是后面这条真帧能被扫出来 + const auto good = proto::pack_frame(0x43, std::vector{7}); + fx.pty.send(good); + const auto fr = fx.tr->read_frame(0.5); + CHECK(fr.has_value()); + if (fr) CHECK_EQ(int(fr->cmd), 0x43); } -TEST(InProcQuery, HandlerExceptionPropagates) { - InProcTransport tp; - tp.declare_queryable("rpc", [](const std::string&) -> std::string { - throw std::runtime_error("handler error"); - }); - - EXPECT_THROW(tp.query("rpc", "data"), std::runtime_error); +TEST(transport_close_is_idempotent) { + TransportFixture fx("idem"); + if (!fx.ok()) return; + fx.tr->close(); + CHECK(!fx.tr->is_open()); + CHECK_NOTHROW(fx.tr->close()); + CHECK_NOTHROW(fx.tr->close()); + CHECK(!fx.tr->is_open()); } -TEST(InProcQuery, ComplexPayloadRoundtrip) { - InProcTransport tp; +TEST(transport_operations_after_close_are_loud) { + TransportFixture fx("afterclose"); + if (!fx.ok()) return; + fx.tr->close(); + CHECK_THROWS_AS(fx.tr->write_frame(0x10, nullptr, 0), TransportError); + CHECK_THROWS_AS(fx.tr->read_frame(0.01), TransportError); +} - // Register a mock handler that decodes requests and returns replies - tp.declare_queryable("rpc", [](const std::string& payload) -> std::string { - auto [method, kwargs] = decode_request(payload); - if (method == "echo") { - return encode_reply_ok(kwargs.at("data")); +TEST(transport_claims_the_port_in_process) { + // 进程内那道门排在 open 之前 —— 那一刻还没有任何副作用, 且错里能写清"是进程内自己人占用"。 + Pty pty; + if (!pty.ok()) return; + { + SerialTransport a(pty.slave, 0.05); + bool caught = false; + std::string what; + try { + SerialTransport b(pty.slave, 0.05); + } catch (const TransportError& e) { + caught = true; + what = e.what(); } - return encode_reply_error("LiteArmError", "unknown method"); - }); - - // Send a request - std::map kwargs; - kwargs["data"] = LiteArmValue(std::string("test_value")); - auto req = encode_request("echo", kwargs); - - auto reply = tp.query("rpc", req); - auto result = decode_reply(reply); - EXPECT_EQ(result.as_string(), "test_value"); + CHECK(caught); + // 归因必须指向"进程内占用", 而不是 pyserial 那句 "Resource temporarily unavailable" + CHECK(what.find("本进程内另一个传输占用") != std::string::npos); + } + // 关掉之后端口该被释放 —— 同一个口能被再次打开 + CHECK_NOTHROW(SerialTransport c(pty.slave, 0.05)); } -// ── Close ─────────────────────────────────────────────────────────────────── - -TEST(InProcClose, ClearStateOnClose) { - InProcTransport tp; - auto sub = tp.sub("topic/a"); - tp.declare_queryable("rpc", [](const std::string& p) { return p; }); - - tp.pub("topic/a", "msg"); - tp.close(); +TEST(transport_release_only_drops_its_own_registration) { + Pty pty; + if (!pty.ok()) return; + SerialTransport a(pty.slave, 0.05); + const auto snap = port_owners_snapshot(); + bool found = false; + for (const auto& kv : snap) { + if (kv.first == pty.slave && kv.second == &a) found = true; + } + CHECK(found); + a.close(); + // close 之后本传输那条登记该消失 + for (const auto& kv : port_owners_snapshot()) { + CHECK(!(kv.first == pty.slave && kv.second == &a)); + } +} - // Publishing after close should not crash (silently ignored) - tp.pub("topic/a", "after_close"); - // Querying after close should fail - EXPECT_THROW(tp.query("rpc", "data"), std::runtime_error); +TEST(transport_failed_open_releases_the_registration) { + // 打不开的口不该把登记留下 —— 否则这次失败会把端口占死。 + bool caught = false; + try { + SerialTransport bad("/dev/litearm-does-not-exist-xyz", 0.05); + } catch (const TransportError&) { + caught = true; + } + CHECK(caught); + for (const auto& kv : port_owners_snapshot()) { + CHECK(kv.first != std::string("/dev/litearm-does-not-exist-xyz")); + } } -// ── Factory ───────────────────────────────────────────────────────────────── +TEST(transport_find_cdc_port_is_safe_without_hardware) { + // 本机没有接臂时应当返回空, 而不是抛/崩。接臂时返回 /dev/ttyACM*。 + const auto p = find_cdc_port(); + if (p) { + CHECK(p->rfind("/dev/", 0) == 0); + } +} -TEST(TransportFactory, InProcKind) { - auto tp = make_transport("inproc"); - ASSERT_NE(tp, nullptr); +TEST(transport_serial_is_open_reflects_state) { + TransportFixture fx("isopen"); + if (!fx.ok()) return; + CHECK(fx.tr->is_open()); + fx.tr->close(); + CHECK(!fx.tr->is_open()); } -TEST(TransportFactory, UnknownKindThrows) { - EXPECT_THROW(make_transport("unknown"), std::runtime_error); +TEST(transport_port_name_is_reported) { + TransportFixture fx("portname"); + if (!fx.ok()) return; + CHECK_EQ(fx.tr->port_name(), fx.pty.slave); } -} // namespace -} // namespace litearm +TEST(transport_flush_failures_start_at_zero) { + TransportFixture fx("flush"); + if (!fx.ok()) return; + CHECK_EQ(int(fx.tr->flush_failures()), 0); + fx.tr->write_frame(0x10, nullptr, 0); + // 正常路径不该记失败 + CHECK_EQ(int(fx.tr->flush_failures()), 0); +} diff --git a/tests/test_zero_g.cpp b/tests/test_zero_g.cpp new file mode 100644 index 0000000..3347014 --- /dev/null +++ b/tests/test_zero_g.cpp @@ -0,0 +1,288 @@ +// 零重力 (拖动示教) —— 保活线程 / 双向守卫 / 幂等启停。 +#include "test_support.hpp" + +using namespace litearm; + +namespace { +int count_cmd(const testing::FakeTransport& t, uint8_t cmd) { + int n = 0; + for (const auto& kv : t.tx_snapshot()) { + if (kv.first == cmd) ++n; + } + return n; +} +int count_zero_g_on(const testing::FakeTransport& t) { + int n = 0; + for (const auto& kv : t.tx_snapshot()) { + if (kv.first == proto::CMD_ZERO_G && kv.second.size() == 1 && + kv.second[0] == 0x01) { + ++n; + } + } + return n; +} + +/// 等到 `count_zero_g_on(t) >= want` 或超时。返回**实际**看到的条数。 +/// +/// ⚠ 为什么不用"睡固定时长再看一眼": 那是在断言**调度器的快慢**, 不是在断言保活。 +/// 实测 (2026-09-28, TSan 下): `sleep(80ms)` + 断言 `>= 3` 会以约 1/6 的概率翻红 —— +/// TSan 让每条锁操作慢一个数量级, 保活线程被饿到只跑了 2 拍。而"保活会不会周期重发" +/// 这件事本身**没问题**; 翻红的是判据, 不是被测行为。 +/// 改成"等到出现为止(有上界)"之后: 保活真坏了就一定超时失败, 机器慢只是多等一会儿。 +int wait_for_zero_g_on(const testing::FakeTransport& t, int want, double timeout_s) { + const double end = now_s() + timeout_s; + while (true) { + const int n = count_zero_g_on(t); + if (n >= want) return n; + if (now_s() >= end) return n; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } +} + +/// 等到 `pred()` 为真, 或超时。返回最终是否成立。 +/// +/// ⚠ 本文件里凡是"后台保活线程该做某事了"的判据都用它, **不要**写 +/// `sleep_for(80ms); CHECK(...)`: 那断言的是**调度器有多快**, 而 TSan/ASan 下保活线程 +/// 会被饿到在 80ms 里跑不了几拍 (实测 2026-09-28: `test_zero_g` 与 `test_threading` +/// 各有一条用例因此翻红)。上界给足之后, 保活真坏了照样失败, 机器慢只是多等一会儿。 +template +bool wait_until(Pred pred, double timeout_s) { + const double end = now_s() + timeout_s; + while (true) { + if (pred()) return true; + if (now_s() >= end) return false; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } +} +} // namespace + +TEST(zero_g_start_sends_on_and_starts_the_keepalive) { + lt::Offline off; + off->zero_g_start(0.005); + CHECK(off->zero_g_active()); + // 固件 0x06 自带 watchdog_kick (「重发即保活」), 而固件里没有别的命令能维持一个非 + // MOVE_J 模式不被 0.10s 看门狗掐死 => 保活必须周期重发。 + // ⚠ 判据是"**会重发**" (至少 3 拍), 不是"80ms 内正好几拍" —— 周期 5ms 只是下限约束, + // 上界由固件看门狗 (0.10s) 兜着; 断言具体拍数就等于断言机器有多快 (见上面那条)。 + CHECK(wait_for_zero_g_on(off.t(), 3, 2.0) >= 3); + off->zero_g_stop(); + CHECK(!off->zero_g_active()); + // 退出会发 on=0 + int off_frames = 0; + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first == proto::CMD_ZERO_G && kv.second.size() == 1 && + kv.second[0] == 0x00) { + ++off_frames; + } + } + CHECK_EQ(off_frames, 1); +} + +TEST(zero_g_start_is_idempotent) { + lt::Offline off; + off->zero_g_start(0.01); + const int first = count_zero_g_on(off.t()); + off->zero_g_start(0.01); + // 已激活 = 幂等 no-op: 根本不写 0x06, 无 coast 风险 + CHECK_EQ(count_zero_g_on(off.t()), first); + off->zero_g_stop(); +} + +TEST(zero_g_stop_is_idempotent_and_does_not_invent_a_session) { + lt::Offline off; + // 从未进入过就不发任何 0x06 —— 幂等退出不等于无中生有 + CHECK_NOTHROW(off->zero_g_stop()); + CHECK_NOTHROW(off->zero_g_stop()); + CHECK_EQ(count_cmd(off.t(), proto::CMD_ZERO_G), 0); +} + +TEST(zero_g_guard_rejects_action_commands) { + lt::Offline off; + off->zero_g_start(0.01); + CHECK_THROWS_AS(off->movej(std::vector(7, 0.0)), InvalidCommandError); + CHECK_THROWS_AS(off->movej_sync(std::vector(7, 0.0)), InvalidCommandError); + CHECK_THROWS_AS(off->move_p({0.3, 0.0, 0.4, 0.0, 0.0, 0.0}), InvalidCommandError); + CHECK_THROWS_AS(off->home(), InvalidCommandError); + CHECK_THROWS_AS(off->enable(), InvalidCommandError); + CHECK_THROWS_AS(off->save_params(), InvalidCommandError); + CHECK_THROWS_AS(off->move_js(std::vector(7, 0.0)), InvalidCommandError); + // 归因入口: 文案要说清"为什么拒"与"怎么退出" + try { + off->movej(std::vector(7, 0.0)); + FAIL("应当被拒"); + } catch (const InvalidCommandError& e) { + CHECK(std::string(e.what()).find("zero_g_stop()") != std::string::npos); + } + off->zero_g_stop(); +} + +TEST(zero_g_guard_lets_queries_and_energy_shedding_through) { + // 拖动示教期间仍应能读状态; 降能量方向的动作必须永远可达。 + lt::Offline off; + off->zero_g_start(0.01); + CHECK_NOTHROW(off->get_state(true)); + CHECK_NOTHROW(off->get_tcp()); + CHECK_NOTHROW(off->get_status_now()); + CHECK_NOTHROW(off->emergency_stop()); + CHECK_NOTHROW(off->disable()); + CHECK_NOTHROW(off->params().get_joint_param(0)); + CHECK_NOTHROW(off->diag().kin_bench()); + off->zero_g_stop(); +} + +TEST(zero_g_period_is_validated_against_the_watchdog) { + // 固件看门狗超时是 0.10s —— 周期必须留在它以内且不至于太快。 + lt::Offline off; + CHECK_THROWS_AS(off->zero_g_start(0.1), InvalidCommandError); + CHECK_THROWS_AS(off->zero_g_start(0.001), InvalidCommandError); + CHECK_THROWS_AS(off->zero_g_start(-1.0), InvalidCommandError); + CHECK_NOTHROW(off->zero_g_start(0.04)); + off->zero_g_stop(); +} + +TEST(zero_g_raii_session_stops_on_scope_exit) { + lt::Offline off; + { + auto session = off->zero_g(0.01); + CHECK(session.active()); + CHECK(session.arm() == off.arm.get()); + } + CHECK(!off->zero_g_active()); + int off_frames = 0; + for (const auto& kv : off.t().tx_snapshot()) { + if (kv.first == proto::CMD_ZERO_G && kv.second.size() == 1 && + kv.second[0] == 0x00) { + ++off_frames; + } + } + CHECK_EQ(off_frames, 1); +} + +TEST(zero_g_session_stop_is_the_throwing_variant) { + lt::Offline off; + auto session = off->zero_g(0.01); + CHECK_NOTHROW(session.stop()); + CHECK(!off->zero_g_active()); + // 已经停过之后再 stop 是空操作 + CHECK_NOTHROW(session.stop()); +} + +TEST(zero_g_session_can_be_moved) { + lt::Offline off; + auto a = off->zero_g(0.01); + auto b = std::move(a); + // 被移动走的那一个不再是持有者 + CHECK(!a.active()); + CHECK(b.active()); + b.reset(); + CHECK(!off->zero_g_active()); +} + +TEST(zero_g_keepalive_failure_is_visible_and_not_silent) { + // 写失败 = 保活已断, 必须让调用方知道 —— 臂已脱离零重力, 不能静默。 + lt::Offline off; + off.t().zg_fail_after = 1; // 第 2 次 0x06 写 (首次保活) 抛 + off->zero_g_start(0.005); + // 等保活线程真正跑到那一拍 (有上界) —— 见 `wait_until` 的说明。 + CHECK(wait_until([&] { return !off->zero_g_active(); }, 2.0)); + CHECK(off->zero_g_error() != nullptr); + CHECK(!off->zero_g_error_text().empty()); + // raise_on_lost=true 时 stop 会把原异常抛出来 + CHECK_THROWS_AS(off->zero_g_stop(true), TransportError); + // stop 消费并清空它 + CHECK(off->zero_g_error() == nullptr); +} + +TEST(zero_g_stop_without_raise_swallows_the_keepalive_failure) { + lt::Offline off; + off.t().zg_fail_after = 1; + off->zero_g_start(0.005); + // 先确保保活**已经断掉**再 stop —— 否则测到的是"正常退出", 不是"退出时吞掉保活异常"。 + wait_until([&] { return !off->zero_g_active(); }, 2.0); + CHECK_NOTHROW(off->zero_g_stop(false)); +} + +TEST(zero_g_close_stops_the_keepalive_thread) { + // 第一步收保活线程必须是第一步 (它持有一个写者; 残留会让进程退出时打哑 CDC)。 + lt::Offline off; + off->zero_g_start(0.005); + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + off->close(); + // ⚠ 基线必须取在 **close 之后**: 取在它之前的话, 采样与 close 之间保活线程还能**合法地** + // 再发一帧 (Debug/TSan 下变慢就会撞上, 实测 2 != 1) —— 那是采样窗口的竞态, 不是缺陷。 + const int after_close = count_zero_g_on(off.t()); + std::this_thread::sleep_for(std::chrono::milliseconds(60)); + // close 之后不该再有任何保活帧出去 + CHECK_EQ(count_zero_g_on(off.t()), after_close); +} + +// ---------------------------------------------------------------- 反向守卫 + +TEST(zero_g_is_refused_while_a_cartesian_motion_is_in_flight) { + // 中途进场会丢掉位置环、只剩重力前馈 => 臂会靠摩擦滑停 (coast), 比受控接管差得多。 + lt::Offline off; + // ⚠ `quiesce` 在这里的作用与别处**不同**: 不是躲 get_status_now 的窗口, 而是让 + // "bit10 还亮着"这件事确定。桩的 CART_BUSY 只在"交付后的第 2..7 帧"置位 (见 + // `set_cart_busy`), 被动流一直在跑 ⇒ TSan 下守卫去读的时候窗口可能**已经过期**, + // 于是它读到 bit10=0 而放行 —— 翻红的是布景, 不是守卫。 + // 关掉被动流之后, 从 `set_cart_busy(5)` 到守卫读帧之间只会多出**它自己**那条 0x40 + // 的应答帧, 计数恰好 +1, 必落在窗口内。 + lt::quiesce(off.t()); + // 制造"在途": 让某条笛卡尔入口持着串行锁 (用一条永不返回的响应拖住它不容易), + // 故改用第二条判据 —— 现取一帧状态的 bit10 为 1。 + off.t().set_cart_busy(3); // 接下来几帧状态带 CART_BUSY + off.t().set_pose({0.3, 0.0, 0.4, 0.0, 0.0, 0.0}); + // 先跑一条 wait=false 的 move_l 让它把 cart_busy 拉起来 + off->move_l({0.32, 0.0, 0.42, 0.0, 0.0, 0.0}, 0.5, false); + off.t().set_cart_busy(5); // 保持"臂还在跑"的形状 + try { + off->zero_g_start(0.01); + // 若放行了, 说明两条判据都没抓住 —— 但也可能只是桩的帧序巧合, 故只报失败不崩。 + off->zero_g_stop(); + CHECK(false); + } catch (const InvalidCommandError& e) { + CHECK(std::string(e.what()).find("笛卡尔运动在途") != std::string::npos); + } +} + +TEST(zero_g_is_refused_conservatively_when_the_status_is_unavailable) { + // 未确认就不拦, 漏过去的正是"轨迹中途进场靠摩擦滑停"那一侧 —— 故保守拒绝, + // 但必须说清是哪一种拒绝。 + // + // ⚠⚠ `quiesce` 是本用例的**前提**, 不是讲究 —— 缺了它本用例在 TSan 下约 1/10 概率翻红 + // (实测 2026-09-28: 30 次里 3 次 "应当保守拒绝")。 + // + // 原因与 `cmd_unsupported_during_status_read_...` 是**同一个**: `get_status_now` + // 的结论判据后半条是"有任何新状态帧到", 而被动流与我们的 `0x40` 是两条独立的线。 + // 帧先落进窗口 ⇒ 它以为命令成功了 ⇒ 这里拿到一帧**陈旧**的状态 ⇒ `cart_busy()` + // 为 0 ⇒ 守卫**放行**。 + // ⚠ 这是本缺陷里后果最重的一处: 它落在**安全守卫**上, 而且是**fail-open** —— + // "没能确认是否在途"本该保守拒绝, 却因为"误以为确认过了"而放行。见 README。 + lt::Offline off; + lt::quiesce(off.t()); + off.t().err_override[proto::CMD_GET_STATUS] = 0x03; + try { + off->zero_g_start(0.01); + off->zero_g_stop(); + FAIL("应当保守拒绝"); + } catch (const InvalidCommandError& e) { + CHECK(std::string(e.what()).find("保守拒绝") != std::string::npos); + } +} + +TEST(zero_g_already_active_skips_the_reverse_guard) { + // 已激活 = 幂等 no-op: 根本不写 0x06, 无 coast 风险, 故不该被反向守卫拦住。 + lt::Offline off; + off->zero_g_start(0.01); + off.t().set_cart_busy(3); + CHECK_NOTHROW(off->zero_g_start(0.01)); + off.t().clear_cart_busy(); + off->zero_g_stop(); +} + +TEST(zero_g_last_reset_reason_and_error_are_local_accessors) { + lt::Offline off; + CHECK(off->zero_g_error() == nullptr); + CHECK(off->zero_g_error_text().empty()); + CHECK_EQ(off->zero_g_active(), false); +}