From b53e3716dd3260a0fcf75e3b99b63f3507566579 Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Mon, 15 Jun 2026 15:50:41 +0800 Subject: [PATCH 01/19] docs: add repository contributor guide --- AGENTS.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9c67e9a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +本仓库是 ROS2 `ament_cmake` 工作区。核心节点位于 `src/linker_hand_cpp_ros2/`,入口源码为 `src/linker_hand_cpp_ros2/src/linker_hand_node.cpp`,启动文件在 `src/linker_hand_cpp_ros2/launch/run.xml`。示例程序位于 `src/examples/src/`,每个 `.cpp` 文件对应一个独立可执行示例。包元数据和构建规则分别由各包内的 `package.xml` 与 `CMakeLists.txt` 管理。仓库根目录保留 `README.md`、`LICENSE` 和本指南。 + +## Build, Test, and Development Commands + +- `colcon build`: 从仓库根目录构建全部 ROS2 包。 +- `colcon build --packages-select linker_hand_cpp_ros2`: 仅构建核心节点包,适合快速验证节点改动。 +- `source install/setup.bash`: 加载构建后的工作区环境。 +- `ros2 launch linkerhand_cpp_ros2 run.xml`: 启动 LinkerHand ROS2 节点。 +- `colcon test`: 运行已启用的 ROS2/ament 测试与 lint 钩子。 +- `colcon test-result --verbose`: 查看测试失败详情。 + +构建前需确保已安装 ROS2 Foxy 或 Humble,以及兼容的 `linkerhand-cpp-sdk`(README 标注支持 v1.1.7 及以下)。 + +## Coding Style & Naming Conventions + +项目使用 C++14,CMake 中启用 `-Wall -Wextra -Wpedantic`。遵循现有代码风格:CMake 变量使用大写或描述性蛇形命名,C++ 文件和 ROS2 可执行目标使用小写下划线,例如 `show_wave_l20.cpp`。新增示例应放入 `src/examples/src/`,并同步更新 `src/examples/CMakeLists.txt` 的 `SOURCE_FILES`。注释只解释非显然原因,避免描述代码表面行为。 + +## Testing Guidelines + +当前未包含专门的单元测试目录,主要依赖 `colcon build`、`colcon test` 与运行验证。涉及节点行为的改动,应至少构建核心包,并在可用硬件或仿真环境下运行 `ros2 launch linkerhand_cpp_ros2 run.xml`。新增测试时优先使用 ROS2/ament 生态,并保持测试名称与目标功能对应。 + +## Commit & Pull Request Guidelines + +Git 历史使用英文前缀提交信息,例如 `docs: update README`、`feat: update ci/cd`。提交应采用 `type: 简短说明` 格式,常用类型包括 `feat`、`fix`、`docs`、`build`、`test`、`refactor`。Pull Request 应说明变更目的、影响范围、验证命令和结果;涉及硬件行为时注明设备型号、CAN 口配置和是否完成实机验证。 + +## Security & Configuration Tips + +不要提交本地构建产物、硬件私有配置或凭据。修改 `run.xml` 默认参数时,确认左右手存在性、型号、触觉开关和 CAN 设备名与 README 中的话题约定一致。 From c869c359dbe33ff85332caa429367d4a65ef3e2e Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Mon, 15 Jun 2026 15:51:16 +0800 Subject: [PATCH 02/19] fix: adapt SDK integration and tighten node startup --- .github/workflows/ci.yml | 4 +- AGENTS.md | 8 +- README.md | 25 ++- src/examples/CMakeLists.txt | 72 ++----- src/examples/package.xml | 1 + src/examples/src/linker_hand_node.cpp | 149 ++++++++++----- src/linker_hand_cpp_ros2/CMakeLists.txt | 65 +------ src/linker_hand_cpp_ros2/launch/run.xml | 9 +- src/linker_hand_cpp_ros2/launch/run_left.xml | 40 ++++ src/linker_hand_cpp_ros2/package.xml | 1 + .../src/linker_hand_node.cpp | 179 ++++++++++++------ 11 files changed, 309 insertions(+), 244 deletions(-) create mode 100644 src/linker_hand_cpp_ros2/launch/run_left.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 345c6fe..21d4eaa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,9 +68,9 @@ jobs: - name: Setup LinkerHand SDK run: | - git clone --branch v1.1.7 https://github.com/linker-bot/linkerhand-cpp-sdk.git /tmp/sdk + git clone --depth 1 --branch v2.1.7 https://github.com/linker-bot/linkerhand-cpp-sdk.git /tmp/sdk cd /tmp/sdk && mkdir build && cd build - cmake .. && make -j$(nproc) && make install && ldconfig + cmake .. -DBUILD_EXAMPLES=OFF && make install && ldconfig - name: Build Workspace id: build_step diff --git a/AGENTS.md b/AGENTS.md index 9c67e9a..b642293 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,19 +9,19 @@ - `colcon build`: 从仓库根目录构建全部 ROS2 包。 - `colcon build --packages-select linker_hand_cpp_ros2`: 仅构建核心节点包,适合快速验证节点改动。 - `source install/setup.bash`: 加载构建后的工作区环境。 -- `ros2 launch linkerhand_cpp_ros2 run.xml`: 启动 LinkerHand ROS2 节点。 +- `ros2 launch linker_hand_cpp_ros2 run.xml`: 启动 LinkerHand ROS2 节点。 - `colcon test`: 运行已启用的 ROS2/ament 测试与 lint 钩子。 - `colcon test-result --verbose`: 查看测试失败详情。 -构建前需确保已安装 ROS2 Foxy 或 Humble,以及兼容的 `linkerhand-cpp-sdk`(README 标注支持 v1.1.7 及以下)。 +构建前需确保已安装 ROS2 Foxy 或 Humble,以及 `linkerhand-cpp-sdk v2.1.7`。 ## Coding Style & Naming Conventions -项目使用 C++14,CMake 中启用 `-Wall -Wextra -Wpedantic`。遵循现有代码风格:CMake 变量使用大写或描述性蛇形命名,C++ 文件和 ROS2 可执行目标使用小写下划线,例如 `show_wave_l20.cpp`。新增示例应放入 `src/examples/src/`,并同步更新 `src/examples/CMakeLists.txt` 的 `SOURCE_FILES`。注释只解释非显然原因,避免描述代码表面行为。 +项目使用 C++17,CMake 中启用 `-Wall -Wextra -Wpedantic`。遵循现有代码风格:CMake 变量使用大写或描述性蛇形命名,C++ 文件和 ROS2 可执行目标使用小写下划线,例如 `show_wave_l20.cpp`。新增示例应放入 `src/examples/src/`,并同步更新 `src/examples/CMakeLists.txt` 的 `SOURCE_FILES`。注释只解释非显然原因,避免描述代码表面行为。 ## Testing Guidelines -当前未包含专门的单元测试目录,主要依赖 `colcon build`、`colcon test` 与运行验证。涉及节点行为的改动,应至少构建核心包,并在可用硬件或仿真环境下运行 `ros2 launch linkerhand_cpp_ros2 run.xml`。新增测试时优先使用 ROS2/ament 生态,并保持测试名称与目标功能对应。 +当前未包含专门的单元测试目录,主要依赖 `colcon build`、`colcon test` 与运行验证。涉及节点行为的改动,应至少构建核心包,并在可用硬件或仿真环境下运行 `ros2 launch linker_hand_cpp_ros2 run.xml`。新增测试时优先使用 ROS2/ament 生态,并保持测试名称与目标功能对应。 ## Commit & Pull Request Guidelines diff --git a/README.md b/README.md index 8ab9554..fac0b1e 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,11 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- - **操作系统**: Linux (Ubuntu 20.04+ 推荐) - **架构**: x86_64 -- **编译器**: GCC 7.0+ 或 Clang 5.0+ +- **编译器**: GCC 7.0+ 或 Clang 6.0+(C++17) - **CMake**: 3.15+ -- **依赖**: ROS2 (推荐 Foxy 或 Humble)、[linkerhand-cpp-sdk](https://github.com/linker-bot/linkerhand-cpp-sdk/releases/tag/v1.1.7) +- **依赖**: ROS2 (推荐 Foxy 或 Humble)、[linkerhand-cpp-sdk v2.1.7](https://github.com/linker-bot/linkerhand-cpp-sdk/releases/tag/v2.1.7) -> 注:本项目仅支持linkerhand-cpp-sdk v1.1.7及以下版本 +> 注:本项目已适配 linkerhand-cpp-sdk v2.1.7;O20 依赖 CAN-FD,当前 ROS2 节点仍以普通 CAN 为主,暂未完成 O20 实机运行适配。 ## 🚀 快速开始 @@ -45,23 +45,24 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- - + - + - + + ...... @@ -70,7 +71,7 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- #### 运行 source install/setup.bash - ros2 launch linkerhand_cpp_ros2 run.xml + ros2 launch linker_hand_cpp_ros2 run.xml ## 📚 话题详情 @@ -113,11 +114,15 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- ```bash L7: ["大拇指弯曲", "大拇指横摆","食指弯曲", "中指弯曲", "无名指弯曲","小拇指弯曲","拇指旋转"] +L6/O6: ["拇指根部", "拇指侧摆", "食指根部", "中指根部", "无名指根部", "小指根部"] + L10: ["拇指根部", "拇指侧摆","食指根部", "中指根部", "无名指根部","小指根部","食指侧摆","无名指侧摆","小指侧摆","拇指旋转"] L20: ["拇指根部", "食指根部", "中指根部", "无名指根部","小指根部","拇指侧摆","食指侧摆","中指侧摆","无名指侧摆","小指侧摆","拇指横摆","预留","预留","预留","预留","拇指尖部","食指末端","中指末端","无名指末端","小指末端"] -L21: ["大拇指根部", "食指根部", "中指根部","无名指根部","小拇指根部","大拇指侧摆","食指侧摆","中指侧摆","无名指侧摆","小拇指侧摆","大拇指横滚","预留","预留","预留","预留","大拇指中部","预留","预留","预留","预留","大拇指指尖","食指指尖","中指指尖","无名指指尖","小拇指指尖"] +G20: ["大拇指根部", "食指根部", "中指根部","无名指根部","小拇指根部","大拇指侧摆","食指侧摆","中指侧摆","无名指侧摆","小拇指侧摆","大拇指横滚","大拇指指尖","食指指尖","中指指尖","无名指指尖","小拇指指尖"] + +L21: ["大拇指根部", "食指根部", "中指根部","无名指根部","小拇指根部","大拇指侧摆","食指侧摆","中指侧摆","无名指侧摆","小拇指侧摆","大拇指横滚","预留","预留","预留","预留","大拇指中部","预留","预留","预留","预留","大拇指指尖"] L25: ["大拇指根部", "食指根部", "中指根部","无名指根部","小拇指根部","大拇指侧摆","食指侧摆","中指侧摆","无名指侧摆","小拇指侧摆","大拇指横滚","预留","预留","预留","预留","大拇指中部","食指中部","中指中部","无名指中部","小拇指中部","大拇指指尖","食指指尖","中指指尖","无名指指尖","小拇指指尖"] ``` @@ -132,7 +137,7 @@ L25: ["大拇指根部", "食指根部", "中指根部","无名指根部","小 # 清除故障码 $ ros2 topic pub /left_hand_setting std_msgs/msg/String "data: '{\"setting_cmd\": \"clear_faults\"}'" - # 设置电流 + # 设置电流(linkerhand-cpp-sdk v2.1.7 暂不支持,节点会输出警告) $ ros2 topic pub /left_hand_setting std_msgs/msg/String "data: '{\"setting_cmd\": \"set_electric_current\", \"params\": {\"electric_current\": 50}}'" # 使能 @@ -228,4 +233,4 @@ Copyright (c) 2026 灵心巧手(北京)科技有限公司 --- -**注意**: 使用前请确保设备已正确连接并配置好通信接口。 \ No newline at end of file +**注意**: 使用前请确保设备已正确连接并配置好通信接口。 diff --git a/src/examples/CMakeLists.txt b/src/examples/CMakeLists.txt index 57efa31..4fa38e9 100644 --- a/src/examples/CMakeLists.txt +++ b/src/examples/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.5) +cmake_minimum_required(VERSION 3.10) project(examples) # Default to C99 @@ -6,80 +6,28 @@ if(NOT CMAKE_C_STANDARD) set(CMAKE_C_STANDARD 99) endif() -# Default to C++14 +# Default to C++17 if(NOT CMAKE_CXX_STANDARD) - set(CMAKE_CXX_STANDARD 14) + set(CMAKE_CXX_STANDARD 17) endif() +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) # Enable additional compiler warnings if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic) endif() -if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64") - set(LIB_SUBDIR "x86_64") -elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") - set(LIB_SUBDIR "aarch64") -else() - message(WARNING "Unknown architecture, defaulting to x86_64") - set(LIB_SUBDIR "x86_64") -endif() - # Find dependencies find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) find_package(std_msgs REQUIRED) find_package(sensor_msgs REQUIRED) - -# Define common include directories -set(COMMON_INCLUDE_DIRS - /usr/local/linker_hand_cpp_sdk/include - /usr/local/linker_hand_cpp_sdk/third_party/Robotic_Arm/include - include -) - -#----------------------------------------------------------------------------- -# LINKER_HAND_CPP_SDK -#----------------------------------------------------------------------------- -find_library(LINKER_HAND_LIB - NAMES linkerhand_cpp_sdk linkerhand_cpp - PATHS ${CMAKE_CURRENT_SOURCE_DIR}/lib/${LIB_SUBDIR} - /usr/local/lib/linkerhand-cpp-sdk/${LIB_SUBDIR} - /usr/lib/linkerhand-cpp-sdk/${LIB_SUBDIR} - ${CMAKE_INSTALL_PREFIX}/lib/linkerhand-cpp-sdk/${LIB_SUBDIR} - NO_DEFAULT_PATH -) - -set(LINKER_HAND_INCLUDE_DIR - ${CMAKE_CURRENT_SOURCE_DIR}/include - /usr/local/include/linkerhand-cpp-sdk - /usr/include/linkerhand-cpp-sdk - ${CMAKE_INSTALL_PREFIX}/include/linkerhand-cpp-sdk -) - -if(NOT LINKER_HAND_LIB) - message(FATAL_ERROR "linkerhand_cpp_sdk library not found!") -endif() - -if(NOT LINKER_HAND_INCLUDE_DIR) - message(FATAL_ERROR "LinkerHand headers not found!") -endif() - -message(STATUS "Found linkerhand_cpp_sdk library: ${LINKER_HAND_LIB}") -message(STATUS "Found LinkerHand headers: ${LINKER_HAND_INCLUDE_DIR}") - -#----------------------------------------------------------------------------- -# INCLUDE_DIRECTORIES -#----------------------------------------------------------------------------- -include_directories( - ${CMAKE_CURRENT_SOURCE_DIR}/include - ${LINKER_HAND_INCLUDE_DIR} -) - -# ---------------------------------------------------------------------- +find_package(nlohmann_json REQUIRED) +find_package(linkerhand-cpp-sdk 2.0 CONFIG REQUIRED) # Define common link libraries -set(COMMON_LIBS ${LINKER_HAND_LIB} pthread) +set(COMMON_LIBS LinkerHand::linkerhand_cpp_sdk nlohmann_json::nlohmann_json) # Define source files set(SOURCE_FILES @@ -107,6 +55,10 @@ foreach(SOURCE_FILE ${SOURCE_FILES}) sensor_msgs ) target_link_libraries(${SOURCE_FILE} ${COMMON_LIBS}) + set_target_properties(${SOURCE_FILE} PROPERTIES + BUILD_RPATH "${linkerhand-cpp-sdk_LIBRARY_DIR}" + INSTALL_RPATH "${linkerhand-cpp-sdk_LIBRARY_DIR}" + ) install(TARGETS ${SOURCE_FILE} DESTINATION lib/${PROJECT_NAME} ) diff --git a/src/examples/package.xml b/src/examples/package.xml index 6ff7971..ce4a475 100644 --- a/src/examples/package.xml +++ b/src/examples/package.xml @@ -11,6 +11,7 @@ ament_lint_auto ament_lint_common + nlohmann-json-dev ament_cmake diff --git a/src/examples/src/linker_hand_node.cpp b/src/examples/src/linker_hand_node.cpp index 3e61ba2..345bb2e 100644 --- a/src/examples/src/linker_hand_node.cpp +++ b/src/examples/src/linker_hand_node.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -22,6 +23,7 @@ #include #include "LinkerHandApi.h" +#include "CommFactory.h" using namespace std::chrono_literals; @@ -66,6 +68,7 @@ class LinkerHand : public rclcpp::Node this->declare_parameter("HAND_SPEED", 50); this->declare_parameter("HAND_EFFORT", 200); this->declare_parameter("CAN_CHANNEL", "can0"); + this->declare_parameter("CAN_BITRATE", 1000000); this->declare_parameter("HAND_SETTING_TOPIC", "/cb_hand_setting_cmd"); this->declare_parameter("HAND_CONTROL_TOPIC", "/cb_hand_control_cmd"); @@ -88,6 +91,7 @@ class LinkerHand : public rclcpp::Node this->get_parameter("HAND_SPEED", hand_speed); this->get_parameter("HAND_EFFORT", hand_effort); this->get_parameter("CAN_CHANNEL", can_channel); + this->get_parameter("CAN_BITRATE", can_bitrate); // std::cout << "LinkerHand SDK version: " << version << std::endl; std::cout << (hand_type == 0 ? "LEFT_HAND" : "RIGHT_HAND") << " HAND_EXISTS:" << hand_exists << " HAND_JOINTS:" << hand_joints << " HAND_TOUCH:" << hand_touch << std::endl; @@ -96,32 +100,68 @@ class LinkerHand : public rclcpp::Node // LinkerHandType mapping std::map linker_hand_map = { + {"L6", LINKER_HAND::L6}, {"L7", LINKER_HAND::L7}, {"L10", LINKER_HAND::L10}, {"L20", LINKER_HAND::L20}, {"L21", LINKER_HAND::L21}, - {"L25", LINKER_HAND::L25} + {"L25", LINKER_HAND::L25}, + {"O6", LINKER_HAND::O6}, + {"O20", LINKER_HAND::O20}, + {"G20", LINKER_HAND::G20} }; - COMM_TYPE channel; - - (can_channel == "can0") ? channel = COMM_TYPE::COMM_CAN_0 : channel = COMM_TYPE::COMM_CAN_1; - if (hand_exists) { auto it = linker_hand_map.find(hand_joints); if (it != linker_hand_map.end()) { + if (it->second == LINKER_HAND::O20) { + RCLCPP_ERROR(this->get_logger(), "O20 requires CAN-FD and is not supported by this CAN-only ROS2 node yet"); + return; + } if (hand_type == 0) { - hand_api = std::make_unique(it->second, HAND_TYPE::LEFT, channel); + hand_api = std::make_unique(it->second, HAND_TYPE::LEFT, COMM_TYPE::CAN); } else if (hand_type == 1) { - hand_api = std::make_unique(it->second, HAND_TYPE::RIGHT, channel); + hand_api = std::make_unique(it->second, HAND_TYPE::RIGHT, COMM_TYPE::CAN); } else { std::cout << "Invalid hand_type: " << hand_type << std::endl; + return; } } else { std::cout << "Invalid hand: " << hand_joints << std::endl; + return; } } + can_bus = std::shared_ptr( + Communication::CommFactory::createCanBus(can_channel, can_bitrate)); + + hand_api->setCanTxCallback([this](uint32_t can_id, const uint8_t *data, uintptr_t data_len) -> int32_t { + try { + std::vector data_vec(data, data + data_len); + can_bus->send(data_vec, can_id); + return 0; + } catch (const std::exception &e) { + RCLCPP_ERROR(this->get_logger(), "CAN send error: %s", e.what()); + return -1; + } + }); + + hand_api->setCanRxCallback([this](uint32_t *can_id_out, uint8_t *data_out, uint8_t *len_out) -> int32_t { + try { + auto frame = can_bus->recv(); + if (frame.can_id == 0 && frame.can_dlc == 0) { + return -1; + } + *can_id_out = frame.can_id; + *len_out = frame.can_dlc; + std::memcpy(data_out, frame.data, frame.can_dlc); + return 0; + } catch (const std::exception &e) { + RCLCPP_ERROR(this->get_logger(), "CAN receive error: %s", e.what()); + return -1; + } + }); + if (hand_exists) initHand(hand_api, hand_joints); auto hand_control_cmd_cb = [this](sensor_msgs::msg::JointState::SharedPtr msg) -> void @@ -158,11 +198,8 @@ class LinkerHand : public rclcpp::Node hand_api->clearFaultCode(); } - // setCurrent if (data["setting_cmd"] == "set_electric_current") { - int tmp = data["params"]["electric_current"].get(); - std::vector e_c(5, tmp); - hand_api->setCurrent(e_c); + RCLCPP_WARN(this->get_logger(), "set_electric_current is not supported by linkerhand-cpp-sdk v2.1.7"); } // enable @@ -237,9 +274,14 @@ class LinkerHand : public rclcpp::Node // Ensure the release of resources in the destructor hand_api.reset(); - pub_touch_thread.join(); - pub_state_thread.join(); - pub_info_thread.join(); + if (pub_touch_thread.joinable()) pub_touch_thread.join(); + if (pub_state_thread.joinable()) pub_state_thread.join(); + if (pub_info_thread.joinable()) pub_info_thread.join(); + } + + bool isActive() const + { + return hand_api != nullptr; } // General function: Publish touch sensor data @@ -250,9 +292,11 @@ class LinkerHand : public rclcpp::Node const auto& touch = hand->getForce(); - for (const auto& vec : touch) { - for (uint8_t byte : vec) { - message.data.push_back(static_cast(byte)); + for (const auto& finger_data : touch) { + for (const auto& sensor_row : finger_data) { + for (uint8_t byte : sensor_row) { + message.data.push_back(static_cast(byte)); + } } } publisher.publish(message); @@ -276,7 +320,7 @@ class LinkerHand : public rclcpp::Node void publishLinkerHandInfo(const HandType& hand, rclcpp::Publisher& publisher) { auto message = std_msgs::msg::String(); - message.data = hand->getVersion() + "Temperature: " + vectorToString(hand->getTemperature()) + "\nFaultCode: " + vectorToString(hand->getFaultCode()) + "\nCurrent: " + vectorToString(hand->getCurrent()); + message.data = hand->getVersion() + "Temperature: " + vectorToString(hand->getTemperature()) + "\nFaultCode: " + vectorToString(hand->getFaultCode()); publisher.publish(message); @@ -306,45 +350,39 @@ class LinkerHand : public rclcpp::Node std::vector speed(5, hand_speed); std::vector effort(5, hand_effort); - if (hand_name == "L7" && msg->position.size() == 7) - { - (msg->velocity.size() == 7) ? speed = convert(msg->velocity) : speed = std::vector(7, hand_speed); - (msg->effort.size() == 7) ? effort = convert(msg->effort) : effort = std::vector(7, hand_effort); - - hand->setSpeed(speed); // speed - hand->setTorque(effort); // torque - (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); // joint position + const auto expected_joint_count = getJointCount(hand_name); + if (expected_joint_count == 0 || msg->position.size() != expected_joint_count) { + std::cout << hand_name << " Invalid joint number: " << msg->position.size() << std::endl; + return; } - else if (hand_name == "L10" && msg->position.size() == 10) - { - if (msg->velocity.size() == 5) speed = convert(msg->velocity); - hand->setSpeed(speed); // speed - hand->setTorque(effort); // torque - (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); // joint position + if (msg->velocity.size() == expected_joint_count) { + speed = convert(msg->velocity); + } else { + speed = std::vector(expected_joint_count, hand_speed); } - else if (hand_name == "L20" && msg->position.size() == 20) - { - if (msg->velocity.size() == 5) speed = convert(msg->velocity); - - hand->setSpeed(speed); // speed - // hand->setTorque({100, 100, 100, 100, 100}); // torque - (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); // joint position + if (msg->effort.size() == expected_joint_count) { + effort = convert(msg->effort); + } else { + effort = std::vector(expected_joint_count, hand_effort); } - else if ((hand_name == "L25" || hand_name == "L21") && msg->position.size() == 25) - { - (msg->velocity.size() == 25) ? speed = convert(msg->velocity) : speed = std::vector(25, hand_speed); - (msg->effort.size() == 25) ? effort = convert(msg->effort) : effort = std::vector(25, hand_effort); - hand->setSpeed(speed); // speed - hand->setTorque(effort); // torque - (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); // joint position - } - else - { - std::cout << hand_name << " Invalid joint number: " << msg->position.size() << std::endl; - return; - } + hand->setSpeed(speed); + hand->setTorque(effort); + (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); + } + + size_t getJointCount(const std::string& hand_name) const + { + if (hand_name == "L6" || hand_name == "O6") return 6; + if (hand_name == "L7") return 7; + if (hand_name == "L10") return 10; + if (hand_name == "L20") return 20; + if (hand_name == "L21") return 21; + if (hand_name == "L25") return 25; + if (hand_name == "G20") return 16; + if (hand_name == "O20") return 34; + return 0; } // std::vector to std::string @@ -392,6 +430,8 @@ class LinkerHand : public rclcpp::Node int hand_speed; int hand_effort; + int can_bitrate; + std::shared_ptr can_bus; // 创建线程对象 std::thread pub_touch_thread; @@ -431,6 +471,11 @@ int main(int argc, char *argv[]) std::signal(SIGINT, signalHandler); auto node = std::make_shared(); + if (!node->isActive()) { + RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "Hand is disabled or failed to initialize. Exiting."); + rclcpp::shutdown(); + return 0; + } rclcpp::spin(node); return 0; diff --git a/src/linker_hand_cpp_ros2/CMakeLists.txt b/src/linker_hand_cpp_ros2/CMakeLists.txt index d815ad6..cc3ff1b 100644 --- a/src/linker_hand_cpp_ros2/CMakeLists.txt +++ b/src/linker_hand_cpp_ros2/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.5) +cmake_minimum_required(VERSION 3.10) project(linker_hand_cpp_ros2) # Default to C99 @@ -6,69 +6,24 @@ if(NOT CMAKE_C_STANDARD) set(CMAKE_C_STANDARD 99) endif() -# Default to C++14 +# Default to C++17 if(NOT CMAKE_CXX_STANDARD) - set(CMAKE_CXX_STANDARD 14) + set(CMAKE_CXX_STANDARD 17) endif() +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") add_compile_options(-Wall -Wextra -Wpedantic) endif() -if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64") - set(LIB_SUBDIR "x86_64") -elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "aarch64|arm64") - set(LIB_SUBDIR "aarch64") -else() - message(WARNING "Unknown architecture, defaulting to x86_64") - set(LIB_SUBDIR "x86_64") -endif() - # find dependencies find_package(ament_cmake REQUIRED) find_package(rclcpp REQUIRED) find_package(std_msgs REQUIRED) find_package(sensor_msgs REQUIRED) - -#----------------------------------------------------------------------------- -# LINKER_HAND_CPP_SDK -#----------------------------------------------------------------------------- -find_library(LINKER_HAND_LIB - NAMES linkerhand_cpp_sdk linkerhand_cpp - PATHS ${CMAKE_CURRENT_SOURCE_DIR}/lib/${LIB_SUBDIR} - /usr/local/lib/linkerhand-cpp-sdk/${LIB_SUBDIR} - /usr/lib/linkerhand-cpp-sdk/${LIB_SUBDIR} - ${CMAKE_INSTALL_PREFIX}/lib/linkerhand-cpp-sdk/${LIB_SUBDIR} - NO_DEFAULT_PATH -) - -set(LINKER_HAND_INCLUDE_DIR - ${CMAKE_CURRENT_SOURCE_DIR}/include - /usr/local/include/linkerhand-cpp-sdk - /usr/include/linkerhand-cpp-sdk - ${CMAKE_INSTALL_PREFIX}/include/linkerhand-cpp-sdk -) - -if(NOT LINKER_HAND_LIB) - message(FATAL_ERROR "linkerhand_cpp_sdk library not found!") -endif() - -if(NOT LINKER_HAND_INCLUDE_DIR) - message(FATAL_ERROR "LinkerHand headers not found!") -endif() - -message(STATUS "Found linkerhand_cpp_sdk library: ${LINKER_HAND_LIB}") -message(STATUS "Found LinkerHand headers: ${LINKER_HAND_INCLUDE_DIR}") - -#----------------------------------------------------------------------------- -# INCLUDE_DIRECTORIES -#----------------------------------------------------------------------------- -include_directories( - ${CMAKE_CURRENT_SOURCE_DIR}/include - ${LINKER_HAND_INCLUDE_DIR} -) - -# ---------------------------------------------------------------------- +find_package(nlohmann_json REQUIRED) +find_package(linkerhand-cpp-sdk 2.0 CONFIG REQUIRED) add_executable(linker_hand_node src/linker_hand_node.cpp) target_include_directories(linker_hand_node PUBLIC @@ -76,8 +31,7 @@ target_include_directories(linker_hand_node PUBLIC $) ament_target_dependencies(linker_hand_node rclcpp std_msgs sensor_msgs) -# 链接库 -target_link_libraries(linker_hand_node ${LINKER_HAND_LIB} pthread) +target_link_libraries(linker_hand_node LinkerHand::linkerhand_cpp_sdk nlohmann_json::nlohmann_json) install(TARGETS linker_hand_node @@ -96,7 +50,8 @@ install (DIRECTORY launch # ) set_target_properties(linker_hand_node PROPERTIES - INSTALL_RPATH "/usr/local/linker_hand_cpp_sdk/third_party/Robotic_Arm/lib" + BUILD_RPATH "${linkerhand-cpp-sdk_LIBRARY_DIR}" + INSTALL_RPATH "${linkerhand-cpp-sdk_LIBRARY_DIR}" ) if(BUILD_TESTING) diff --git a/src/linker_hand_cpp_ros2/launch/run.xml b/src/linker_hand_cpp_ros2/launch/run.xml index dc38de6..0614d1a 100644 --- a/src/linker_hand_cpp_ros2/launch/run.xml +++ b/src/linker_hand_cpp_ros2/launch/run.xml @@ -1,23 +1,24 @@ - + - + - + + @@ -31,6 +32,7 @@ + @@ -51,6 +53,7 @@ + diff --git a/src/linker_hand_cpp_ros2/launch/run_left.xml b/src/linker_hand_cpp_ros2/launch/run_left.xml new file mode 100644 index 0000000..303b702 --- /dev/null +++ b/src/linker_hand_cpp_ros2/launch/run_left.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/linker_hand_cpp_ros2/package.xml b/src/linker_hand_cpp_ros2/package.xml index 8361c57..9bfded5 100644 --- a/src/linker_hand_cpp_ros2/package.xml +++ b/src/linker_hand_cpp_ros2/package.xml @@ -11,6 +11,7 @@ ament_lint_auto ament_lint_common + nlohmann-json-dev ament_cmake diff --git a/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp b/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp index f7485cb..d218301 100644 --- a/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp +++ b/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp @@ -13,7 +13,10 @@ #include #include #include +#include +#include #include +#include #include #include @@ -23,6 +26,7 @@ #include #include "LinkerHandApi.h" +#include "CommFactory.h" using namespace std::chrono_literals; @@ -65,6 +69,7 @@ class LinkerHand : public rclcpp::Node this->declare_parameter("HAND_SPEED", 50); this->declare_parameter("HAND_EFFORT", 200); this->declare_parameter("CAN_CHANNEL", "can0"); + this->declare_parameter("CAN_BITRATE", 1000000); this->declare_parameter("HAND_SETTING_TOPIC", "/cb_hand_setting_cmd"); this->declare_parameter("HAND_CONTROL_TOPIC", "/cb_hand_control_cmd"); @@ -87,6 +92,7 @@ class LinkerHand : public rclcpp::Node this->get_parameter("HAND_SPEED", hand_speed); this->get_parameter("HAND_EFFORT", hand_effort); this->get_parameter("CAN_CHANNEL", can_channel); + this->get_parameter("CAN_BITRATE", can_bitrate); // std::cout << "LinkerHand SDK version: " << version << std::endl; std::cout << (hand_type == @@ -97,32 +103,88 @@ class LinkerHand : public rclcpp::Node // LinkerHandType mapping std::map linker_hand_map = { + {"L6", LINKER_HAND::L6}, {"L7", LINKER_HAND::L7}, {"L10", LINKER_HAND::L10}, {"L20", LINKER_HAND::L20}, {"L21", LINKER_HAND::L21}, - {"L25", LINKER_HAND::L25} + {"L25", LINKER_HAND::L25}, + {"O6", LINKER_HAND::O6}, + {"O20", LINKER_HAND::O20}, + {"G20", LINKER_HAND::G20} }; - COMM_TYPE channel; - - (can_channel == "can0") ? channel = COMM_TYPE::COMM_CAN_0 : channel = COMM_TYPE::COMM_CAN_1; - if (hand_exists) { auto it = linker_hand_map.find(hand_joints); if (it != linker_hand_map.end()) { + if (it->second == LINKER_HAND::O20) { + RCLCPP_ERROR(this->get_logger(), + "O20 requires CAN-FD and is not supported by this CAN-only ROS2 node yet"); + return; + } + if (hand_type == 0) { - hand_api = std::make_unique(it->second, HAND_TYPE::LEFT, channel); + side = HAND_TYPE::LEFT; } else if (hand_type == 1) { - hand_api = std::make_unique(it->second, HAND_TYPE::RIGHT, channel); + side = HAND_TYPE::RIGHT; } else { std::cout << "Invalid hand_type: " << hand_type << std::endl; + return; + } + try { + hand_api = std::make_unique(it->second, side, COMM_TYPE::CAN); + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "Failed to create LinkerHandApi: %s", e.what()); + return; } } else { std::cout << "Invalid hand: " << hand_joints << std::endl; + return; } } + try { + can_bus = std::shared_ptr( + Communication::CommFactory::createCanBus(side)); + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "Failed to create CAN bus: %s", e.what()); + RCLCPP_ERROR(this->get_logger(), + "Start CAN first, for example: sudo ip link set can0 up type can bitrate %d && " + "sudo ip link set can0 txqueuelen 1024", + can_bitrate); + return; + } + + hand_api->setCanTxCallback([this](uint32_t can_id, const uint8_t * data, + uintptr_t data_len) -> int32_t { + try { + std::vector data_vec(data, data + data_len); + can_bus->send(data_vec, can_id); + return 0; + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "CAN send error: %s", e.what()); + return -1; + } + }); + + hand_api->setCanRxCallback([this](uint32_t * can_id_out, uint8_t * data_out, + uint8_t * len_out) -> int32_t { + try { + auto frame = can_bus->recv(); + if (frame.can_id == 0 && frame.can_dlc == 0) { + return -1; + } + *can_id_out = frame.can_id; + *len_out = frame.can_dlc; + std::memcpy(data_out, frame.data, frame.can_dlc); + return 0; + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "CAN receive error: %s", e.what()); + return -1; + } + }); + node_active = true; + if (hand_exists) {initHand(hand_api, hand_joints);} auto hand_control_cmd_cb = [this](sensor_msgs::msg::JointState::SharedPtr msg) -> void @@ -160,11 +222,9 @@ class LinkerHand : public rclcpp::Node hand_api->clearFaultCode(); } - // setCurrent if (data["setting_cmd"] == "set_electric_current") { - int tmp = data["params"]["electric_current"].get(); - std::vector e_c(5, tmp); - hand_api->setCurrent(e_c); + RCLCPP_WARN(this->get_logger(), + "set_electric_current is not supported by linkerhand-cpp-sdk v2.1.7"); } // enable @@ -242,9 +302,14 @@ class LinkerHand : public rclcpp::Node // Ensure the release of resources in the destructor hand_api.reset(); - pub_touch_thread.join(); - pub_state_thread.join(); - pub_info_thread.join(); + if (pub_touch_thread.joinable()) {pub_touch_thread.join();} + if (pub_state_thread.joinable()) {pub_state_thread.join();} + if (pub_info_thread.joinable()) {pub_info_thread.join();} + } + + bool isActive() const + { + return node_active; } // General function: Publish touch sensor data @@ -267,6 +332,7 @@ class LinkerHand : public rclcpp::Node } } } + publisher.publish(message); } // General function: public joint state @@ -293,8 +359,7 @@ class LinkerHand : public rclcpp::Node { auto message = std_msgs::msg::String(); message.data = hand->getVersion() + "Temperature: " + vectorToString(hand->getTemperature()) + - "\nFaultCode: " + vectorToString(hand->getFaultCode()) + "\nCurrent: " + - vectorToString(hand->getCurrent()); + "\nFaultCode: " + vectorToString(hand->getFaultCode()); publisher.publish(message); @@ -327,44 +392,40 @@ class LinkerHand : public rclcpp::Node std::vector speed(5, hand_speed); std::vector effort(5, hand_effort); - if (hand_name == "L7" && msg->position.size() == 7) { - (msg->velocity.size() == 7) ? speed = convert(msg->velocity) : speed = std::vector(7, hand_speed); - (msg->effort.size() == 7) ? effort = convert(msg->effort) : effort = std::vector(7, hand_effort); - - hand->setSpeed(speed); // speed - hand->setTorque(effort); // torque - (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); // joint position - } else if (hand_name == "L10" && msg->position.size() == 10) { - if (msg->velocity.size() == 5) {speed = convert(msg->velocity);} - - hand->setSpeed(speed); // speed - hand->setTorque(effort); // torque - (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); // joint position - } else if (hand_name == "L20" && msg->position.size() == 20) { - if (msg->velocity.size() == 5) {speed = convert(msg->velocity);} - - hand->setSpeed(speed); // speed - // hand->setTorque({100, 100, 100, 100, 100}); // torque - (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); // joint position - } else if ((hand_name == "L25" || hand_name == "L21") && msg->position.size() == 25) { - (msg->velocity.size() == 25) ? speed = convert(msg->velocity) : speed = std::vector(25, hand_speed); - (msg->effort.size() == 25) ? effort = convert(msg->effort) : effort = std::vector(25, hand_effort); - - hand->setSpeed(speed); // speed - hand->setTorque(effort); // torque - (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); // joint position - } else { + const auto expected_joint_count = getJointCount(hand_name); + if (expected_joint_count == 0 || msg->position.size() != expected_joint_count) { std::cout << hand_name << " Invalid joint number: " << msg->position.size() << std::endl; return; } + + if (msg->velocity.size() == expected_joint_count) { + speed = convert(msg->velocity); + } else { + speed = std::vector(expected_joint_count, hand_speed); + } + if (msg->effort.size() == expected_joint_count) { + effort = convert(msg->effort); + } else { + effort = std::vector(expected_joint_count, hand_effort); + } + + hand->setSpeed(speed); + hand->setTorque(effort); + (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); + } + + size_t getJointCount(const std::string & hand_name) const + { + if (hand_name == "L6" || hand_name == "O6") {return 6;} + if (hand_name == "L7") {return 7;} + if (hand_name == "L10") {return 10;} + if (hand_name == "L20") {return 20;} + if (hand_name == "L21") {return 21;} + if (hand_name == "L25") {return 25;} + if (hand_name == "G20") {return 16;} + if (hand_name == "O20") {return 34;} + return 0; } // std::vector to std::string @@ -408,10 +469,14 @@ class LinkerHand : public rclcpp::Node std::string version; bool hand_exists; bool hand_touch; + bool node_active{false}; std::string hand_joints; int hand_speed; int hand_effort; + int can_bitrate; + HAND_TYPE side; + std::shared_ptr can_bus; // 创建线程对象 std::thread pub_touch_thread; @@ -433,13 +498,6 @@ class LinkerHand : public rclcpp::Node void signalHandler(int signal) { if (signal == SIGINT) { - // stop can channel - int result = system(std::string("sudo ip link set " + can_channel + " down").c_str()); - if (result == 0) { - std::cout << ((can_channel == - "can0") ? "CAN0" : "CAN1") << " interface stopped successfully." << std::endl; - } - RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "Ctrl+C detected. Shutting down..."); rclcpp::shutdown(); } @@ -453,6 +511,11 @@ int main(int argc, char *argv[]) std::signal(SIGINT, signalHandler); auto node = std::make_shared(); + if (!node->isActive()) { + RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "Hand is disabled or failed to initialize. Exiting."); + rclcpp::shutdown(); + return 0; + } rclcpp::spin(node); return 0; From e3a96d0ea401ccf78e0f5385a09517972fc1408e Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Mon, 15 Jun 2026 15:51:44 +0800 Subject: [PATCH 03/19] feat: add control GUI with model configuration --- src/linker_hand_cpp_ros2/CMakeLists.txt | 5 + src/linker_hand_cpp_ros2/package.xml | 1 + .../scripts/left_hand_control_gui.py | 329 ++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100755 src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py diff --git a/src/linker_hand_cpp_ros2/CMakeLists.txt b/src/linker_hand_cpp_ros2/CMakeLists.txt index cc3ff1b..9293c39 100644 --- a/src/linker_hand_cpp_ros2/CMakeLists.txt +++ b/src/linker_hand_cpp_ros2/CMakeLists.txt @@ -37,6 +37,11 @@ target_link_libraries(linker_hand_node LinkerHand::linkerhand_cpp_sdk nlohmann_j install(TARGETS linker_hand_node DESTINATION lib/${PROJECT_NAME}) +install(PROGRAMS + scripts/left_hand_control_gui.py + DESTINATION lib/${PROJECT_NAME} +) + install (DIRECTORY launch DESTINATION share/${PROJECT_NAME} ) diff --git a/src/linker_hand_cpp_ros2/package.xml b/src/linker_hand_cpp_ros2/package.xml index 9bfded5..aefadfe 100644 --- a/src/linker_hand_cpp_ros2/package.xml +++ b/src/linker_hand_cpp_ros2/package.xml @@ -12,6 +12,7 @@ ament_lint_auto ament_lint_common nlohmann-json-dev + rclpy ament_cmake diff --git a/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py b/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py new file mode 100755 index 0000000..7cb22bd --- /dev/null +++ b/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +import argparse +import threading +import tkinter as tk +from tkinter import ttk + +import rclpy +from rclpy.node import Node +from sensor_msgs.msg import JointState + + +JOINT_COUNTS = { + "L6": 6, + "O6": 6, + "L7": 7, + "L10": 10, + "L20": 20, + "L21": 21, + "L25": 25, + "G20": 16, + "O20": 34, +} + + +class HandControlPublisher(Node): + def __init__(self, topic): + super().__init__("hand_control_gui") + self.topic = None + self.publisher = None + self.set_topic(topic) + + def set_topic(self, topic): + if topic == self.topic: + return + if self.publisher is not None: + self.destroy_publisher(self.publisher) + self.topic = topic + self.publisher = self.create_publisher(JointState, topic, 10) + + def publish_command(self, positions, speeds, torques): + msg = JointState() + msg.header.stamp = self.get_clock().now().to_msg() + msg.position = [float(v) for v in positions] + msg.velocity = [float(v) for v in speeds] + msg.effort = [float(v) for v in torques] + self.publisher.publish(msg) + + +class HandControlGui: + def __init__(self, node, model, side, topic, live_publish, publish_rate_hz): + self.node = node + self.closed = False + + self.root = tk.Tk() + self.root.title("LinkerHand Control") + self.root.protocol("WM_DELETE_WINDOW", self.close) + self.root.minsize(820, 520) + + self.model_var = tk.StringVar(value=model) + self.side_var = tk.StringVar(value=side) + self.topic_var = tk.StringVar(value=topic) + self.live_var = tk.BooleanVar(value=live_publish) + self.rate_var = tk.IntVar(value=max(1, int(publish_rate_hz))) + self.joint_count = JOINT_COUNTS[model] + + self.main = None + self.table_container = None + self.position_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] + self.speed_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] + self.torque_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] + + self._build() + self.publish_loop() + + def _build(self): + self.main = ttk.Frame(self.root, padding=12) + self.main.grid(row=0, column=0, sticky="nsew") + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(0, weight=1) + + self._build_config(self.main) + self._build_joint_table() + self._build_controls(self.main) + + self.status = tk.StringVar(value="Ready") + ttk.Label(self.main, textvariable=self.status).grid(row=3, column=0, sticky="w", pady=(8, 0)) + + def _build_config(self, parent): + config = ttk.Frame(parent) + config.grid(row=0, column=0, sticky="ew", pady=(0, 8)) + config.columnconfigure(5, weight=1) + + ttk.Label(config, text="Side").grid(row=0, column=0, padx=(0, 4), sticky="w") + side_box = ttk.Combobox( + config, + textvariable=self.side_var, + values=("left", "right"), + state="readonly", + width=7, + ) + side_box.grid(row=0, column=1, padx=(0, 10), sticky="w") + side_box.bind("<>", lambda _event: self.apply_side_topic()) + + ttk.Label(config, text="Model").grid(row=0, column=2, padx=(0, 4), sticky="w") + model_box = ttk.Combobox( + config, + textvariable=self.model_var, + values=sorted(JOINT_COUNTS), + state="readonly", + width=7, + ) + model_box.grid(row=0, column=3, padx=(0, 10), sticky="w") + model_box.bind("<>", lambda _event: self.apply_model()) + + ttk.Label(config, text="Rate").grid(row=0, column=4, padx=(0, 4), sticky="w") + ttk.Spinbox(config, from_=1, to=200, width=5, textvariable=self.rate_var).grid( + row=0, column=5, sticky="w" + ) + ttk.Label(config, text="Hz").grid(row=0, column=6, padx=(4, 10), sticky="w") + ttk.Checkbutton(config, text="Live", variable=self.live_var).grid( + row=0, column=7, padx=(0, 10), sticky="w" + ) + + ttk.Label(config, text="Topic").grid(row=1, column=0, padx=(0, 4), pady=(6, 0), sticky="w") + topic_entry = ttk.Entry(config, textvariable=self.topic_var) + topic_entry.grid(row=1, column=1, columnspan=6, pady=(6, 0), sticky="ew") + ttk.Button(config, text="Apply", command=self.apply_topic).grid( + row=1, column=7, padx=(0, 10), pady=(6, 0), sticky="w" + ) + + self.summary = tk.StringVar() + ttk.Label(config, textvariable=self.summary).grid( + row=2, column=0, columnspan=8, pady=(6, 0), sticky="w" + ) + self.update_summary() + + def _build_joint_table(self): + if self.table_container is not None: + self.table_container.destroy() + + columns = ("joint", "position", "speed", "torque") + table = self._create_scrollable_table(self.main) + self.main.rowconfigure(1, weight=1) + for col in range(4): + table.columnconfigure(col, weight=1 if col else 0) + + for col, text in enumerate(columns): + ttk.Label(table, text=text).grid(row=0, column=col, padx=4, pady=4) + + for index in range(self.joint_count): + row = index + 1 + ttk.Label(table, text=f"J{index + 1}").grid(row=row, column=0, padx=4, sticky="w") + self._add_slider(table, row, 1, self.position_vars[index]) + self._add_slider(table, row, 2, self.speed_vars[index]) + self._add_slider(table, row, 3, self.torque_vars[index]) + + def _build_controls(self, parent): + controls = ttk.Frame(parent) + controls.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + controls.columnconfigure(5, weight=1) + + ttk.Button(controls, text="Open", command=lambda: self.set_all_positions(255)).grid( + row=0, column=0, padx=3 + ) + ttk.Button(controls, text="Half", command=lambda: self.set_all_positions(128)).grid( + row=0, column=1, padx=3 + ) + ttk.Button(controls, text="Close", command=lambda: self.set_all_positions(0)).grid( + row=0, column=2, padx=3 + ) + ttk.Button(controls, text="Publish", command=self.publish).grid(row=0, column=3, padx=3) + ttk.Button(controls, text="Quit", command=self.close).grid(row=0, column=4, padx=3) + + def _create_scrollable_table(self, parent): + self.table_container = ttk.Frame(parent) + self.table_container.grid(row=1, column=0, sticky="nsew") + self.table_container.columnconfigure(0, weight=1) + self.table_container.rowconfigure(0, weight=1) + + canvas = tk.Canvas(self.table_container, highlightthickness=0) + scrollbar = ttk.Scrollbar(self.table_container, orient="vertical", command=canvas.yview) + table = ttk.Frame(canvas) + window_id = canvas.create_window((0, 0), window=table, anchor="nw") + + table.bind( + "", + lambda _event: canvas.configure(scrollregion=canvas.bbox("all")), + ) + canvas.bind( + "", + lambda event: canvas.itemconfigure(window_id, width=event.width), + ) + canvas.configure(yscrollcommand=scrollbar.set) + canvas.grid(row=0, column=0, sticky="nsew") + scrollbar.grid(row=0, column=1, sticky="ns") + return table + + def _add_slider(self, parent, row, column, variable): + frame = ttk.Frame(parent) + frame.grid(row=row, column=column, padx=4, pady=2, sticky="ew") + frame.columnconfigure(0, weight=1) + scale = ttk.Scale( + frame, + from_=0, + to=255, + orient="horizontal", + variable=variable, + ) + scale.grid(row=0, column=0, sticky="ew") + spin = ttk.Spinbox( + frame, + from_=0, + to=255, + width=4, + textvariable=variable, + ) + spin.grid(row=0, column=1, padx=(4, 0)) + + def apply_side_topic(self): + self.topic_var.set(f"/{self.side_var.get()}_hand_control") + self.apply_topic() + + def apply_topic(self): + topic = self.topic_var.get().strip() + if not topic.startswith("/"): + topic = f"/{topic}" + self.topic_var.set(topic) + self.node.set_topic(topic) + self.update_summary() + + def apply_model(self): + old_positions = [var.get() for var in self.position_vars] + old_speeds = [var.get() for var in self.speed_vars] + old_torques = [var.get() for var in self.torque_vars] + self.joint_count = JOINT_COUNTS[self.model_var.get()] + self.position_vars = self.resize_vars(old_positions, 255) + self.speed_vars = self.resize_vars(old_speeds, 255) + self.torque_vars = self.resize_vars(old_torques, 255) + self._build_joint_table() + self.update_summary() + + def resize_vars(self, values, default): + resized = [] + for index in range(self.joint_count): + value = values[index] if index < len(values) else default + resized.append(tk.IntVar(value=value)) + return resized + + def update_summary(self): + self.summary.set( + f"{self.side_var.get()} {self.model_var.get()} | " + f"{self.joint_count} joints | {self.topic_var.get()}" + ) + + def set_all_positions(self, value): + for var in self.position_vars: + var.set(value) + self.publish() + + def publish(self): + positions = [var.get() for var in self.position_vars] + speeds = [var.get() for var in self.speed_vars] + torques = [var.get() for var in self.torque_vars] + self.node.publish_command(positions, speeds, torques) + self.status.set( + f"Published position={positions} speed={speeds} torque={torques}" + ) + + def publish_loop(self): + if self.closed: + return + if self.live_var.get(): + self.publish() + period_ms = max(1, round(1000 / max(1, self.rate_var.get()))) + self.root.after(period_ms, self.publish_loop) + + def run(self): + self.root.mainloop() + + def close(self): + if self.closed: + return + self.closed = True + self.root.quit() + self.root.destroy() + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("--side", default="left", choices=("left", "right")) + parser.add_argument("--topic", default=None) + parser.add_argument("--model", default="O6", choices=sorted(JOINT_COUNTS)) + parser.add_argument( + "--no-live", + action="store_true", + help="only publish when pressing Publish/Open/Half/Close", + ) + parser.add_argument("--rate", type=int, default=50, help="live publish rate in Hz") + return parser.parse_args() + + +def main(): + args = parse_args() + if args.rate <= 0: + raise SystemExit("--rate must be greater than 0") + topic = args.topic or f"/{args.side}_hand_control" + + rclpy.init() + node = HandControlPublisher(topic) + spin_thread = threading.Thread(target=rclpy.spin, args=(node,), daemon=True) + spin_thread.start() + + gui = HandControlGui( + node=node, + model=args.model, + side=args.side, + topic=topic, + live_publish=not args.no_live, + publish_rate_hz=args.rate, + ) + try: + gui.run() + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() From 29dcaafc9f9a6237dd56987e94b9571bac2bf42f Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Mon, 15 Jun 2026 15:52:15 +0800 Subject: [PATCH 04/19] feat: show feedback state and touch heatmaps --- .../scripts/left_hand_control_gui.py | 301 ++++++++++++++++-- 1 file changed, 282 insertions(+), 19 deletions(-) diff --git a/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py b/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py index 7cb22bd..ab564a2 100755 --- a/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py +++ b/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py @@ -7,6 +7,8 @@ import rclpy from rclpy.node import Node from sensor_msgs.msg import JointState +from std_msgs.msg import Float32MultiArray +from std_msgs.msg import String JOINT_COUNTS = { @@ -21,12 +23,24 @@ "O20": 34, } +FINGER_NAMES = ("Thumb", "Index", "Middle", "Ring", "Little") + class HandControlPublisher(Node): def __init__(self, topic): super().__init__("hand_control_gui") self.topic = None self.publisher = None + self.state_topic = None + self.touch_topic = None + self.info_topic = None + self.state_subscription = None + self.touch_subscription = None + self.info_subscription = None + self.latest_state = None + self.latest_touch = [] + self.latest_info = "" + self.data_lock = threading.Lock() self.set_topic(topic) def set_topic(self, topic): @@ -37,6 +51,45 @@ def set_topic(self, topic): self.topic = topic self.publisher = self.create_publisher(JointState, topic, 10) + def set_feedback_topics(self, state_topic, touch_topic, info_topic): + if state_topic != self.state_topic: + if self.state_subscription is not None: + self.destroy_subscription(self.state_subscription) + self.state_topic = state_topic + self.state_subscription = self.create_subscription( + JointState, state_topic, self.state_callback, 10 + ) + if touch_topic != self.touch_topic: + if self.touch_subscription is not None: + self.destroy_subscription(self.touch_subscription) + self.touch_topic = touch_topic + self.touch_subscription = self.create_subscription( + Float32MultiArray, touch_topic, self.touch_callback, 10 + ) + if info_topic != self.info_topic: + if self.info_subscription is not None: + self.destroy_subscription(self.info_subscription) + self.info_topic = info_topic + self.info_subscription = self.create_subscription( + String, info_topic, self.info_callback, 10 + ) + + def state_callback(self, msg): + with self.data_lock: + self.latest_state = msg + + def touch_callback(self, msg): + with self.data_lock: + self.latest_touch = list(msg.data) + + def info_callback(self, msg): + with self.data_lock: + self.latest_info = msg.data + + def get_feedback(self): + with self.data_lock: + return self.latest_state, list(self.latest_touch), self.latest_info + def publish_command(self, positions, speeds, torques): msg = JointState() msg.header.stamp = self.get_clock().now().to_msg() @@ -52,41 +105,73 @@ def __init__(self, node, model, side, topic, live_publish, publish_rate_hz): self.closed = False self.root = tk.Tk() - self.root.title("LinkerHand Control") + self.root.title("LinkerHand Control Panel") self.root.protocol("WM_DELETE_WINDOW", self.close) - self.root.minsize(820, 520) + self.root.minsize(980, 720) + self.configure_style() self.model_var = tk.StringVar(value=model) self.side_var = tk.StringVar(value=side) self.topic_var = tk.StringVar(value=topic) + self.state_topic_var = tk.StringVar() + self.touch_topic_var = tk.StringVar() + self.info_topic_var = tk.StringVar() self.live_var = tk.BooleanVar(value=live_publish) self.rate_var = tk.IntVar(value=max(1, int(publish_rate_hz))) self.joint_count = JOINT_COUNTS[model] self.main = None self.table_container = None + self.feedback_container = None self.position_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] self.speed_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] self.torque_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] + self.state_vars = { + "position": tk.StringVar(value="-"), + "speed": tk.StringVar(value="-"), + "torque": tk.StringVar(value="-"), + } + self.info_vars = [] + self.touch_labels = [] + self.touch_canvases = [] + self.latest_touch_values = [] + self.update_feedback_topics() self._build() self.publish_loop() + self.refresh_feedback() def _build(self): - self.main = ttk.Frame(self.root, padding=12) + self.main = ttk.Frame(self.root, padding=10) self.main.grid(row=0, column=0, sticky="nsew") self.root.columnconfigure(0, weight=1) self.root.rowconfigure(0, weight=1) + self.main.columnconfigure(0, weight=1) + self.main.rowconfigure(1, weight=1) self._build_config(self.main) self._build_joint_table() + self._build_feedback(self.main) self._build_controls(self.main) self.status = tk.StringVar(value="Ready") - ttk.Label(self.main, textvariable=self.status).grid(row=3, column=0, sticky="w", pady=(8, 0)) + ttk.Label(self.main, textvariable=self.status, style="Status.TLabel").grid( + row=4, column=0, sticky="ew", pady=(8, 0) + ) + + def configure_style(self): + style = ttk.Style(self.root) + try: + style.theme_use("clam") + except tk.TclError: + pass + style.configure("TLabelframe", padding=8) + style.configure("TLabelframe.Label", font=("TkDefaultFont", 10, "bold")) + style.configure("Header.TLabel", font=("TkDefaultFont", 10, "bold")) + style.configure("Status.TLabel", foreground="#555555") def _build_config(self, parent): - config = ttk.Frame(parent) + config = ttk.LabelFrame(parent, text="Configuration") config.grid(row=0, column=0, sticky="ew", pady=(0, 8)) config.columnconfigure(5, weight=1) @@ -129,7 +214,7 @@ def _build_config(self, parent): ) self.summary = tk.StringVar() - ttk.Label(config, textvariable=self.summary).grid( + ttk.Label(config, textvariable=self.summary, style="Status.TLabel").grid( row=2, column=0, columnspan=8, pady=(6, 0), sticky="w" ) self.update_summary() @@ -155,24 +240,28 @@ def _build_joint_table(self): self._add_slider(table, row, 3, self.torque_vars[index]) def _build_controls(self, parent): - controls = ttk.Frame(parent) - controls.grid(row=2, column=0, sticky="ew", pady=(10, 0)) + controls = ttk.LabelFrame(parent, text="Commands") + controls.grid(row=3, column=0, sticky="ew", pady=(10, 0)) controls.columnconfigure(5, weight=1) ttk.Button(controls, text="Open", command=lambda: self.set_all_positions(255)).grid( - row=0, column=0, padx=3 + row=0, column=0, padx=(0, 6), pady=2 ) ttk.Button(controls, text="Half", command=lambda: self.set_all_positions(128)).grid( - row=0, column=1, padx=3 + row=0, column=1, padx=6, pady=2 ) ttk.Button(controls, text="Close", command=lambda: self.set_all_positions(0)).grid( - row=0, column=2, padx=3 + row=0, column=2, padx=6, pady=2 + ) + ttk.Button(controls, text="Publish", command=self.publish).grid( + row=0, column=3, padx=6, pady=2 + ) + ttk.Button(controls, text="Quit", command=self.close).grid( + row=0, column=4, padx=6, pady=2 ) - ttk.Button(controls, text="Publish", command=self.publish).grid(row=0, column=3, padx=3) - ttk.Button(controls, text="Quit", command=self.close).grid(row=0, column=4, padx=3) def _create_scrollable_table(self, parent): - self.table_container = ttk.Frame(parent) + self.table_container = ttk.LabelFrame(parent, text="Command Values") self.table_container.grid(row=1, column=0, sticky="nsew") self.table_container.columnconfigure(0, weight=1) self.table_container.rowconfigure(0, weight=1) @@ -191,7 +280,7 @@ def _create_scrollable_table(self, parent): lambda event: canvas.itemconfigure(window_id, width=event.width), ) canvas.configure(yscrollcommand=scrollbar.set) - canvas.grid(row=0, column=0, sticky="nsew") + canvas.grid(row=0, column=0, sticky="nsew", padx=(0, 4)) scrollbar.grid(row=0, column=1, sticky="ns") return table @@ -218,6 +307,7 @@ def _add_slider(self, parent, row, column, variable): def apply_side_topic(self): self.topic_var.set(f"/{self.side_var.get()}_hand_control") + self.update_feedback_topics() self.apply_topic() def apply_topic(self): @@ -237,6 +327,7 @@ def apply_model(self): self.speed_vars = self.resize_vars(old_speeds, 255) self.torque_vars = self.resize_vars(old_torques, 255) self._build_joint_table() + self._build_feedback(self.main) self.update_summary() def resize_vars(self, values, default): @@ -249,7 +340,20 @@ def resize_vars(self, values, default): def update_summary(self): self.summary.set( f"{self.side_var.get()} {self.model_var.get()} | " - f"{self.joint_count} joints | {self.topic_var.get()}" + f"{self.joint_count} joints | {self.topic_var.get()} | " + f"{self.state_topic_var.get()} | {self.touch_topic_var.get()} | " + f"{self.info_topic_var.get()}" + ) + + def update_feedback_topics(self): + side = self.side_var.get() + self.state_topic_var.set(f"/{side}_hand_state") + self.touch_topic_var.set(f"/{side}_hand_touch") + self.info_topic_var.set(f"/{side}_hand_info") + self.node.set_feedback_topics( + self.state_topic_var.get(), + self.touch_topic_var.get(), + self.info_topic_var.get(), ) def set_all_positions(self, value): @@ -262,9 +366,7 @@ def publish(self): speeds = [var.get() for var in self.speed_vars] torques = [var.get() for var in self.torque_vars] self.node.publish_command(positions, speeds, torques) - self.status.set( - f"Published position={positions} speed={speeds} torque={torques}" - ) + self.status.set(f"Published {len(positions)} joints to {self.node.topic}") def publish_loop(self): if self.closed: @@ -274,6 +376,167 @@ def publish_loop(self): period_ms = max(1, round(1000 / max(1, self.rate_var.get()))) self.root.after(period_ms, self.publish_loop) + def _build_feedback(self, parent): + if self.feedback_container is not None: + self.feedback_container.destroy() + + self.feedback_container = ttk.LabelFrame(parent, text="Feedback") + self.feedback_container.grid(row=2, column=0, sticky="nsew", pady=(8, 0)) + self.feedback_container.columnconfigure(0, weight=3) + self.feedback_container.columnconfigure(1, weight=2) + self.feedback_container.rowconfigure(0, weight=1) + + state_frame = ttk.LabelFrame(self.feedback_container, text="State / Info") + state_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) + state_frame.columnconfigure(1, weight=1) + for row, (name, var) in enumerate(self.state_vars.items()): + ttk.Label(state_frame, text=name).grid(row=row, column=0, padx=4, pady=2, sticky="w") + ttk.Label(state_frame, textvariable=var, width=64).grid( + row=row, column=1, padx=4, pady=2, sticky="ew" + ) + + self.info_vars = [] + for index in range(6): + var = tk.StringVar(value="-") + self.info_vars.append(var) + row = len(self.state_vars) + index + ttk.Label(state_frame, text=f"info {index + 1}").grid( + row=row, column=0, padx=4, pady=2, sticky="w" + ) + ttk.Label(state_frame, textvariable=var, width=64).grid( + row=row, column=1, padx=4, pady=2, sticky="ew" + ) + + touch_frame = ttk.LabelFrame(self.feedback_container, text="Touch") + touch_frame.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) + touch_frame.rowconfigure(0, weight=1) + self.touch_canvases = [] + canvas_width, canvas_height = self.touch_canvas_size() + for finger in range(5): + touch_frame.columnconfigure(finger, weight=1) + finger_frame = ttk.LabelFrame(touch_frame, text=FINGER_NAMES[finger]) + finger_frame.grid(row=0, column=finger, padx=3, pady=3, sticky="nsew") + finger_frame.columnconfigure(0, weight=1) + finger_frame.rowconfigure(0, weight=1) + canvas = tk.Canvas( + finger_frame, + width=canvas_width, + height=canvas_height, + highlightthickness=0, + ) + canvas.grid(row=0, column=0, padx=2, pady=2, sticky="nsew") + canvas.bind("", lambda _event: self.redraw_touch_matrix()) + self.touch_canvases.append(canvas) + + def refresh_feedback(self): + if self.closed: + return + state, touch, info = self.node.get_feedback() + if state is not None: + self.state_vars["position"].set(self.format_values(state.position)) + self.state_vars["speed"].set(self.format_values(state.velocity)) + self.state_vars["torque"].set(self.format_values(state.effort)) + if info: + self.update_info_lines(info) + if touch: + self.update_touch_matrix(touch) + self.root.after(100, self.refresh_feedback) + + def update_info_lines(self, info): + lines = [part.strip() for part in info.splitlines() if part.strip()] + for index, var in enumerate(self.info_vars): + var.set(lines[index] if index < len(lines) else "-") + + def format_values(self, values): + return " ".join(str(int(value)) for value in values[: self.joint_count]) + + def update_touch_matrix(self, values): + self.latest_touch_values = list(values) + rows, cols = self.touch_shape() + cells_per_finger = rows * cols + for finger in range(5): + matrix = [] + for row in range(rows): + row_values = [] + for col in range(cols): + index = finger * cells_per_finger + row * cols + col + row_values.append(int(values[index]) if index < len(values) else 0) + matrix.append(row_values) + self.draw_heatmap(self.touch_canvases[finger], matrix) + + def redraw_touch_matrix(self): + if self.latest_touch_values: + self.update_touch_matrix(self.latest_touch_values) + + def touch_shape(self): + if self.model_var.get() == "O6": + return 10, 4 + return 12, 6 + + def display_shape(self): + return self.touch_shape() + + def touch_canvas_size(self): + if self.model_var.get() == "O6": + return 56, 120 + return 72, 144 + + def draw_heatmap(self, canvas, matrix): + display_rows, display_cols = self.display_shape() + width = max(1, canvas.winfo_width()) + height = max(1, canvas.winfo_height()) + padding = 4 + usable_width = max(1, width - padding * 2) + usable_height = max(1, height - padding * 2) + target_ratio = display_cols / display_rows + usable_ratio = usable_width / usable_height + if usable_ratio > target_ratio: + draw_height = usable_height + draw_width = draw_height * target_ratio + else: + draw_width = usable_width + draw_height = draw_width / target_ratio + x_offset = (width - draw_width) / 2 + y_offset = (height - draw_height) / 2 + cell_w = draw_width / display_cols + cell_h = draw_height / display_rows + canvas.delete("all") + for row in range(display_rows): + for col in range(display_cols): + value = self.display_value(matrix, row, col) + x1 = x_offset + col * cell_w + y1 = y_offset + row * cell_h + x2 = x1 + cell_w + y2 = y1 + cell_h + canvas.create_rectangle( + x1, + y1, + x2, + y2, + fill=self.heat_color(value), + outline="#333333", + width=1, + ) + + def display_value(self, matrix, display_row, display_col): + if display_row < len(matrix) and display_col < len(matrix[display_row]): + return matrix[display_row][display_col] + return 0 + + def heat_color(self, value): + value = max(0, min(255, int(value))) + if value <= 127: + ratio = value / 127.0 + red = int(255 * ratio) + green = int(80 + 175 * ratio) + blue = int(255 * (1 - ratio)) + else: + ratio = (value - 127) / 128.0 + red = 255 + green = int(255 * (1 - ratio)) + blue = 0 + return f"#{red:02x}{green:02x}{blue:02x}" + def run(self): self.root.mainloop() From f9783a4e4d9b238b6a099f57b2bc356a0a97d7d6 Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Mon, 15 Jun 2026 15:55:37 +0800 Subject: [PATCH 05/19] fix: deduplicate commands and auto-detect hand model --- .../scripts/left_hand_control_gui.py | 70 +++++++++++++++++-- .../src/linker_hand_node.cpp | 56 ++++++++++++--- 2 files changed, 112 insertions(+), 14 deletions(-) diff --git a/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py b/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py index ab564a2..4b5f138 100755 --- a/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py +++ b/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py @@ -6,6 +6,7 @@ import rclpy from rclpy.node import Node +from rclpy.parameter_client import AsyncParameterClient from sensor_msgs.msg import JointState from std_msgs.msg import Float32MultiArray from std_msgs.msg import String @@ -98,6 +99,22 @@ def publish_command(self, positions, speeds, torques): msg.effort = [float(v) for v in torques] self.publisher.publish(msg) + def get_remote_parameter(self, node_name, parameter_name, timeout_sec=1.0): + client = AsyncParameterClient(self, node_name) + wait_for_service = getattr(client, "wait_for_service", None) + if wait_for_service is None: + wait_for_service = client.wait_for_services + if not wait_for_service(timeout_sec=timeout_sec): + return None + future = client.get_parameters([parameter_name]) + rclpy.spin_until_future_complete(self, future, timeout_sec=timeout_sec) + if not future.done() or future.result() is None: + return None + values = future.result().values + if not values: + return None + return values[0].string_value + class HandControlGui: def __init__(self, node, model, side, topic, live_publish, publish_rate_hz): @@ -119,6 +136,7 @@ def __init__(self, node, model, side, topic, live_publish, publish_rate_hz): self.live_var = tk.BooleanVar(value=live_publish) self.rate_var = tk.IntVar(value=max(1, int(publish_rate_hz))) self.joint_count = JOINT_COUNTS[model] + self.command_dirty = False self.main = None self.table_container = None @@ -126,6 +144,7 @@ def __init__(self, node, model, side, topic, live_publish, publish_rate_hz): self.position_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] self.speed_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] self.torque_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] + self.watch_command_vars() self.state_vars = { "position": tk.StringVar(value="-"), "speed": tk.StringVar(value="-"), @@ -288,23 +307,38 @@ def _add_slider(self, parent, row, column, variable): frame = ttk.Frame(parent) frame.grid(row=row, column=column, padx=4, pady=2, sticky="ew") frame.columnconfigure(0, weight=1) + validate_int = (self.root.register(self.validate_byte_text), "%P") scale = ttk.Scale( frame, from_=0, to=255, orient="horizontal", - variable=variable, + command=lambda value, var=variable: self.set_int_value(var, value), ) + scale.set(variable.get()) scale.grid(row=0, column=0, sticky="ew") + variable.trace_add("write", lambda *_args, var=variable, widget=scale: widget.set(var.get())) spin = ttk.Spinbox( frame, from_=0, to=255, width=4, textvariable=variable, + validate="key", + validatecommand=validate_int, ) spin.grid(row=0, column=1, padx=(4, 0)) + def set_int_value(self, variable, value): + variable.set(max(0, min(255, int(round(float(value)))))) + + def validate_byte_text(self, text): + if text == "": + return True + if not text.isdigit(): + return False + return 0 <= int(text) <= 255 + def apply_side_topic(self): self.topic_var.set(f"/{self.side_var.get()}_hand_control") self.update_feedback_topics() @@ -326,9 +360,11 @@ def apply_model(self): self.position_vars = self.resize_vars(old_positions, 255) self.speed_vars = self.resize_vars(old_speeds, 255) self.torque_vars = self.resize_vars(old_torques, 255) + self.watch_command_vars() self._build_joint_table() self._build_feedback(self.main) self.update_summary() + self.command_dirty = False def resize_vars(self, values, default): resized = [] @@ -337,6 +373,13 @@ def resize_vars(self, values, default): resized.append(tk.IntVar(value=value)) return resized + def watch_command_vars(self): + for variable in self.position_vars + self.speed_vars + self.torque_vars: + variable.trace_add("write", self.mark_command_dirty) + + def mark_command_dirty(self, *_args): + self.command_dirty = True + def update_summary(self): self.summary.set( f"{self.side_var.get()} {self.model_var.get()} | " @@ -362,6 +405,7 @@ def set_all_positions(self, value): self.publish() def publish(self): + self.command_dirty = False positions = [var.get() for var in self.position_vars] speeds = [var.get() for var in self.speed_vars] torques = [var.get() for var in self.torque_vars] @@ -371,7 +415,7 @@ def publish(self): def publish_loop(self): if self.closed: return - if self.live_var.get(): + if self.live_var.get() and self.command_dirty: self.publish() period_ms = max(1, round(1000 / max(1, self.rate_var.get()))) self.root.after(period_ms, self.publish_loop) @@ -552,7 +596,12 @@ def parse_args(): parser = argparse.ArgumentParser() parser.add_argument("--side", default="left", choices=("left", "right")) parser.add_argument("--topic", default=None) - parser.add_argument("--model", default="O6", choices=sorted(JOINT_COUNTS)) + parser.add_argument( + "--model", + default=None, + choices=sorted(JOINT_COUNTS), + help="hand model; auto-read HAND_JOINTS from the running hand node when omitted", + ) parser.add_argument( "--no-live", action="store_true", @@ -570,17 +619,18 @@ def main(): rclpy.init() node = HandControlPublisher(topic) - spin_thread = threading.Thread(target=rclpy.spin, args=(node,), daemon=True) - spin_thread.start() + model = args.model or read_hand_model(node, args.side) or "O6" gui = HandControlGui( node=node, - model=args.model, + model=model, side=args.side, topic=topic, live_publish=not args.no_live, publish_rate_hz=args.rate, ) + spin_thread = threading.Thread(target=rclpy.spin, args=(node,), daemon=True) + spin_thread.start() try: gui.run() finally: @@ -588,5 +638,13 @@ def main(): rclpy.shutdown() +def read_hand_model(node, side): + node_name = f"/linker_hand_{side}_node" + model = node.get_remote_parameter(node_name, "HAND_JOINTS") + if model in JOINT_COUNTS: + return model + return None + + if __name__ == "__main__": main() diff --git a/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp b/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp index d218301..0b17138 100644 --- a/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp +++ b/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp @@ -272,7 +272,7 @@ class LinkerHand : public rclcpp::Node if (pub_hand_touch_->get_subscription_count() > 0) { publishTouchData(hand_api, *this->pub_hand_touch_); } - std::this_thread::sleep_for(std::chrono::milliseconds(125)); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); } }); } @@ -283,7 +283,7 @@ class LinkerHand : public rclcpp::Node publishJointState(hand_api, *this->pub_hand_state_); // publishJointState(hand_api, *this->pub_hand_state_arc_, true); } - std::this_thread::sleep_for(std::chrono::milliseconds(20)); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); } }); @@ -292,7 +292,7 @@ class LinkerHand : public rclcpp::Node if (pub_hand_info_->get_subscription_count() > 0) { publishLinkerHandInfo(hand_api, *this->pub_hand_info_); } - std::this_thread::sleep_for(std::chrono::milliseconds(1000)); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); } }); } @@ -358,7 +358,7 @@ class LinkerHand : public rclcpp::Node rclcpp::Publisher & publisher) { auto message = std_msgs::msg::String(); - message.data = hand->getVersion() + "Temperature: " + vectorToString(hand->getTemperature()) + + message.data = hand->getVersion() + "\nTemperature: " + vectorToString(hand->getTemperature()) + "\nFaultCode: " + vectorToString(hand->getFaultCode()); publisher.publish(message); @@ -409,10 +409,44 @@ class LinkerHand : public rclcpp::Node effort = std::vector(expected_joint_count, hand_effort); } - hand->setSpeed(speed); - hand->setTorque(effort); - (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); + const auto position = convert(msg->position); + bool send_position = true; + bool send_speed = true; + bool send_effort = true; + { + std::lock_guard lock(control_mutex_); + if (has_last_control_command && last_control_is_arc == is_arc) { + if (!is_arc) { + send_position = last_position_command != position; + } + send_speed = last_speed_command != speed; + send_effort = last_effort_command != effort; + if (!send_position && !send_speed && !send_effort) { + return; + } + } + has_last_control_command = true; + last_control_is_arc = is_arc; + if (!is_arc && send_position) { + last_position_command = position; + } + if (send_speed) { + last_speed_command = speed; + } + if (send_effort) { + last_effort_command = effort; + } + } + + if (send_speed) { + hand->setSpeed(speed); + } + if (send_effort) { + hand->setTorque(effort); + } + if (send_position) { + (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(position); + } } size_t getJointCount(const std::string & hand_name) const @@ -492,6 +526,12 @@ class LinkerHand : public rclcpp::Node std::string hand_control_arc_topic; int hand_type; + std::mutex control_mutex_; + bool has_last_control_command{false}; + bool last_control_is_arc{false}; + std::vector last_position_command; + std::vector last_speed_command; + std::vector last_effort_command; }; // signal From d56424ae703517cc4208849234ed40a96cbc646a Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Mon, 15 Jun 2026 15:56:47 +0800 Subject: [PATCH 06/19] feat: add Modbus transport support --- src/linker_hand_cpp_ros2/launch/run.xml | 4 + src/linker_hand_cpp_ros2/launch/run_left.xml | 2 + .../src/linker_hand_node.cpp | 203 +++++++++++++----- 3 files changed, 157 insertions(+), 52 deletions(-) diff --git a/src/linker_hand_cpp_ros2/launch/run.xml b/src/linker_hand_cpp_ros2/launch/run.xml index 0614d1a..aa3e3fe 100644 --- a/src/linker_hand_cpp_ros2/launch/run.xml +++ b/src/linker_hand_cpp_ros2/launch/run.xml @@ -7,12 +7,14 @@ + + @@ -31,6 +33,7 @@ + @@ -52,6 +55,7 @@ + diff --git a/src/linker_hand_cpp_ros2/launch/run_left.xml b/src/linker_hand_cpp_ros2/launch/run_left.xml index 303b702..ae01108 100644 --- a/src/linker_hand_cpp_ros2/launch/run_left.xml +++ b/src/linker_hand_cpp_ros2/launch/run_left.xml @@ -7,6 +7,7 @@ + @@ -25,6 +26,7 @@ + diff --git a/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp b/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp index 0b17138..b9d8eff 100644 --- a/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp +++ b/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -27,6 +28,7 @@ #include "LinkerHandApi.h" #include "CommFactory.h" +#include "Modbus.h" using namespace std::chrono_literals; @@ -68,6 +70,7 @@ class LinkerHand : public rclcpp::Node this->declare_parameter("HAND_JOINTS", "L7"); this->declare_parameter("HAND_SPEED", 50); this->declare_parameter("HAND_EFFORT", 200); + this->declare_parameter("COMM_TYPE", "CAN"); this->declare_parameter("CAN_CHANNEL", "can0"); this->declare_parameter("CAN_BITRATE", 1000000); @@ -91,13 +94,16 @@ class LinkerHand : public rclcpp::Node this->get_parameter("HAND_JOINTS", hand_joints); this->get_parameter("HAND_SPEED", hand_speed); this->get_parameter("HAND_EFFORT", hand_effort); + this->get_parameter("COMM_TYPE", comm_type); this->get_parameter("CAN_CHANNEL", can_channel); this->get_parameter("CAN_BITRATE", can_bitrate); // std::cout << "LinkerHand SDK version: " << version << std::endl; + normalizeCommType(); + std::cout << (hand_type == 0 ? "LEFT_HAND" : "RIGHT_HAND") << " HAND_EXISTS:" << hand_exists << " HAND_JOINTS:" << - hand_joints << " HAND_TOUCH:" << hand_touch << std::endl; + hand_joints << " HAND_TOUCH:" << hand_touch << " COMM_TYPE:" << comm_type << std::endl; if (hand_exists != true) {return;} @@ -117,9 +123,10 @@ class LinkerHand : public rclcpp::Node if (hand_exists) { auto it = linker_hand_map.find(hand_joints); if (it != linker_hand_map.end()) { - if (it->second == LINKER_HAND::O20) { + hand_model = it->second; + if (hand_model == LINKER_HAND::O20) { RCLCPP_ERROR(this->get_logger(), - "O20 requires CAN-FD and is not supported by this CAN-only ROS2 node yet"); + "O20 requires CAN-FD and is not supported by this ROS2 node yet"); return; } @@ -131,58 +138,15 @@ class LinkerHand : public rclcpp::Node std::cout << "Invalid hand_type: " << hand_type << std::endl; return; } - try { - hand_api = std::make_unique(it->second, side, COMM_TYPE::CAN); - } catch (const std::exception & e) { - RCLCPP_ERROR(this->get_logger(), "Failed to create LinkerHandApi: %s", e.what()); - return; - } } else { std::cout << "Invalid hand: " << hand_joints << std::endl; return; } } - try { - can_bus = std::shared_ptr( - Communication::CommFactory::createCanBus(side)); - } catch (const std::exception & e) { - RCLCPP_ERROR(this->get_logger(), "Failed to create CAN bus: %s", e.what()); - RCLCPP_ERROR(this->get_logger(), - "Start CAN first, for example: sudo ip link set can0 up type can bitrate %d && " - "sudo ip link set can0 txqueuelen 1024", - can_bitrate); + if (!initTransport()) { return; } - - hand_api->setCanTxCallback([this](uint32_t can_id, const uint8_t * data, - uintptr_t data_len) -> int32_t { - try { - std::vector data_vec(data, data + data_len); - can_bus->send(data_vec, can_id); - return 0; - } catch (const std::exception & e) { - RCLCPP_ERROR(this->get_logger(), "CAN send error: %s", e.what()); - return -1; - } - }); - - hand_api->setCanRxCallback([this](uint32_t * can_id_out, uint8_t * data_out, - uint8_t * len_out) -> int32_t { - try { - auto frame = can_bus->recv(); - if (frame.can_id == 0 && frame.can_dlc == 0) { - return -1; - } - *can_id_out = frame.can_id; - *len_out = frame.can_dlc; - std::memcpy(data_out, frame.data, frame.can_dlc); - return 0; - } catch (const std::exception & e) { - RCLCPP_ERROR(this->get_logger(), "CAN receive error: %s", e.what()); - return -1; - } - }); node_active = true; if (hand_exists) {initHand(hand_api, hand_joints);} @@ -267,7 +231,7 @@ class LinkerHand : public rclcpp::Node if (hand_exists && hand_touch) { pub_touch_thread = std::thread([this]() { - while (rclcpp::ok()) { + while (rclcpp::ok() && node_active) { // rclcpp::spin_some(this); if (pub_hand_touch_->get_subscription_count() > 0) { publishTouchData(hand_api, *this->pub_hand_touch_); @@ -278,7 +242,7 @@ class LinkerHand : public rclcpp::Node } pub_state_thread = std::thread([this]() { - while (rclcpp::ok()) { + while (rclcpp::ok() && node_active) { if (pub_hand_state_->get_subscription_count() > 0) { publishJointState(hand_api, *this->pub_hand_state_); // publishJointState(hand_api, *this->pub_hand_state_arc_, true); @@ -288,7 +252,7 @@ class LinkerHand : public rclcpp::Node }); pub_info_thread = std::thread([this]() { - while (rclcpp::ok()) { + while (rclcpp::ok() && node_active) { if (pub_hand_info_->get_subscription_count() > 0) { publishLinkerHandInfo(hand_api, *this->pub_hand_info_); } @@ -300,6 +264,7 @@ class LinkerHand : public rclcpp::Node ~LinkerHand() { // Ensure the release of resources in the destructor + node_active = false; hand_api.reset(); if (pub_touch_thread.joinable()) {pub_touch_thread.join();} @@ -312,6 +277,136 @@ class LinkerHand : public rclcpp::Node return node_active; } + void normalizeCommType() + { + for (auto & ch : comm_type) { + ch = static_cast(std::toupper(static_cast(ch))); + } + } + + bool initTransport() + { + if (comm_type == "CAN") { + return initCanTransport(); + } + if (comm_type == "MODBUS") { + return initModbusTransport(); + } + RCLCPP_ERROR(this->get_logger(), "Unsupported COMM_TYPE: %s", comm_type.c_str()); + return false; + } + + bool initCanTransport() + { + try { + hand_api = std::make_unique(hand_model, side, COMM_TYPE::CAN); + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "Failed to create CAN LinkerHandApi: %s", e.what()); + return false; + } + + try { + can_bus = std::shared_ptr( + Communication::CommFactory::createCanBus(side)); + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "Failed to create CAN bus: %s", e.what()); + RCLCPP_ERROR(this->get_logger(), + "Start CAN first, for example: sudo ip link set can0 up type can bitrate %d && " + "sudo ip link set can0 txqueuelen 1024", + can_bitrate); + return false; + } + + hand_api->setCanTxCallback([this](uint32_t can_id, const uint8_t * data, + uintptr_t data_len) -> int32_t { + try { + std::vector data_vec(data, data + data_len); + can_bus->send(data_vec, can_id); + return 0; + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "CAN send error: %s", e.what()); + return -1; + } + }); + + hand_api->setCanRxCallback([this](uint32_t * can_id_out, uint8_t * data_out, + uint8_t * len_out) -> int32_t { + try { + auto frame = can_bus->recv(); + if (frame.can_id == 0 && frame.can_dlc == 0) { + return -1; + } + *can_id_out = frame.can_id; + *len_out = frame.can_dlc; + std::memcpy(data_out, frame.data, frame.can_dlc); + return 0; + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "CAN receive error: %s", e.what()); + return -1; + } + }); + + return true; + } + + bool initModbusTransport() + { + if (!supportsModbus(hand_joints)) { + RCLCPP_ERROR(this->get_logger(), "MODBUS is only supported for O6, L7, and L10"); + return false; + } + + try { + hand_api = std::make_unique(hand_model, side, COMM_TYPE::MODBUS); + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "Failed to create Modbus LinkerHandApi: %s", e.what()); + return false; + } + + try { + modbus = std::make_shared(side); + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "Failed to create Modbus transport: %s", e.what()); + return false; + } + if (!modbus || !modbus->isOpen()) { + RCLCPP_ERROR(this->get_logger(), "Failed to open Modbus serial transport"); + return false; + } + + hand_api->setModbusTxCallback([this](uint8_t, uint16_t, const uint8_t * data, + uintptr_t len) -> int32_t { + if (!modbus) { + return -1; + } + return modbus->sendRawFrame(data, len) ? 0 : -1; + }); + + hand_api->setModbusRxCallback([this](uint8_t sid, uint16_t * addr_out, + uint8_t * data_out, uint8_t * len_out) -> int32_t { + if (!modbus) { + return -1; + } + const int len = modbus->receiveCompleteFrame(data_out, 256, 500); + if (len <= 0 || data_out[0] != sid) { + return -1; + } + *len_out = static_cast(len); + if (addr_out) { + *addr_out = 0; + } + return 0; + }); + + RCLCPP_INFO(this->get_logger(), "Modbus transport initialized by hand side"); + return true; + } + + bool supportsModbus(const std::string & hand_name) const + { + return hand_name == "O6" || hand_name == "L7" || hand_name == "L10"; + } + // General function: Publish touch sensor data template void publishTouchData( @@ -479,8 +574,9 @@ class LinkerHand : public rclcpp::Node void initHand(const HandType & hand, const std::string & hand_name) { if (hand_name == "L10") { - hand->setSpeed(std::vector(5, hand_speed)); // speed - hand->setTorque(std::vector(5, hand_effort)); // torque + const auto joint_count = getJointCount(hand_name); + hand->setSpeed(std::vector(joint_count, hand_speed)); // speed + hand->setTorque(std::vector(joint_count, hand_effort)); // torque hand->fingerMove({255, 128, 255, 255, 255, 255, 128, 128, 128, 128}); // joint position } } @@ -505,12 +601,15 @@ class LinkerHand : public rclcpp::Node bool hand_touch; bool node_active{false}; std::string hand_joints; + std::string comm_type; int hand_speed; int hand_effort; int can_bitrate; HAND_TYPE side; + LINKER_HAND hand_model; std::shared_ptr can_bus; + std::shared_ptr modbus; // 创建线程对象 std::thread pub_touch_thread; From 3f0069a62c93523e04da95fbaa4ed3812dbac5d5 Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Mon, 29 Jun 2026 13:29:20 +0800 Subject: [PATCH 07/19] refactor: normalize naming --- src/linker_hand_cpp_ros2/src/linker_hand_node.cpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp b/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp index b9d8eff..6a06b07 100644 --- a/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp +++ b/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp @@ -438,8 +438,8 @@ class LinkerHand : public rclcpp::Node { auto message = sensor_msgs::msg::JointState(); - (is_arc) ? message.position = hand->getStateArc() : message.position = convert(hand->getState()); + (is_arc) ? message.position = hand->getPositionArc() : message.position = convert(hand->getPosition()); message.velocity = convert(hand->getSpeed()); message.effort = convert(hand->getTorque()); @@ -540,7 +540,7 @@ class LinkerHand : public rclcpp::Node hand->setTorque(effort); } if (send_position) { - (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(position); + (is_arc) ? hand->setPositionArc(msg->position) : hand->setPosition(position); } } @@ -553,7 +553,7 @@ class LinkerHand : public rclcpp::Node if (hand_name == "L21") {return 21;} if (hand_name == "L25") {return 25;} if (hand_name == "G20") {return 16;} - if (hand_name == "O20") {return 34;} + if (hand_name == "O20") {return 16;} return 0; } @@ -577,7 +577,7 @@ class LinkerHand : public rclcpp::Node const auto joint_count = getJointCount(hand_name); hand->setSpeed(std::vector(joint_count, hand_speed)); // speed hand->setTorque(std::vector(joint_count, hand_effort)); // torque - hand->fingerMove({255, 128, 255, 255, 255, 255, 128, 128, 128, 128}); // joint position + hand->setPosition({255, 128, 255, 255, 255, 255, 128, 128, 128, 128}); // joint position } } From 068ed65d7b7b4c6622589db4163ae9026975d272 Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Tue, 30 Jun 2026 10:16:14 +0800 Subject: [PATCH 08/19] ci: dependence linkerhand-cpp-sdk v2.1.8 --- .github/workflows/ci.yml | 2 +- AGENTS.md | 32 ------------------- .../scripts/left_hand_control_gui.py | 9 +++++- 3 files changed, 9 insertions(+), 34 deletions(-) delete mode 100644 AGENTS.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21d4eaa..7924222 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: - name: Setup LinkerHand SDK run: | - git clone --depth 1 --branch v2.1.7 https://github.com/linker-bot/linkerhand-cpp-sdk.git /tmp/sdk + git clone --depth 1 --branch v2.1.8 https://github.com/linker-bot/linkerhand-cpp-sdk.git /tmp/sdk cd /tmp/sdk && mkdir build && cd build cmake .. -DBUILD_EXAMPLES=OFF && make install && ldconfig diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index b642293..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,32 +0,0 @@ -# Repository Guidelines - -## Project Structure & Module Organization - -本仓库是 ROS2 `ament_cmake` 工作区。核心节点位于 `src/linker_hand_cpp_ros2/`,入口源码为 `src/linker_hand_cpp_ros2/src/linker_hand_node.cpp`,启动文件在 `src/linker_hand_cpp_ros2/launch/run.xml`。示例程序位于 `src/examples/src/`,每个 `.cpp` 文件对应一个独立可执行示例。包元数据和构建规则分别由各包内的 `package.xml` 与 `CMakeLists.txt` 管理。仓库根目录保留 `README.md`、`LICENSE` 和本指南。 - -## Build, Test, and Development Commands - -- `colcon build`: 从仓库根目录构建全部 ROS2 包。 -- `colcon build --packages-select linker_hand_cpp_ros2`: 仅构建核心节点包,适合快速验证节点改动。 -- `source install/setup.bash`: 加载构建后的工作区环境。 -- `ros2 launch linker_hand_cpp_ros2 run.xml`: 启动 LinkerHand ROS2 节点。 -- `colcon test`: 运行已启用的 ROS2/ament 测试与 lint 钩子。 -- `colcon test-result --verbose`: 查看测试失败详情。 - -构建前需确保已安装 ROS2 Foxy 或 Humble,以及 `linkerhand-cpp-sdk v2.1.7`。 - -## Coding Style & Naming Conventions - -项目使用 C++17,CMake 中启用 `-Wall -Wextra -Wpedantic`。遵循现有代码风格:CMake 变量使用大写或描述性蛇形命名,C++ 文件和 ROS2 可执行目标使用小写下划线,例如 `show_wave_l20.cpp`。新增示例应放入 `src/examples/src/`,并同步更新 `src/examples/CMakeLists.txt` 的 `SOURCE_FILES`。注释只解释非显然原因,避免描述代码表面行为。 - -## Testing Guidelines - -当前未包含专门的单元测试目录,主要依赖 `colcon build`、`colcon test` 与运行验证。涉及节点行为的改动,应至少构建核心包,并在可用硬件或仿真环境下运行 `ros2 launch linker_hand_cpp_ros2 run.xml`。新增测试时优先使用 ROS2/ament 生态,并保持测试名称与目标功能对应。 - -## Commit & Pull Request Guidelines - -Git 历史使用英文前缀提交信息,例如 `docs: update README`、`feat: update ci/cd`。提交应采用 `type: 简短说明` 格式,常用类型包括 `feat`、`fix`、`docs`、`build`、`test`、`refactor`。Pull Request 应说明变更目的、影响范围、验证命令和结果;涉及硬件行为时注明设备型号、CAN 口配置和是否完成实机验证。 - -## Security & Configuration Tips - -不要提交本地构建产物、硬件私有配置或凭据。修改 `run.xml` 默认参数时,确认左右手存在性、型号、触觉开关和 CAN 设备名与 README 中的话题约定一致。 diff --git a/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py b/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py index 4b5f138..cca67eb 100755 --- a/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py +++ b/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py @@ -317,7 +317,14 @@ def _add_slider(self, parent, row, column, variable): ) scale.set(variable.get()) scale.grid(row=0, column=0, sticky="ew") - variable.trace_add("write", lambda *_args, var=variable, widget=scale: widget.set(var.get())) + # variable.trace_add( + # "write", + # lambda *_args, var=variable, widget=scale: widget.set(var.get()) + # ) + variable.trace_add( + "write", + lambda *_args, var=variable, widget=scale: widget.set(var.get()), + ) spin = ttk.Spinbox( frame, from_=0, From ad112a85728092ee61f6f4103d91a35e5983470e Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Tue, 30 Jun 2026 11:36:46 +0800 Subject: [PATCH 09/19] chore: remove examples package and update README --- README.md | 15 - src/examples/CMakeLists.txt | 89 --- src/examples/package.xml | 22 - src/examples/src/action_group_show_l10.cpp | 454 ------------- src/examples/src/action_group_show_l20.cpp | 385 ----------- src/examples/src/action_group_show_l25.cpp | 633 ------------------ src/examples/src/action_group_show_l7.cpp | 396 ----------- src/examples/src/linker_hand_node.cpp | 482 ------------- src/examples/src/loop_l20.cpp | 73 -- src/examples/src/loop_l25.cpp | 104 --- src/examples/src/show_ok_l20.cpp | 105 --- .../src/show_surround_index_finger_l20.cpp | 97 --- src/examples/src/show_wave_l20.cpp | 95 --- 13 files changed, 2950 deletions(-) delete mode 100644 src/examples/CMakeLists.txt delete mode 100644 src/examples/package.xml delete mode 100644 src/examples/src/action_group_show_l10.cpp delete mode 100644 src/examples/src/action_group_show_l20.cpp delete mode 100644 src/examples/src/action_group_show_l25.cpp delete mode 100644 src/examples/src/action_group_show_l7.cpp delete mode 100644 src/examples/src/linker_hand_node.cpp delete mode 100644 src/examples/src/loop_l20.cpp delete mode 100644 src/examples/src/loop_l25.cpp delete mode 100644 src/examples/src/show_ok_l20.cpp delete mode 100644 src/examples/src/show_surround_index_finger_l20.cpp delete mode 100644 src/examples/src/show_wave_l20.cpp diff --git a/README.md b/README.md index fac0b1e..285519c 100644 --- a/README.md +++ b/README.md @@ -200,21 +200,6 @@ data: - '...' ``` -## 💡 使用示例 - -| 序号 | 文件名称 | 描述 | -| :--- | :-------- | :--------------------------------------------- | -| 1 | src/examples/src/action_group_show_l7 | L7型号手指舞示例 | -| 2 | src/examples/src/action_group_show_l10 | L10型号手指舞示例 | -| 3 | src/examples/src/action_group_show_l20 | L20型号手指舞示例 | -| 4 | src/examples/src/loop_l20 | L20型号手势张开握拳示例 | -| 5 | src/examples/src/show_ok_l20 | L20型号手势ok示例 | -| 6 | src/examples/src/show_surround_index_finger_l20 | L20型号单指手势示例 | -| 7 | src/examples/src/show_wave_l20 | L20型号wave手势示例 | -| 8 | src/examples/src/action_group_show_l25 | L25型号手指舞示例 | -| 9 | src/examples/src/loop_l25 | L25型号手势张开握拳示例 | - - ## 📄 许可证 本项目采用 [Apache 2.0 许可证](LICENSE)。 diff --git a/src/examples/CMakeLists.txt b/src/examples/CMakeLists.txt deleted file mode 100644 index 4fa38e9..0000000 --- a/src/examples/CMakeLists.txt +++ /dev/null @@ -1,89 +0,0 @@ -cmake_minimum_required(VERSION 3.10) -project(examples) - -# Default to C99 -if(NOT CMAKE_C_STANDARD) - set(CMAKE_C_STANDARD 99) -endif() - -# Default to C++17 -if(NOT CMAKE_CXX_STANDARD) - set(CMAKE_CXX_STANDARD 17) -endif() -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - -# Enable additional compiler warnings -if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") - add_compile_options(-Wall -Wextra -Wpedantic) -endif() - -# Find dependencies -find_package(ament_cmake REQUIRED) -find_package(rclcpp REQUIRED) -find_package(std_msgs REQUIRED) -find_package(sensor_msgs REQUIRED) -find_package(nlohmann_json REQUIRED) -find_package(linkerhand-cpp-sdk 2.0 CONFIG REQUIRED) - -# Define common link libraries -set(COMMON_LIBS LinkerHand::linkerhand_cpp_sdk nlohmann_json::nlohmann_json) - -# Define source files -set(SOURCE_FILES - action_group_show_l7 - action_group_show_l10 - action_group_show_l20 - action_group_show_l25 - loop_l20 - loop_l25 - show_surround_index_finger_l20 - show_ok_l20 - show_wave_l20 -) - -# Create and name a shared library -foreach(SOURCE_FILE ${SOURCE_FILES}) - add_executable(${SOURCE_FILE} src/${SOURCE_FILE}.cpp) - target_include_directories(${SOURCE_FILE} PUBLIC - $ - $ - ) - ament_target_dependencies(${SOURCE_FILE} - rclcpp - std_msgs - sensor_msgs - ) - target_link_libraries(${SOURCE_FILE} ${COMMON_LIBS}) - set_target_properties(${SOURCE_FILE} PROPERTIES - BUILD_RPATH "${linkerhand-cpp-sdk_LIBRARY_DIR}" - INSTALL_RPATH "${linkerhand-cpp-sdk_LIBRARY_DIR}" - ) - install(TARGETS ${SOURCE_FILE} - DESTINATION lib/${PROJECT_NAME} - ) -endforeach() - -# Install launch files -# install(DIRECTORY launch -# DESTINATION share/${PROJECT_NAME}/ -# ) - -# Install configuration files -# install(DIRECTORY config -# DESTINATION share/${PROJECT_NAME}/ -# ) - -# Install settings file -# install(FILES config/setting.yaml -# DESTINATION config -# ) - -# If BUILD_TESTING is enabled, include test-related dependencies -if(BUILD_TESTING) - find_package(ament_lint_auto REQUIRED) - ament_lint_auto_find_test_dependencies() -endif() - -# Package information -ament_package() diff --git a/src/examples/package.xml b/src/examples/package.xml deleted file mode 100644 index ce4a475..0000000 --- a/src/examples/package.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - examples - 0.0.0 - TODO: Package description - lst - Apache2.0 - - ament_cmake - - ament_lint_auto - ament_lint_common - nlohmann-json-dev - - - ament_cmake - rclcpp - std_msgs - sensor_msgs - - diff --git a/src/examples/src/action_group_show_l10.cpp b/src/examples/src/action_group_show_l10.cpp deleted file mode 100644 index 3b53fee..0000000 --- a/src/examples/src/action_group_show_l10.cpp +++ /dev/null @@ -1,454 +0,0 @@ -// 本demo为适配球形拇指根部关节版本L10灵巧手,默认支持右手,如需左手,请将 HAND_TYPE::RIGHT 改为 HAND_TYPE::LEFT - -#include -#include -#include -#include -#include - -using namespace std; - -bool running = true; - -vector joint_order = {"joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7", "joint8", "joint9", "joint10"}; - -map hand = { - {"joint1", 255}, //拇指根部弯曲 - {"joint2", 128}, //拇指侧摆 - {"joint3", 255}, //食指根部弯曲 - {"joint4", 255}, //中指根部弯曲 - {"joint5", 255}, //无名指根部弯曲 - {"joint6", 255}, //小指根部弯曲 - {"joint7", 128}, //食指侧摆 - {"joint8", 128}, //中指侧摆 - {"joint9", 128}, //无名指侧摆 - {"joint10", 255} //拇指旋转 -}; - -int show_count = 0; -int show_count_obj = 0; -int show_step = 0; - -vector showLeft() { - static vector position; - show_count++; - if (show_count >= show_count_obj) { - show_count = 0; - switch (show_step) { - case 0: - show_step++; - show_count_obj = 100; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint3"] = 250; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint7"] = 128; - hand["joint8"] = 128; - hand["joint9"] = 128; - hand["joint10"] = 250; - break; - case 1: - show_step++; - show_count_obj = 10; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint5"] = 0; - hand["joint6"] = 0; - hand["joint10"] = 250; - break; - case 2: - show_step++; - show_count_obj = 30; - hand["joint1"] = 40; - hand["joint2"] = 240; - hand["joint10"] = 80; - break; - case 3: - show_step++; - show_count_obj = 10; - hand["joint7"] = 200; - break; - case 4: - show_step++; - show_count_obj = 10; - hand["joint7"] = 50; - break; - case 5: - show_step++; - show_count_obj = 15; - hand["joint7"] = 128; - break; - case 6: - show_step++; - show_count_obj = 2; - hand["joint7"] = 50; - break; - case 7: - show_step++; - show_count_obj = 10; - hand["joint7"] = 128; - break; - case 8: - show_step++; - show_count_obj = 10; - hand["joint7"] = 50; - break; - case 9: - show_step++; - show_count_obj = 10; - hand["joint7"] = 128; - break; - case 10: - show_step++; - show_count_obj = 15; - hand["joint3"] = 100; - hand["joint4"] = 100; - break; - case 11: - show_step++; - show_count_obj = 15; - hand["joint3"] = 250; - hand["joint4"] = 250; - break; - case 12: - show_step++; - show_count_obj = 15; - hand["joint3"] = 100; - hand["joint4"] = 100; - break; - case 13: - show_step++; - show_count_obj = 15; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint3"] = 250; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint7"] = 128; - hand["joint8"] = 128; - hand["joint9"] = 128; - hand["joint10"] = 250; - break; - case 14: - show_step++; - show_count_obj = 40; - hand["joint1"] = 40; - hand["joint2"] = 240; - hand["joint10"] = 80; - break; - case 15: - show_step++; - break; - case 16: - show_step++; - show_count_obj = 30; - hand["joint3"] = 10; - hand["joint4"] = 10; - hand["joint5"] = 10; - hand["joint6"] = 10; - break; - case 17: - show_step++; - show_count_obj = 15; - hand["joint6"] = 250; - break; - case 18: - show_step++; - show_count_obj = 15; - hand["joint5"] = 250; - break; - case 19: - show_step++; - show_count_obj = 15; - hand["joint4"] = 250; - break; - case 20: - show_step++; - show_count_obj = 15; - hand["joint3"] = 250; - break; - case 21: - show_step++; - show_count_obj = 20; - hand["joint1"] = 250; - hand["joint2"] = 110; - hand["joint10"] = 240; - break; - case 22: - show_step++; - show_count_obj = 20; - hand["joint1"] = 250; - hand["joint2"] = 10; - hand["joint10"] = 110; - break; - case 23: - show_step++; - show_count_obj = 40; - hand["joint1"] = 0; - hand["joint2"] = 10; - hand["joint10"] = 110; - break; - case 24: - show_step++; - show_count_obj = 30; - hand["joint1"] = 0; - hand["joint2"] = 240; - hand["joint10"] = 110; - break; - case 25: - show_step++; - show_count_obj = 50; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint10"] = 110; - break; - case 26: - show_step++; - show_count_obj = 10; - hand["joint7"] = 200; - hand["joint8"] = 200; - hand["joint9"] = 200; - break; - case 27: - show_step++; - show_count_obj = 15; - hand["joint7"] = 80; - hand["joint8"] = 80; - hand["joint9"] = 80; - break; - case 28: - show_step++; - show_count_obj = 20; - hand["joint7"] = 128; - hand["joint8"] = 128; - hand["joint9"] = 128; - break; - case 29: - show_step++; - show_count_obj = 15; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint10"] = 250; - break; - case 30: - show_step++; - break; - case 31: - show_step++; - break; - case 32: - show_step++; - break; - case 33: - show_step++; - show_count_obj = 15; - hand["joint3"] = 0; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint10"] = 250; - break; - case 34: - show_step++; - show_count_obj = 15; - hand["joint4"] = 0; - break; - case 35: - show_step++; - show_count_obj = 15; - hand["joint5"] = 0; - break; - case 36: - show_step++; - show_count_obj = 15; - hand["joint6"] = 0; - break; - case 37: - show_step++; - show_count_obj = 40; - hand["joint1"] = 0; - break; - case 38: - show_step++; - show_count_obj = 40; - hand["joint1"] = 250; - hand["joint2"] = 230; - hand["joint10"] = 250; - break; - case 39: - show_step++; - show_count_obj = 30; - hand["joint3"] = 250; - hand["joint6"] = 250; - break; - case 40: - show_step++; - show_count_obj = 40; - hand["joint1"] = 10; - hand["joint2"] = 40; - hand["joint10"] = 60; - break; - case 41: - show_step++; - show_count_obj = 15; - hand["joint7"] = 80; - hand["joint9"] = 200; - break; - case 42: - show_step++; - show_count_obj = 15; - hand["joint7"] = 200; - hand["joint9"] = 80; - break; - case 43: - show_step++; - show_count_obj = 15; - hand["joint7"] = 80; - hand["joint9"] = 200; - break; - case 44: - show_step++; - show_count_obj = 15; - hand["joint7"] = 200; - hand["joint9"] = 80; - break; - case 45: - show_step++; - show_count_obj = 15; - hand["joint7"] = 128; - hand["joint9"] = 128; - break; - case 46: - show_step++; - show_count_obj = 50; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint3"] = 250; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint7"] = 128; - hand["joint8"] = 128; - hand["joint9"] = 128; - hand["joint10"] = 250; - break; - case 47: - show_step++; - show_count_obj = 50; - hand["joint1"] = 130; - hand["joint2"] = 130; - hand["joint3"] = 130; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint10"] = 90; - break; - case 48: - show_step++; - show_count_obj = 20; - hand["joint1"] = 250; - hand["joint3"] = 250; - hand["joint4"] = 120; - break; - case 49: - show_step++; - show_count_obj = 35; - hand["joint1"] = 120; - hand["joint4"] = 130; - hand["joint10"] = 60; - break; - case 50: - show_step++; - show_count_obj = 30; - hand["joint1"] = 250; - hand["joint4"] = 250; - hand["joint5"] = 145; - break; - case 51: - show_step++; - show_count_obj = 35; - hand["joint1"] = 113; - hand["joint2"] = 103; - hand["joint5"] = 128; - hand["joint10"] = 42; - break; - case 52: - show_step++; - show_count_obj = 30; - hand["joint1"] = 250; - hand["joint5"] = 250; - break; - case 53: - show_step++; - show_count_obj = 40; - hand["joint1"] = 118; - hand["joint2"] = 103; - hand["joint6"] = 120; - hand["joint10"] = 22; - break; - case 54: - show_step++; - show_count_obj = 20; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint3"] = 250; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint7"] = 128; - hand["joint8"] = 128; - hand["joint9"] = 128; - hand["joint10"] = 250; - break; - default: - break; - } - position.clear(); - for (const auto& joint : joint_order) { // 按照固定的顺序提取关节值 - // std::cout << joint << " " << hand[joint] << std::endl; - position.push_back(static_cast(hand[joint])); - } - } - return position; -} - -class JointStatePublisher : public rclcpp::Node { -public: - JointStatePublisher() : Node("action_group_show") { - joint_state_pub = this->create_publisher("/right_hand_control", 10); - timer = this->create_wall_timer(std::chrono::milliseconds(33), std::bind(&JointStatePublisher::publishJointState, this)); - } - -private: - void publishJointState() { - sensor_msgs::msg::JointState joint_state; - joint_state.header.stamp = this->now(); - joint_state.name = {"joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7", "joint8", "joint9", "joint10"}; - vector position = showLeft(); - joint_state.position = position; - joint_state.velocity.resize(position.size(), 100.0); - joint_state.effort.resize(position.size(), 200.0); - joint_state_pub->publish(joint_state); - } - - rclcpp::Publisher::SharedPtr joint_state_pub; - rclcpp::TimerBase::SharedPtr timer; -}; - -void signalHandler(int signal) { - (void)signal; - running = false; - rclcpp::shutdown(); -} - -int main(int argc, char **argv) { - rclcpp::init(argc, argv); - signal(SIGINT, signalHandler); - rclcpp::spin(std::make_shared()); - return 0; -} \ No newline at end of file diff --git a/src/examples/src/action_group_show_l20.cpp b/src/examples/src/action_group_show_l20.cpp deleted file mode 100644 index 7672689..0000000 --- a/src/examples/src/action_group_show_l20.cpp +++ /dev/null @@ -1,385 +0,0 @@ -// 本demo仅支持L20,默认为右手,如需左手,请修改话题名 - -#include -#include -#include -#include -#include -#include - -using namespace std; -using namespace std::chrono_literals; - -bool running = true; - -int show_count = 0; -int show_count_obj = 0; -int show_step = 0; - -vector show_left() { - vector position; - show_count++; - if (show_count >= show_count_obj) { - show_count = 0; - switch (show_step) { - case 0: - show_step++; - show_count_obj = 50; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 1: - show_step++; - show_count_obj = 10; - position = {250, 250, 250, 0, 0, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 0, 0}; - break; - case 2: - show_step++; - show_count_obj = 30; - position = {100, 250, 250, 0, 0, 180, 128, 128, 128, 128, 200, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 3: - show_step++; - show_count_obj = 10; - position = {100, 250, 250, 0, 0, 180, 200, 200, 128, 128, 200, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 4: - show_step++; - show_count_obj = 10; - position = {100, 250, 250, 0, 0, 180, 50, 50, 128, 128, 200, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 5: - show_step++; - show_count_obj = 15; - position = {100, 250, 250, 0, 0, 180, 128, 128, 128, 128, 200, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 6: - show_step++; - show_count_obj = 10; - position = {100, 250, 250, 0, 0, 180, 58, 200, 128, 128, 200, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 7: - show_step++; - show_count_obj = 10; - position = {100, 250, 250, 0, 0, 180, 128, 128, 128, 128, 200, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 8: - show_step++; - show_count_obj = 10; - position = {100, 250, 250, 0, 0, 180, 58, 200, 128, 128, 200, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 9: - show_step++; - show_count_obj = 10; - position = {100, 250, 250, 0, 0, 180, 128, 128, 128, 128, 200, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 10: - show_step++; - show_count_obj = 15; - position = {100, 100, 100, 0, 0, 180, 128, 128, 128, 128, 200, 0, 0, 0, 0, 0, 100, 100, 0, 0}; - break; - case 11: - show_step++; - show_count_obj = 15; - position = {100, 250, 250, 0, 0, 180, 128, 128, 128, 128, 200, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 12: - show_step++; - show_count_obj = 15; - position = {100, 100, 100, 0, 0, 180, 128, 128, 128, 128, 200, 0, 0, 0, 0, 0, 100, 100, 0, 0}; - break; - case 13: - show_step++; - show_count_obj = 15; - position = {100, 250, 250, 0, 0, 180, 128, 128, 128, 128, 200, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 14: - show_step++; - show_count_obj = 40; - position = {250, 250, 250, 0, 0, 150, 128, 128, 128, 128, 250, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 15: - show_step++; - show_count_obj = 10; - position = {250, 250, 250, 0, 0, 5, 128, 128, 128, 128, 250, 0, 0, 0, 0, 0, 250, 250, 0, 0}; - break; - case 16: - show_step++; - show_count_obj = 30; - position = {250, 100, 100, 100, 100, 5, 128, 128, 128, 128, 250, 0, 0, 0, 0, 0, 10, 10, 10, 10}; - break; - case 17: - show_step++; - show_count_obj = 15; - position = {250, 100, 100, 100, 250, 5, 128, 128, 128, 128, 250, 0, 0, 0, 0, 0, 10, 10, 10, 250}; - break; - case 18: - show_step++; - show_count_obj = 15; - position = {250, 100, 100, 250, 250, 5, 128, 128, 128, 128, 250, 0, 0, 0, 0, 0, 10, 10, 250, 250}; - break; - case 19: - show_step++; - show_count_obj = 15; - position = {250, 100, 250, 250, 250, 5, 128, 128, 128, 128, 250, 0, 0, 0, 0, 0, 10, 250, 250, 250}; - break; - case 20: - show_step++; - show_count_obj = 15; - position = {250, 250, 250, 250, 250, 5, 128, 128, 128, 128, 250, 0, 0, 0, 0, 0, 250, 250, 250, 250}; - break; - case 21: - show_step++; - show_count_obj = 10; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 22: - show_step++; - show_count_obj = 20; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 10, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 23: - show_step++; - show_count_obj = 40; - position = {0, 250, 250, 250, 250, 250, 128, 128, 128, 128, 10, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 24: - show_step++; - show_count_obj = 30; - position = {0, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 25: - show_step++; - show_count_obj = 50; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 26: - show_step++; - show_count_obj = 10; - position = {250, 250, 250, 250, 250, 250, 200, 200, 200, 200, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 27: - show_step++; - show_count_obj = 15; - position = {250, 250, 250, 250, 250, 250, 80, 80, 80, 80, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 28: - show_step++; - show_count_obj = 20; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 29: - show_step++; - show_count_obj = 15; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 0, 250, 250, 250}; - break; - case 30: - show_step++; - show_count_obj = 15; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 0, 0, 250, 250}; - break; - case 31: - show_step++; - show_count_obj = 15; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 0, 0, 0, 250}; - break; - case 32: - show_step++; - show_count_obj = 15; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 0, 0, 0, 0}; - break; - case 33: - show_step++; - show_count_obj = 15; - position = {250, 0, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 0, 0, 0, 0}; - break; - case 34: - show_step++; - show_count_obj = 15; - position = {250, 0, 0, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 0, 0, 0, 0}; - break; - case 35: - show_step++; - show_count_obj = 15; - position = {250, 0, 0, 0, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 0, 0, 0, 0}; - break; - case 36: - show_step++; - show_count_obj = 15; - position = {250, 0, 0, 0, 0, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 0, 0, 0, 0}; - break; - case 37: - show_step++; - show_count_obj = 40; - position = {0, 0, 0, 0, 0, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 200, 0, 0, 0, 0}; - break; - case 38: - show_step++; - show_count_obj = 40; - position = {250, 0, 0, 0, 0, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 0, 0, 0, 0}; - break; - case 39: - show_step++; - show_count_obj = 30; - position = {250, 250, 0, 0, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 0, 0, 250}; - break; - case 40: - show_step++; - show_count_obj = 40; - position = {250, 250, 0, 0, 250, 200, 128, 128, 128, 128, 100, 0, 0, 0, 0, 100, 250, 0, 0, 250}; - break; - case 41: - show_step++; - show_count_obj = 15; - position = {250, 250, 0, 0, 250, 200, 80, 128, 128, 200, 100, 0, 0, 0, 0, 100, 250, 0, 0, 250}; - break; - case 42: - show_step++; - show_count_obj = 15; - position = {250, 250, 0, 0, 250, 200, 200, 128, 128, 80, 100, 0, 0, 0, 0, 100, 250, 0, 0, 250}; - break; - case 43: - show_step++; - show_count_obj = 15; - position = {250, 250, 0, 0, 250, 200, 80, 128, 128, 200, 100, 0, 0, 0, 0, 100, 250, 0, 0, 250}; - break; - case 44: - show_step++; - show_count_obj = 15; - position = {250, 250, 0, 0, 250, 200, 200, 128, 128, 80, 100, 0, 0, 0, 0, 100, 250, 0, 0, 250}; - break; - case 45: - show_step++; - show_count_obj = 15; - position = {250, 250, 0, 0, 250, 200, 128, 128, 128, 128, 100, 0, 0, 0, 0, 100, 250, 0, 0, 250}; - break; - case 46: - show_step++; - show_count_obj = 50; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 47: - show_step++; - show_count_obj = 50; - position = {55, 0, 250, 250, 250, 170, 128, 128, 128, 128, 70, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 48: - show_step++; - show_count_obj = 20; - position = {250, 250, 20, 250, 250, 220, 128, 128, 128, 128, 100, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 49: - show_step++; - show_count_obj = 35; - position = {55, 250, 0, 250, 250, 140, 128, 128, 128, 128, 60, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 50: - show_step++; - show_count_obj = 20; - position = {250, 250, 250, 20, 250, 170, 128, 128, 128, 128, 100, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 51: - show_step++; - show_count_obj = 35; - position = {55, 250, 250, 0, 250, 110, 128, 128, 128, 128, 50, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 52: - show_step++; - show_count_obj = 20; - position = {250, 250, 250, 250, 20, 130, 128, 128, 128, 128, 100, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 53: - show_step++; - show_count_obj = 40; - position = {55, 250, 250, 250, 0, 60, 128, 128, 128, 128, 50, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 54: - show_step++; - show_count_obj = 20; - position = {250, 250, 250, 250, 20, 130, 128, 128, 128, 128, 100, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 55: - show_step++; - show_count_obj = 40; - position = {160, 250, 250, 250, 160, 60, 128, 128, 128, 128, 50, 0, 0, 0, 0, 100, 250, 250, 250, 80}; - break; - case 56: - show_step++; - show_count_obj = 20; - position = {250, 250, 250, 250, 250, 130, 128, 128, 128, 128, 100, 0, 0, 0, 0, 250, 250, 250, 50, 250}; - break; - case 57: - show_step++; - show_count_obj = 35; - position = {160, 250, 250, 150, 250, 100, 128, 128, 128, 128, 50, 0, 0, 0, 0, 100, 250, 250, 80, 250}; - break; - case 58: - show_step++; - show_count_obj = 20; - position = {250, 250, 250, 250, 250, 180, 128, 128, 128, 128, 100, 0, 0, 0, 0, 250, 50, 250, 250, 250}; - break; - case 59: - show_step++; - show_count_obj = 35; - position = {160, 250, 150, 250, 250, 135, 128, 128, 128, 128, 70, 0, 0, 0, 0, 100, 250, 85, 250, 250}; - break; - case 60: - show_step++; - show_count_obj = 20; - position = {250, 250, 250, 250, 250, 220, 128, 128, 128, 128, 100, 0, 0, 0, 0, 250, 50, 250, 250, 250}; - break; - case 61: - show_step++; - show_count_obj = 35; - position = {165, 150, 250, 250, 250, 170, 128, 128, 128, 128, 70, 0, 0, 0, 0, 100, 80, 250, 250, 250}; - break; - case 62: - show_step++; - show_count_obj = 60; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - default: - show_step = 0; - position = {250, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - } - } - return position; -} - -class JointStatePublisher : public rclcpp::Node { -public: - JointStatePublisher() : Node("dong_test_sender") { - joint_state_pub = this->create_publisher("/right_hand_control", 10); - timer = this->create_wall_timer(33ms, std::bind(&JointStatePublisher::publishJointState, this)); - } - -private: - void publishJointState() { - sensor_msgs::msg::JointState joint_state; - joint_state.header.stamp = this->now(); - joint_state.name = {"joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7", "joint8", "joint9", "joint10", "joint11", "joint12", "joint13", "joint14", "joint15", "joint16", "joint17", "joint18", "joint19", "joint20"}; - vector position = show_left(); - if (!position.empty()) { - joint_state.position = position; - } - joint_state.velocity.resize(joint_state.position.size(), 100.0); - joint_state.effort.resize(joint_state.position.size(), 200.0); - if (joint_state.position.size() > 0) joint_state_pub->publish(joint_state); - } - - rclcpp::Publisher::SharedPtr joint_state_pub; - rclcpp::TimerBase::SharedPtr timer; -}; - -void signalHandler(int sig) { - (void) sig; - running = false; - rclcpp::shutdown(); -} - -int main(int argc, char **argv) { - rclcpp::init(argc, argv); - signal(SIGINT, signalHandler); - rclcpp::spin(std::make_shared()); - return 0; -} \ No newline at end of file diff --git a/src/examples/src/action_group_show_l25.cpp b/src/examples/src/action_group_show_l25.cpp deleted file mode 100644 index 393baa7..0000000 --- a/src/examples/src/action_group_show_l25.cpp +++ /dev/null @@ -1,633 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace std::chrono_literals; -using namespace std::placeholders; -using namespace std; - -int show_count = 0; -int show_count_obj = 0; -int show_step = 0; -vector joint_order = {"joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7", "joint8", "joint9", "joint10", "joint11", "joint12", "joint13", "joint14", "joint15", "joint16", "joint17", "joint18", "joint19", "joint20", "joint21", "joint22", "joint23", "joint24", "joint25"}; - -std::map hand = { - {"joint1", 250}, - {"joint2", 250}, - {"joint3", 250}, - {"joint4", 250}, - {"joint5", 250}, - {"joint6", 250}, - {"joint7", 250}, - {"joint8", 250}, - {"joint9", 250}, - {"joint10", 250}, - {"joint11", 250}, - {"joint12", 0}, - {"joint13", 0}, - {"joint14", 0}, - {"joint15", 0}, - {"joint16", 250}, - {"joint17", 250}, - {"joint18", 250}, - {"joint19", 250}, - {"joint20", 250}, - {"joint21", 250}, - {"joint22", 250}, - {"joint23", 250}, - {"joint24", 250}, - {"joint25", 250} -}; - -std::vector show_left() { - static int show_count = 0; - static int show_count_obj = 0; - static int show_step = 0; - - show_count++; - if (show_count >= show_count_obj) { - show_count = 0; - switch (show_step) { - case 0: - show_step++; - show_count_obj = 50; - hand["joint1"] = 75; - hand["joint2"] = 255; - hand["joint3"] = 255; - hand["joint4"] = 255; - hand["joint5"] = 255; - hand["joint6"] = 176; - hand["joint7"] = 51; - hand["joint8"] = 51; - hand["joint9"] = 125; - hand["joint10"] = 202; - hand["joint11"] = 202; - hand["joint12"] = 255; - hand["joint13"] = 255; - hand["joint14"] = 255; - hand["joint15"] = 255; - hand["joint16"] = 255; - hand["joint17"] = 255; - hand["joint18"] = 255; - hand["joint19"] = 255; - hand["joint20"] = 255; - hand["joint21"] = 255; - hand["joint22"] = 255; - hand["joint23"] = 255; - hand["joint24"] = 255; - hand["joint25"] = 255; - break; - case 1: - show_step++; - show_count_obj = 10; - hand["joint4"] = 0; - hand["joint5"] = 0; - hand["joint7"] = 128; - hand["joint8"] = 128; - hand["joint9"] = 128; - hand["joint10"] = 128; - hand["joint11"] = 250; - hand["joint16"] = 250; - hand["joint17"] = 250; - hand["joint18"] = 250; - hand["joint19"] = 0; - hand["joint20"] = 0; - hand["joint21"] = 250; - hand["joint22"] = 250; - hand["joint23"] = 250; - hand["joint24"] = 0; - hand["joint25"] = 0; - break; - case 2: - show_step++; - show_count_obj = 30; - hand["joint1"] = 100; - hand["joint6"] = 180; - hand["joint16"] = 0; - hand["joint21"] = 0; - break; - case 3: - show_step++; - show_count_obj = 10; - hand["joint7"] = 200; - hand["joint8"] = 200; - hand["joint11"] = 200; - break; - case 4: - show_step++; - show_count_obj = 13; - hand["joint7"] = 50; - hand["joint8"] = 50; - break; - case 5: - show_step++; - show_count_obj = 13; - hand["joint7"] = 128; - hand["joint8"] = 128; - break; - case 6: - show_step++; - show_count_obj = 2; - hand["joint7"] = 50; - hand["joint8"] = 200; - break; - case 7: - show_step++; - show_count_obj = 10; - hand["joint7"] = 128; - hand["joint8"] = 128; - break; - case 8: - show_step++; - show_count_obj = 10; - hand["joint7"] = 50; - hand["joint8"] = 200; - break; - case 9: - show_step++; - show_count_obj = 10; - hand["joint7"] = 128; - hand["joint8"] = 128; - break; - case 10: - show_step++; - show_count_obj = 15; - hand["joint2"] = 100; - hand["joint3"] = 100; - hand["joint17"] = 100; - hand["joint18"] = 100; - hand["joint22"] = 100; - hand["joint23"] = 100; - break; - case 11: - show_step++; - show_count_obj = 15; - hand["joint2"] = 250; - hand["joint3"] = 250; - hand["joint17"] = 250; - hand["joint18"] = 250; - hand["joint22"] = 250; - hand["joint23"] = 250; - break; - case 12: - show_step++; - show_count_obj = 15; - hand["joint2"] = 100; - hand["joint3"] = 100; - hand["joint17"] = 100; - hand["joint18"] = 100; - hand["joint22"] = 100; - hand["joint23"] = 100; - break; - case 13: - show_step++; - show_count_obj = 15; - hand["joint2"] = 250; - hand["joint3"] = 250; - hand["joint17"] = 250; - hand["joint18"] = 250; - hand["joint22"] = 250; - hand["joint23"] = 250; - break; - case 14: - show_step++; - show_count_obj = 40; - hand["joint1"] = 250; - hand["joint6"] = 150; - hand["joint11"] = 250; - hand["joint23"] = 250; - break; - case 15: - show_step++; - show_count_obj = 10; - hand["joint6"] = 5; - break; - case 16: - show_step++; - show_count_obj = 30; - hand["joint2"] = 100; - hand["joint3"] = 100; - hand["joint4"] = 100; - hand["joint5"] = 100; - hand["joint17"] = 100; - hand["joint18"] = 100; - hand["joint19"] = 100; - hand["joint20"] = 100; - hand["joint22"] = 100; - hand["joint23"] = 100; - hand["joint24"] = 100; - hand["joint25"] = 100; - break; - case 17: - show_step++; - show_count_obj = 15; - hand["joint5"] = 250; - hand["joint20"] = 250; - hand["joint25"] = 250; - break; - case 18: - show_step++; - show_count_obj = 15; - hand["joint4"] = 250; - hand["joint19"] = 250; - hand["joint24"] = 250; - break; - case 19: - show_step++; - show_count_obj = 15; - hand["joint3"] = 250; - hand["joint18"] = 250; - hand["joint23"] = 250; - break; - case 20: - show_step++; - show_count_obj = 15; - hand["joint2"] = 250; - hand["joint17"] = 250; - hand["joint22"] = 250; - break; - case 21: - show_step++; - show_count_obj = 10; - hand["joint6"] = 250; - hand["joint16"] = 250; - hand["joint21"] = 250; - break; - case 22: - show_step++; - show_count_obj = 20; - hand["joint11"] = 10; - break; - case 23: - show_step++; - show_count_obj = 40; - hand["joint1"] = 0; - break; - case 24: - show_step++; - show_count_obj = 30; - hand["joint11"] = 250; - break; - case 25: - show_step++; - show_count_obj = 50; - break; - case 26: - show_step++; - show_count_obj = 10; - hand["joint7"] = 200; - hand["joint8"] = 200; - hand["joint9"] = 200; - hand["joint10"] = 200; - break; - case 27: - show_step++; - show_count_obj = 15; - hand["joint7"] = 80; - hand["joint8"] = 80; - hand["joint9"] = 80; - hand["joint10"] = 80; - break; - case 28: - show_step++; - show_count_obj = 20; - hand["joint7"] = 128; - hand["joint8"] = 128; - hand["joint9"] = 128; - hand["joint10"] = 128; - break; - case 29: - show_step++; - show_count_obj = 15; - hand["joint17"] = 0; - hand["joint22"] = 0; - break; - case 30: - show_step++; - show_count_obj = 15; - hand["joint18"] = 0; - hand["joint23"] = 0; - break; - case 31: - show_step++; - show_count_obj = 15; - hand["joint19"] = 0; - hand["joint24"] = 0; - break; - case 32: - show_step++; - show_count_obj = 15; - hand["joint20"] = 0; - hand["joint25"] = 0; - break; - case 33: - show_step++; - show_count_obj = 15; - hand["joint2"] = 0; - break; - case 34: - show_step++; - show_count_obj = 15; - hand["joint3"] = 0; - break; - case 35: - show_step++; - show_count_obj = 15; - hand["joint4"] = 0; - break; - case 36: - show_step++; - show_count_obj = 15; - hand["joint5"] = 0; - break; - case 37: - show_step++; - show_count_obj = 40; - hand["joint1"] = 0; - hand["joint16"] = 200; - break; - case 38: - show_step++; - show_count_obj = 40; - hand["joint1"] = 250; - hand["joint16"] = 250; - break; - case 39: - show_step++; - show_count_obj = 30; - hand["joint2"] = 250; - hand["joint5"] = 250; - hand["joint17"] = 250; - hand["joint20"] = 250; - hand["joint22"] = 250; - hand["joint25"] = 250; - break; - case 40: - show_step++; - show_count_obj = 40; - hand["joint1"] = 100; - hand["joint6"] = 200; - hand["joint11"] = 100; - hand["joint16"] = 100; - break; - case 41: - show_step++; - show_count_obj = 15; - hand["joint7"] = 80; - hand["joint10"] = 200; - break; - case 42: - show_step++; - show_count_obj = 15; - hand["joint7"] = 200; - hand["joint10"] = 80; - break; - case 43: - show_step++; - show_count_obj = 15; - hand["joint7"] = 80; - hand["joint10"] = 200; - break; - case 44: - show_step++; - show_count_obj = 15; - hand["joint7"] = 200; - hand["joint10"] = 80; - break; - case 45: - show_step++; - show_count_obj = 15; - hand["joint7"] = 128; - hand["joint10"] = 128; - break; - case 46: - show_step++; - show_count_obj = 50; - hand["joint1"] = 250; - hand["joint3"] = 250; - hand["joint4"] = 250; - hand["joint6"] = 250; - hand["joint11"] = 250; - hand["joint16"] = 250; - hand["joint18"] = 250; - hand["joint19"] = 250; - hand["joint21"] = 250; - hand["joint23"] = 250; - hand["joint24"] = 250; - break; - case 47: - show_step++; - show_count_obj = 50; - hand["joint1"] = 40; - hand["joint2"] = 0; - hand["joint6"] = 100; - hand["joint11"] = 70; - hand["joint17"] = 240; - hand["joint22"] = 240; - break; - case 48: - show_step++; - show_count_obj = 20; - hand["joint2"] = 250; - hand["joint6"] = 220; - hand["joint11"] = 100; - hand["joint17"] = 250; - hand["joint22"] = 250; - break; - case 49: - show_step++; - show_count_obj = 35; - hand["joint3"] = 0; - hand["joint6"] = 70; - hand["joint11"] = 60; - hand["joint18"] = 220; - hand["joint23"] = 220; - break; - case 50: - show_step++; - show_count_obj = 20; - hand["joint3"] = 250; - hand["joint6"] = 100; - hand["joint11"] = 100; - hand["joint18"] = 250; - hand["joint23"] = 250; - break; - case 51: - show_step++; - show_count_obj = 35; - hand["joint4"] = 0; - hand["joint6"] = 30; - hand["joint11"] = 50; - hand["joint19"] = 220; - hand["joint24"] = 220; - break; - case 52: - show_step++; - show_count_obj = 20; - hand["joint4"] = 250; - hand["joint6"] = 100; - hand["joint11"] = 100; - hand["joint19"] = 250; - hand["joint24"] = 250; - break; - case 53: - show_step++; - show_count_obj = 40; - hand["joint5"] = 0; - hand["joint6"] = 0; - hand["joint11"] = 40; - hand["joint20"] = 230; - hand["joint25"] = 230; - break; - case 54: - show_step++; - show_count_obj = 20; - hand["joint5"] = 20; - hand["joint6"] = 0; - hand["joint11"] = 100; - hand["joint20"] = 250; - hand["joint25"] = 250; - break; - case 55: - show_step++; - show_count_obj = 40; - hand["joint1"] = 175; - hand["joint5"] = 175; - hand["joint6"] = 0; - hand["joint11"] = 0; - hand["joint16"] = 130; - hand["joint20"] = 100; - hand["joint21"] = 130; - hand["joint25"] = 80; - break; - case 56: - show_step++; - show_count_obj = 20; - hand["joint1"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 0; - hand["joint11"] = 0; - hand["joint16"] = 250; - hand["joint20"] = 250; - hand["joint21"] = 250; - hand["joint25"] = 250; - break; - case 57: - show_step++; - show_count_obj = 35; - hand["joint1"] = 170; - hand["joint4"] = 170; - hand["joint6"] = 30; - hand["joint11"] = 50; - hand["joint16"] = 130; - hand["joint19"] = 80; - hand["joint21"] = 130; - hand["joint24"] = 80; - break; - case 58: - show_step++; - show_count_obj = 20; - hand["joint1"] = 250; - hand["joint4"] = 250; - hand["joint6"] = 30; - hand["joint11"] = 50; - hand["joint16"] = 250; - hand["joint19"] = 250; - hand["joint21"] = 250; - hand["joint24"] = 250; - break; - case 59: - show_step++; - show_count_obj = 35; - hand["joint1"] = 155; - hand["joint3"] = 155; - hand["joint6"] = 70; - hand["joint11"] = 60; - hand["joint16"] = 130; - hand["joint18"] = 90; - hand["joint21"] = 130; - hand["joint24"] = 80; - break; - case 60: - show_step++; - show_count_obj = 20; - hand["joint1"] = 250; - hand["joint3"] = 250; - hand["joint6"] = 100; - hand["joint11"] = 100; - hand["joint16"] = 250; - hand["joint18"] = 250; - hand["joint21"] = 250; - hand["joint24"] = 250; - break; - case 61: - show_step++; - show_count_obj = 35; - hand["joint1"] = 165; - hand["joint2"] = 165; - hand["joint6"] = 100; - hand["joint11"] = 70; - hand["joint16"] = 130; - hand["joint18"] = 80; - hand["joint21"] = 130; - hand["joint24"] = 80; - break; - default: - show_step = 0; - break; - } - } - - std::vector positions; - positions.clear(); - for (const auto& joint : joint_order) { // 按照固定的顺序提取关节值 - positions.push_back(static_cast(hand[joint])); - } - return positions; -} - -class HandController : public rclcpp::Node { -public: - HandController() : Node("dong_test_sender") { - publisher_ = this->create_publisher("/right_hand_control", 10); - timer_ = this->create_wall_timer(33ms, std::bind(&HandController::publishJointState, this)); - } - -private: - - void publishJointState() { - sensor_msgs::msg::JointState joint_state; - joint_state.header.stamp = this->now(); - std::vector joint_names; - for (const auto& entry : hand) { - joint_names.push_back(entry.first); - } - joint_state.name = joint_names; - vector position = show_left(); - joint_state.position = position; - joint_state.velocity.resize(position.size(), 100.0); - joint_state.effort.resize(position.size(), 200.0); - publisher_->publish(joint_state); - } - - rclcpp::Publisher::SharedPtr publisher_; - rclcpp::TimerBase::SharedPtr timer_; -}; - -void signal_handler(int sig) { - (void) sig; - rclcpp::shutdown(); -} - -int main(int argc, char** argv) { - std::signal(SIGINT, signal_handler); - rclcpp::init(argc, argv); - rclcpp::spin(std::make_shared()); - rclcpp::shutdown(); - return 0; -} \ No newline at end of file diff --git a/src/examples/src/action_group_show_l7.cpp b/src/examples/src/action_group_show_l7.cpp deleted file mode 100644 index 19266ba..0000000 --- a/src/examples/src/action_group_show_l7.cpp +++ /dev/null @@ -1,396 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace std::chrono_literals; -using namespace std::placeholders; - -int show_count = 0; -int show_count_obj = 0; -int show_step = 0; - -std::map hand = { - {"joint1", 255}, // 拇指根部弯曲 - {"joint2", 128}, // 拇指侧摆 - {"joint3", 255}, // 食指根部弯曲 - {"joint4", 255}, // 中指根部弯曲 - {"joint5", 255}, // 无名指根部弯曲 - {"joint6", 255}, // 小指根部弯曲 - {"joint7", 255} // 拇指旋转 -}; - -class HandController : public rclcpp::Node { -public: - HandController() : Node("dong_test_sender") { - publisher_ = this->create_publisher("/right_hand_control", 10); - timer_ = this->create_wall_timer(30ms, std::bind(&HandController::timer_callback, this)); - } - -private: - void timer_callback() { - auto message = sensor_msgs::msg::JointState(); - message.header.stamp = this->now(); - message.name = {"joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7"}; - message.position = show_left(); - message.velocity.resize(message.position.size(), 100.0); - message.effort.resize(message.position.size(), 200.0); - publisher_->publish(message); - } - - std::vector show_left() { - show_count++; - if (show_count >= show_count_obj) { - show_count = 0; - switch (show_step) { - case 0: - show_step++; - show_count_obj = 100; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint3"] = 250; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint7"] = 250; - break; - case 1: - show_step++; - show_count_obj = 10; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint5"] = 0; - hand["joint6"] = 0; - hand["joint7"] = 250; - break; - case 2: - show_step += 8; - show_count_obj = 30; - hand["joint1"] = 40; - hand["joint2"] = 240; - hand["joint7"] = 80; - break; - case 3: - show_step++; - break; - case 4: - show_step++; - break; - case 5: - show_step++; - break; - case 6: - show_step++; - break; - case 7: - show_step++; - break; - case 8: - show_step++; - break; - case 9: - show_step++; - break; - case 10: - show_step++; - show_count_obj = 15; - hand["joint3"] = 100; - hand["joint4"] = 100; - break; - case 11: - show_step++; - show_count_obj = 15; - hand["joint3"] = 250; - hand["joint4"] = 250; - break; - case 12: - show_step++; - show_count_obj = 15; - hand["joint3"] = 100; - hand["joint4"] = 100; - break; - case 13: - show_step++; - show_count_obj = 15; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint3"] = 250; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint7"] = 250; - break; - case 14: - show_step++; - show_count_obj = 40; - hand["joint1"] = 40; - hand["joint2"] = 240; - hand["joint7"] = 120; - break; - case 15: - show_step++; - break; - case 16: - show_step++; - show_count_obj = 30; - hand["joint3"] = 10; - hand["joint4"] = 10; - hand["joint5"] = 10; - hand["joint6"] = 10; - break; - case 17: - show_step++; - show_count_obj = 15; - hand["joint6"] = 250; - break; - case 18: - show_step++; - show_count_obj = 15; - hand["joint5"] = 250; - break; - case 19: - show_step++; - show_count_obj = 15; - hand["joint4"] = 250; - break; - case 20: - show_step++; - show_count_obj = 15; - hand["joint3"] = 250; - break; - case 21: - show_step += 4; - show_count_obj = 20; - hand["joint1"] = 250; - hand["joint2"] = 110; - hand["joint7"] = 240; - break; - case 22: - show_step++; - show_count_obj = 20; - hand["joint1"] = 250; - hand["joint2"] = 10; - hand["joint7"] = 110; - break; - case 23: - show_step++; - show_count_obj = 40; - hand["joint1"] = 0; - hand["joint2"] = 10; - hand["joint7"] = 110; - break; - case 24: - show_step++; - show_count_obj = 30; - hand["joint1"] = 0; - hand["joint2"] = 240; - hand["joint7"] = 110; - break; - case 25: - show_step += 4; - show_count_obj = 50; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint7"] = 110; - break; - case 26: - show_step++; - break; - case 27: - show_step++; - break; - case 28: - show_step++; - break; - case 29: - show_step += 4; - show_count_obj = 15; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint7"] = 250; - break; - case 30: - show_step++; - break; - case 31: - show_step++; - break; - case 32: - show_step++; - break; - case 33: - show_step++; - show_count_obj = 15; - hand["joint3"] = 0; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint7"] = 250; - break; - case 34: - show_step++; - show_count_obj = 15; - hand["joint4"] = 0; - break; - case 35: - show_step++; - show_count_obj = 15; - hand["joint5"] = 0; - break; - case 36: - show_step++; - show_count_obj = 15; - hand["joint6"] = 0; - break; - case 37: - show_step++; - show_count_obj = 40; - hand["joint1"] = 0; - break; - case 38: - show_step++; - show_count_obj = 40; - hand["joint1"] = 250; - hand["joint2"] = 230; - hand["joint7"] = 250; - break; - case 39: - show_step++; - show_count_obj = 30; - hand["joint3"] = 250; - hand["joint6"] = 250; - break; - case 40: - show_step++; - show_count_obj = 40; - hand["joint1"] = 10; - hand["joint2"] = 40; - hand["joint7"] = 60; - break; - case 41: - show_step += 5; - show_count_obj = 5; - hand["joint1"] = 50; - break; - case 42: - show_step++; - break; - case 43: - show_step++; - break; - case 44: - show_step++; - break; - case 45: - show_step++; - break; - case 46: - show_step++; - show_count_obj = 50; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint3"] = 250; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint7"] = 250; - break; - case 47: - show_step++; - show_count_obj = 50; - hand["joint1"] = 120; - hand["joint2"] = 130; - hand["joint3"] = 155; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint7"] = 90; - break; - case 48: - show_step++; - show_count_obj = 20; - hand["joint1"] = 250; - hand["joint3"] = 250; - break; - case 49: - show_step++; - show_count_obj = 35; - hand["joint1"] = 120; - hand["joint4"] = 140; - hand["joint7"] = 60; - break; - case 50: - show_step++; - show_count_obj = 30; - hand["joint1"] = 250; - hand["joint4"] = 250; - break; - case 51: - show_step++; - show_count_obj = 35; - hand["joint1"] = 120; - hand["joint2"] = 125; - hand["joint5"] = 145; - hand["joint7"] = 40; - break; - case 52: - show_step++; - show_count_obj = 30; - hand["joint1"] = 250; - hand["joint5"] = 250; - break; - case 53: - show_step++; - show_count_obj = 40; - hand["joint1"] = 120; - hand["joint6"] = 135; - hand["joint7"] = 15; - break; - case 54: - show_step++; - show_count_obj = 20; - hand["joint1"] = 250; - hand["joint2"] = 250; - hand["joint3"] = 250; - hand["joint4"] = 250; - hand["joint5"] = 250; - hand["joint6"] = 250; - hand["joint7"] = 250; - break; - default: - show_step = 0; - break; - } - } - - std::vector positions; - for (const auto& entry : hand) { - positions.push_back(static_cast(entry.second)); - } - return positions; - } - - rclcpp::Publisher::SharedPtr publisher_; - rclcpp::TimerBase::SharedPtr timer_; -}; - -void signal_handler(int sig) { - (void) sig; - rclcpp::shutdown(); -} - -int main(int argc, char** argv) { - std::signal(SIGINT, signal_handler); - rclcpp::init(argc, argv); - rclcpp::spin(std::make_shared()); - rclcpp::shutdown(); - return 0; -} \ No newline at end of file diff --git a/src/examples/src/linker_hand_node.cpp b/src/examples/src/linker_hand_node.cpp deleted file mode 100644 index 345bb2e..0000000 --- a/src/examples/src/linker_hand_node.cpp +++ /dev/null @@ -1,482 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include - -#include "LinkerHandApi.h" -#include "CommFactory.h" - -using namespace std::chrono_literals; - -static std::string can_channel; - -std::string bytesToHex(const std::vector &bytes) -{ - std::string hexStr; - for (uint8_t byte : bytes) - { - hexStr += std::to_string(byte) + " "; - } - return hexStr; -} - -std::string bytesToHex(const std::vector> &bytes) -{ - std::string hexStr; - for (const auto &vec : bytes) - { - for (uint8_t byte : vec) - { - hexStr += std::to_string(byte) + " "; - } - hexStr += "\n"; - } - return hexStr; -} - -class LinkerHand : public rclcpp::Node -{ -public: - LinkerHand() : Node("linker_hand_cpp_ros2") - { - - // 声明并获取话题参数 - this->declare_parameter("VERSION", "sdk-0.0"); - this->declare_parameter("HAND_EXISTS", false); - this->declare_parameter("HAND_TYPE", 0); - this->declare_parameter("HAND_TOUCH", false); - this->declare_parameter("HAND_JOINTS", "L7"); - this->declare_parameter("HAND_SPEED", 50); - this->declare_parameter("HAND_EFFORT", 200); - this->declare_parameter("CAN_CHANNEL", "can0"); - this->declare_parameter("CAN_BITRATE", 1000000); - - this->declare_parameter("HAND_SETTING_TOPIC", "/cb_hand_setting_cmd"); - this->declare_parameter("HAND_CONTROL_TOPIC", "/cb_hand_control_cmd"); - this->declare_parameter("HAND_STATE_TOPIC", "/cb_hand_state"); - this->declare_parameter("HAND_TOUCH_TOPIC", "/cb_hand_touch"); - this->declare_parameter("HAND_INFO_TOPIC", "/cb_hand_info"); - // 获取话题参数 - this->get_parameter("HAND_SETTING_TOPIC", hand_setting_topic); - this->get_parameter("HAND_CONTROL_TOPIC", hand_control_topic); - this->get_parameter("HAND_STATE_TOPIC", hand_state_topic); - this->get_parameter("HAND_TOUCH_TOPIC", hand_touch_topic); - this->get_parameter("HAND_INFO_TOPIC", hand_info_topic); - - // LinkerHand - this->get_parameter("VERSION", version); - this->get_parameter("HAND_EXISTS", hand_exists); - this->get_parameter("HAND_TYPE", hand_type); - this->get_parameter("HAND_TOUCH", hand_touch); - this->get_parameter("HAND_JOINTS", hand_joints); - this->get_parameter("HAND_SPEED", hand_speed); - this->get_parameter("HAND_EFFORT", hand_effort); - this->get_parameter("CAN_CHANNEL", can_channel); - this->get_parameter("CAN_BITRATE", can_bitrate); - - // std::cout << "LinkerHand SDK version: " << version << std::endl; - std::cout << (hand_type == 0 ? "LEFT_HAND" : "RIGHT_HAND") << " HAND_EXISTS:" << hand_exists << " HAND_JOINTS:" << hand_joints << " HAND_TOUCH:" << hand_touch << std::endl; - - if (hand_exists != true) return; - - // LinkerHandType mapping - std::map linker_hand_map = { - {"L6", LINKER_HAND::L6}, - {"L7", LINKER_HAND::L7}, - {"L10", LINKER_HAND::L10}, - {"L20", LINKER_HAND::L20}, - {"L21", LINKER_HAND::L21}, - {"L25", LINKER_HAND::L25}, - {"O6", LINKER_HAND::O6}, - {"O20", LINKER_HAND::O20}, - {"G20", LINKER_HAND::G20} - }; - - if (hand_exists) { - auto it = linker_hand_map.find(hand_joints); - if (it != linker_hand_map.end()) { - if (it->second == LINKER_HAND::O20) { - RCLCPP_ERROR(this->get_logger(), "O20 requires CAN-FD and is not supported by this CAN-only ROS2 node yet"); - return; - } - if (hand_type == 0) { - hand_api = std::make_unique(it->second, HAND_TYPE::LEFT, COMM_TYPE::CAN); - } else if (hand_type == 1) { - hand_api = std::make_unique(it->second, HAND_TYPE::RIGHT, COMM_TYPE::CAN); - } else { - std::cout << "Invalid hand_type: " << hand_type << std::endl; - return; - } - } else { - std::cout << "Invalid hand: " << hand_joints << std::endl; - return; - } - } - - can_bus = std::shared_ptr( - Communication::CommFactory::createCanBus(can_channel, can_bitrate)); - - hand_api->setCanTxCallback([this](uint32_t can_id, const uint8_t *data, uintptr_t data_len) -> int32_t { - try { - std::vector data_vec(data, data + data_len); - can_bus->send(data_vec, can_id); - return 0; - } catch (const std::exception &e) { - RCLCPP_ERROR(this->get_logger(), "CAN send error: %s", e.what()); - return -1; - } - }); - - hand_api->setCanRxCallback([this](uint32_t *can_id_out, uint8_t *data_out, uint8_t *len_out) -> int32_t { - try { - auto frame = can_bus->recv(); - if (frame.can_id == 0 && frame.can_dlc == 0) { - return -1; - } - *can_id_out = frame.can_id; - *len_out = frame.can_dlc; - std::memcpy(data_out, frame.data, frame.can_dlc); - return 0; - } catch (const std::exception &e) { - RCLCPP_ERROR(this->get_logger(), "CAN receive error: %s", e.what()); - return -1; - } - }); - - if (hand_exists) initHand(hand_api, hand_joints); - - auto hand_control_cmd_cb = [this](sensor_msgs::msg::JointState::SharedPtr msg) -> void - { - if (hand_exists) controlHand(hand_api, hand_joints, msg); - }; - - // auto hand_control_cmd_arc_cb = [this](sensor_msgs::msg::JointState::SharedPtr msg) -> void - // { - // if (hand_exists) controlHand(hand_api, hand_joints, msg, true); - // }; - - auto hand_setting_cb = [this](std_msgs::msg::String::SharedPtr msg) -> void - { - // std::cout << "hand_setting_cb: " << msg->data << std::endl; - - // bool setting_left = false, setting_right = false; - try { - nlohmann::json data = nlohmann::json::parse(msg->data); - RCLCPP_INFO(this->get_logger(), "command:%s", data["setting_cmd"].get().c_str()); - RCLCPP_INFO(this->get_logger(), "data:%s", data.dump().c_str()); - - // if (data["params"]["hand_type"] == "left" && hand_exists) { - // setting_left = true; - // } else if (data["params"]["hand_type"] == "right" && right_hand_exists) { - // setting_right = true; - // } else { - // RCLCPP_ERROR(this->get_logger(), "hand type invalid !"); - // return; - // } - - // clearFaultCode - if (data["setting_cmd"] == "clear_faults") { - hand_api->clearFaultCode(); - } - - if (data["setting_cmd"] == "set_electric_current") { - RCLCPP_WARN(this->get_logger(), "set_electric_current is not supported by linkerhand-cpp-sdk v2.1.7"); - } - - // enable - if (data["setting_cmd"] == "enable") { - hand_api->setEnable(); - } - - // disable - if (data["setting_cmd"] == "disable") { - hand_api->setDisable(); - } - } catch (const std::exception &e) { - RCLCPP_ERROR(this->get_logger(), "Command parameter error : %s", e.what()); - } - }; - - sub_settings = this->create_subscription(hand_setting_topic, 10, hand_setting_cb); - sub_hand_control = this->create_subscription(hand_control_topic, 10, hand_control_cmd_cb); - // sub_hand_control_arc = this->create_subscription("/cb_hand_control_cmd_arc", 10, hand_control_cmd_arc_cb); - - pub_hand_touch_ = this->create_publisher(hand_touch_topic, 10); - pub_hand_info_ = this->create_publisher(hand_info_topic, 10); - pub_hand_state_ = this->create_publisher(hand_state_topic, 10); - // pub_hand_state_arc_ = this->create_publisher("/cb_hand_state_arc", 10); - - - // auto start_time = std::chrono::high_resolution_clock::now(); - // auto end_time = std::chrono::high_resolution_clock::now(); - // auto duration = std::chrono::duration_cast>(end_time - start_time); - - // start_time = std::chrono::high_resolution_clock::now(); - - // end_time = std::chrono::high_resolution_clock::now(); - // duration = std::chrono::duration_cast>(end_time - start_time); - // std::cout << "delay time: " << duration.count() << " seconds" << std::endl; - - - if (hand_exists && hand_touch) { - pub_touch_thread = std::thread([this]() { - while (rclcpp::ok()) { - // rclcpp::spin_some(this); - if (pub_hand_touch_->get_subscription_count() > 0) { - publishTouchData(hand_api, *this->pub_hand_touch_); - } - std::this_thread::sleep_for(std::chrono::milliseconds(125)); - } - }); - } - - pub_state_thread = std::thread([this]() { - while (rclcpp::ok()) { - if (pub_hand_state_->get_subscription_count() > 0) { - publishJointState(hand_api, *this->pub_hand_state_); - // publishJointState(hand_api, *this->pub_hand_state_arc_, true); - } - std::this_thread::sleep_for(std::chrono::milliseconds(20)); - } - }); - - pub_info_thread = std::thread([this]() { - while (rclcpp::ok()) { - if (pub_hand_info_->get_subscription_count() > 0) { - publishLinkerHandInfo(hand_api, *this->pub_hand_info_); - } - std::this_thread::sleep_for(std::chrono::milliseconds(1000)); - } - }); - } - - ~LinkerHand() - { - // Ensure the release of resources in the destructor - hand_api.reset(); - - if (pub_touch_thread.joinable()) pub_touch_thread.join(); - if (pub_state_thread.joinable()) pub_state_thread.join(); - if (pub_info_thread.joinable()) pub_info_thread.join(); - } - - bool isActive() const - { - return hand_api != nullptr; - } - - // General function: Publish touch sensor data - template - void publishTouchData(const HandType& hand, rclcpp::Publisher& publisher) - { - auto message = std_msgs::msg::Float32MultiArray(); - - const auto& touch = hand->getForce(); - - for (const auto& finger_data : touch) { - for (const auto& sensor_row : finger_data) { - for (uint8_t byte : sensor_row) { - message.data.push_back(static_cast(byte)); - } - } - } - publisher.publish(message); - } - - // General function: public joint state - template - void publishJointState(const HandType& hand, rclcpp::Publisher& publisher, const bool is_arc = false) - { - auto message = sensor_msgs::msg::JointState(); - - (is_arc) ? message.position = hand->getStateArc() : message.position = convert(hand->getState()); - message.velocity = convert(hand->getSpeed()); - message.effort = convert(hand->getTorque()); - - publisher.publish(message); - } - - // General function: publish linker hand info - template - void publishLinkerHandInfo(const HandType& hand, rclcpp::Publisher& publisher) - { - auto message = std_msgs::msg::String(); - message.data = hand->getVersion() + "Temperature: " + vectorToString(hand->getTemperature()) + "\nFaultCode: " + vectorToString(hand->getFaultCode()); - publisher.publish(message); - - - } - - // General function: Convert vector - template - std::vector convert(const std::vector& vec) { - std::vector result; - for (const auto& value : vec) { - result.push_back(static_cast(value)); - } - return result; - } - - // General function: subscribe Hand Control Information - template - void controlHand(const HandType& hand, const std::string& hand_name, const sensor_msgs::msg::JointState::SharedPtr msg, const bool is_arc = false) - { - // for (auto &p : msg->position) - // { - // std::cout << p << " "; - // } - // std::cout << std::endl; - - // init hand speed and effort - std::vector speed(5, hand_speed); - std::vector effort(5, hand_effort); - - const auto expected_joint_count = getJointCount(hand_name); - if (expected_joint_count == 0 || msg->position.size() != expected_joint_count) { - std::cout << hand_name << " Invalid joint number: " << msg->position.size() << std::endl; - return; - } - - if (msg->velocity.size() == expected_joint_count) { - speed = convert(msg->velocity); - } else { - speed = std::vector(expected_joint_count, hand_speed); - } - if (msg->effort.size() == expected_joint_count) { - effort = convert(msg->effort); - } else { - effort = std::vector(expected_joint_count, hand_effort); - } - - hand->setSpeed(speed); - hand->setTorque(effort); - (is_arc) ? hand->fingerMoveArc(msg->position) : hand->fingerMove(convert(msg->position)); - } - - size_t getJointCount(const std::string& hand_name) const - { - if (hand_name == "L6" || hand_name == "O6") return 6; - if (hand_name == "L7") return 7; - if (hand_name == "L10") return 10; - if (hand_name == "L20") return 20; - if (hand_name == "L21") return 21; - if (hand_name == "L25") return 25; - if (hand_name == "G20") return 16; - if (hand_name == "O20") return 34; - return 0; - } - - // std::vector to std::string - std::string vectorToString(const std::vector& vec) { - std::ostringstream oss; - for (size_t i = 0; i < vec.size(); ++i) { - if (i > 0) { - oss << " "; - } - oss << static_cast(vec[i]); - } - return oss.str(); - } - - template - void initHand(const HandType& hand, const std::string& hand_name) - { - if (hand_name == "L10") - { - hand->setSpeed(std::vector(5, hand_speed)); // speed - hand->setTorque(std::vector(5, hand_effort)); // torque - hand->fingerMove({255, 128, 255, 255, 255, 255, 128, 128, 128, 128}); // joint position - } - } - -private: - rclcpp::Subscription::SharedPtr sub_hand_control; - rclcpp::Subscription::SharedPtr sub_hand_control_arc; - rclcpp::Subscription::SharedPtr sub_settings; - // rclcpp::TimerBase::SharedPtr timer_; - // rclcpp::TimerBase::SharedPtr timer_state_; - // rclcpp::TimerBase::SharedPtr timer_info_; - // rclcpp::TimerBase::SharedPtr timer_touch_; - rclcpp::Publisher::SharedPtr pub_hand_info_; - rclcpp::Publisher::SharedPtr pub_hand_touch_; - rclcpp::Publisher::SharedPtr pub_hand_state_; - rclcpp::Publisher::SharedPtr pub_hand_state_arc_; - - std::unique_ptr hand_api; - - std::string version; - bool hand_exists; - bool hand_touch; - std::string hand_joints; - - int hand_speed; - int hand_effort; - int can_bitrate; - std::shared_ptr can_bus; - - // 创建线程对象 - std::thread pub_touch_thread; - std::thread pub_state_thread; - std::thread pub_info_thread; - - std::string hand_setting_topic; - std::string hand_state_topic; - std::string hand_state_arc_topic; - std::string hand_info_topic; - std::string hand_touch_topic; - std::string hand_control_topic; - std::string hand_control_arc_topic; - - int hand_type; -}; - -// signal -void signalHandler(int signal) { - if (signal == SIGINT) { - // stop can channel - int result = system(std::string("sudo ip link set " + can_channel + " down").c_str()); - if (result == 0) { - std::cout << ((can_channel == "can0") ? "CAN0" : "CAN1") <<" interface stopped successfully." << std::endl; - } - - RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "Ctrl+C detected. Shutting down..."); - rclcpp::shutdown(); - } -} - -int main(int argc, char *argv[]) -{ - rclcpp::init(argc, argv); - - // register signal - std::signal(SIGINT, signalHandler); - - auto node = std::make_shared(); - if (!node->isActive()) { - RCLCPP_INFO(rclcpp::get_logger("rclcpp"), "Hand is disabled or failed to initialize. Exiting."); - rclcpp::shutdown(); - return 0; - } - - rclcpp::spin(node); - return 0; -} diff --git a/src/examples/src/loop_l20.cpp b/src/examples/src/loop_l20.cpp deleted file mode 100644 index 8758e69..0000000 --- a/src/examples/src/loop_l20.cpp +++ /dev/null @@ -1,73 +0,0 @@ -// 本demo仅支持L20,默认为右手,如需左手,请修改hand_type - -#include -#include -#include -#include -#include -#include - -using namespace std; -using namespace std::chrono_literals; - -bool running = true; -vector pos1 = {255, 255, 255, 255, 255, 255, 10, 100, 180, 240, 245, 255, 255, 255, 255, 255, 255, 255, 255, 255}; -vector pos2 = {69.0, 0.0, 0.0, 0.0, 0.0, 151.0, 10.0, 100.0, 180.0, 240.0, 14.0, 255.0, 255.0, 255.0, 255.0, 109.0, 0.0, 0.0, 0.0, 0.0}; -vector joint_names = {"joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7", "joint8", "joint9", "joint10", "joint11", "joint12", "joint13", "joint14", "joint15", "joint16", "joint17", "joint18", "joint19", "joint20"}; - - -class JointStatePublisher : public rclcpp::Node { -public: - JointStatePublisher() : Node("dong_test_sender") { - string hand_type = "right"; // 控制右手 - string topic_name; - if (hand_type == "left") { - topic_name = "/left_hand_control"; - } else if (hand_type == "right") { - topic_name = "/right_hand_control"; - } - joint_state_pub = this->create_publisher(topic_name, 10); - timer = this->create_wall_timer(33ms, std::bind(&JointStatePublisher::publishJointState, this)); - } - -private: - void publishJointState() { - sensor_msgs::msg::JointState joint_state; - joint_state.header.stamp = this->now(); - joint_state.name = joint_names; - joint_state.velocity.resize(joint_names.size(), 100.0); - joint_state.effort.resize(joint_names.size(), 200.0); - - static int msg_count = 0; - if (msg_count < 100) { - joint_state.position = pos1; - msg_count++; - } else if (msg_count < 200) { - joint_state.position = pos2; - msg_count++; - } else { - msg_count = 0; - count++; - RCLCPP_INFO(this->get_logger(), "Loop completed %d times", count); - } - if (joint_state.position.size() > 0) - joint_state_pub->publish(joint_state); - } - - rclcpp::Publisher::SharedPtr joint_state_pub; - rclcpp::TimerBase::SharedPtr timer; - int count = 0; -}; - -void signalHandler(int sig) { - (void) sig; - running = false; - rclcpp::shutdown(); -} - -int main(int argc, char **argv) { - rclcpp::init(argc, argv); - signal(SIGINT, signalHandler); - rclcpp::spin(std::make_shared()); - return 0; -} \ No newline at end of file diff --git a/src/examples/src/loop_l25.cpp b/src/examples/src/loop_l25.cpp deleted file mode 100644 index 65f5ba5..0000000 --- a/src/examples/src/loop_l25.cpp +++ /dev/null @@ -1,104 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include - -using namespace std::chrono_literals; -using namespace std::placeholders; - -std::string hand_joint = "L25"; // 控制L25版本灵巧手 -std::string hand_type = "right"; // 控制左手 - -class HandController : public rclcpp::Node { -public: - HandController() : Node("dong_test_sender") { - if (hand_type == "left") { - publisher_ = this->create_publisher("/left_hand_control", 10); - } else if (hand_type == "right") { - publisher_ = this->create_publisher("/right_hand_control", 10); - } - - if (hand_joint == "L25") { - pos1_1 = {230, 0, 0, 15, 5, 250, 55, 0, 75, 95, 85, 0, 0, 0, 0, 250, 0, 40, 35, 5, 250, 0, 5, 0, 0}; - pos1_2 = {80, 255, 255, 255, 255, 180, 51, 51, 72, 202, 202, 255.0, 255.0, 255.0, 255.0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255}; - pos2_1 = {230, 0, 0, 15, 5, 250, 55, 0, 75, 95, 85, 0, 0, 0, 0, 80, 0, 40, 35, 5, 250, 0, 5, 0, 0}; - pos2_2 = {230, 0, 0, 15, 5, 42, 55, 0, 75, 95, 85, 0, 0, 0, 0, 90, 0, 40, 35, 5, 120, 0, 5, 0, 0}; - } - - joint_state.name = { - "joint1", "joint2", "joint3", "joint4", "joint5", "joint6", - "joint7", "joint8", "joint9", "joint10", "joint11", "joint12", - "joint13", "joint14", "joint15", "joint16", "joint17", "joint18", - "joint19", "joint20" - }; - - timer_ = this->create_wall_timer(33ms, std::bind(&HandController::timer_callback, this)); - } - -private: - void timer_callback() { - auto message = sensor_msgs::msg::JointState(); - message.header.stamp = this->now(); - - joint_state.position = pos2_1; - // joint_state.velocity.resize(joint_state.position.size(), 0.0); - // joint_state.effort.resize(joint_state.position.size(), 0.0); - message = joint_state; - publisher_->publish(message); - RCLCPP_INFO(this->get_logger(), "Published position 1"); - - std::this_thread::sleep_for(1.3s); - - joint_state.position = pos2_2; - // joint_state.velocity.resize(joint_state.position.size(), 0.0); - // joint_state.effort.resize(joint_state.position.size(), 0.0); - message = joint_state; - publisher_->publish(message); - RCLCPP_INFO(this->get_logger(), "Published position 2"); - - std::this_thread::sleep_for(5s); - - joint_state.position = pos1_1; - // joint_state.velocity.resize(joint_state.position.size(), 0.0); - // joint_state.effort.resize(joint_state.position.size(), 0.0); - message = joint_state; - publisher_->publish(message); - RCLCPP_INFO(this->get_logger(), "Published position 3"); - - std::this_thread::sleep_for(0.5s); - - joint_state.position = pos1_2; - // joint_state.velocity.resize(joint_state.position.size(), 0.0); - // joint_state.effort.resize(joint_state.position.size(), 0.0); - message = joint_state; - publisher_->publish(message); - RCLCPP_INFO(this->get_logger(), "Published position 4"); - - RCLCPP_INFO(this->get_logger(), "Cycle completed %d times", count); - count++; - std::this_thread::sleep_for(3s); - } - - rclcpp::Publisher::SharedPtr publisher_; - rclcpp::TimerBase::SharedPtr timer_; - sensor_msgs::msg::JointState joint_state; - std::vector pos1_1, pos1_2, pos2_1, pos2_2; - int count = 0; -}; - -void signal_handler(int sig) { - (void) sig; - rclcpp::shutdown(); -} - -int main(int argc, char** argv) { - std::signal(SIGINT, signal_handler); - rclcpp::init(argc, argv); - rclcpp::spin(std::make_shared()); - rclcpp::shutdown(); - return 0; -} \ No newline at end of file diff --git a/src/examples/src/show_ok_l20.cpp b/src/examples/src/show_ok_l20.cpp deleted file mode 100644 index 831d27f..0000000 --- a/src/examples/src/show_ok_l20.cpp +++ /dev/null @@ -1,105 +0,0 @@ -// 本demo仅支持L20,默认为右手,如需左手,请修改话题名 - -#include -#include -#include -#include -#include -#include - -using namespace std; -using namespace std::chrono_literals; - -bool running = true; - -int show_count = 0; -int show_count_obj = 0; -int show_step = 0; - -vector show_left() { - vector position; - show_count++; - if (show_count >= show_count_obj) { - show_count = 0; - switch (show_step) { - case 0: - show_step++; - show_count_obj = 40; - position = {128, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 1: - show_step++; - show_count_obj = 40; - position = {190, 50, 250, 250, 250, 250, 200, 120, 168, 250, 140, 0, 0, 0, 0, 5, 5, 250, 250, 250}; - break; - case 2: - show_step++; - show_count_obj = 33; - position = {190, 50, 250, 250, 250, 250, 200, 120, 168, 250, 140, 0, 0, 0, 0, 5, 5, 5, 5, 5}; - break; - case 3: - show_step++; - show_count_obj = 30; - position = {190, 50, 250, 250, 250, 250, 200, 120, 168, 250, 140, 0, 0, 0, 0, 5, 5, 250, 250, 250}; - break; - case 4: - show_step++; - show_count_obj = 30; - position = {190, 50, 250, 250, 250, 250, 200, 120, 168, 250, 140, 0, 0, 0, 0, 5, 5, 5, 5, 5}; - break; - case 5: - show_step++; - show_count_obj = 15; - position = {190, 50, 250, 250, 250, 250, 200, 120, 168, 250, 140, 0, 0, 0, 0, 5, 5, 250, 250, 250}; - break; - case 6: - show_step++; - show_count_obj = 2; - position = {190, 50, 250, 250, 250, 250, 80, 120, 168, 250, 140, 0, 0, 0, 0, 5, 5, 250, 250, 250}; - break; - default: - show_step = 0; - position = {128, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - } - } - return position; -} - -class JointStatePublisher : public rclcpp::Node { -public: - JointStatePublisher() : Node("dong_test_sender") { - joint_state_pub = this->create_publisher("/right_hand_control", 10); - timer = this->create_wall_timer(std::chrono::milliseconds(33), std::bind(&JointStatePublisher::publishJointState, this)); - } - -private: - void publishJointState() { - sensor_msgs::msg::JointState joint_state; - joint_state.header.stamp = this->now(); - joint_state.name = {"joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7", "joint8", "joint9", "joint10", "joint11", "joint12", "joint13", "joint14", "joint15", "joint16", "joint17", "joint18", "joint19", "joint20"}; - vector position = show_left(); - if (!position.empty()) { - joint_state.position = position; - } - joint_state.velocity.resize(joint_state.position.size(), 100.0); - joint_state.effort.resize(joint_state.position.size(), 200.0); - if (joint_state.position.size() > 0) joint_state_pub->publish(joint_state); - } - - rclcpp::Publisher::SharedPtr joint_state_pub; - rclcpp::TimerBase::SharedPtr timer; -}; - -void signalHandler(int sig) { - (void) sig; - running = false; - rclcpp::shutdown(); -} - -int main(int argc, char **argv) { - rclcpp::init(argc, argv); - signal(SIGINT, signalHandler); - rclcpp::spin(std::make_shared()); - return 0; -} \ No newline at end of file diff --git a/src/examples/src/show_surround_index_finger_l20.cpp b/src/examples/src/show_surround_index_finger_l20.cpp deleted file mode 100644 index cf016cb..0000000 --- a/src/examples/src/show_surround_index_finger_l20.cpp +++ /dev/null @@ -1,97 +0,0 @@ -// 本demo仅支持L20,默认为右手,如需左手,请修改话题名 - -#include -#include -#include -#include -#include -#include - -using namespace std; -using namespace std::chrono_literals; - -bool running = true; - -int show_count = 0; -int show_count_obj = 0; -int show_step = 0; - -vector show_left() { - vector position; - show_count++; - if (show_count >= show_count_obj) { - show_count = 0; - switch (show_step) { - case 0: - show_step++; - show_count_obj = 50; - position = {128, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 1: - show_step++; - show_count_obj = 40; - position = {190, 250, 5, 5, 5, 120, 128, 128, 128, 120, 180, 0, 0, 0, 0, 5, 250, 5, 5, 5}; - break; - case 2: - show_step++; - show_count_obj = 13; - position = {190, 210, 5, 5, 5, 120, 5, 128, 128, 120, 180, 0, 0, 0, 0, 5, 250, 5, 5, 5}; - break; - case 3: - show_step++; - show_count_obj = 13; - position = {190, 170, 5, 5, 5, 120, 128, 128, 128, 120, 180, 0, 0, 0, 0, 5, 250, 5, 5, 5}; - break; - case 4: - show_step++; - show_count_obj = 13; - position = {190, 210, 5, 5, 5, 120, 250, 128, 128, 120, 180, 0, 0, 0, 0, 5, 250, 5, 5, 5}; - break; - case 5: - show_step++; - show_count_obj = 13; - position = {190, 250, 5, 5, 5, 120, 128, 128, 128, 120, 180, 0, 0, 0, 0, 5, 250, 5, 5, 5}; - break; - default: - show_step = 1; - break; - } - } - return position; -} - -class JointStatePublisher : public rclcpp::Node { -public: - JointStatePublisher() : Node("dong_test_sender") { - joint_state_pub = this->create_publisher("/right_hand_control", 10); - timer = this->create_wall_timer(std::chrono::milliseconds(33), std::bind(&JointStatePublisher::publishJointState, this)); - } - -private: - void publishJointState() { - sensor_msgs::msg::JointState joint_state; - joint_state.header.stamp = this->now(); - joint_state.name = {"joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7", "joint8", "joint9", "joint10", "joint11", "joint12", "joint13", "joint14", "joint15", "joint16", "joint17", "joint18", "joint19", "joint20"}; - joint_state.position = show_left(); - joint_state.velocity.resize(joint_state.position.size(), 100.0); - joint_state.effort.resize(joint_state.position.size(), 200.0); - if (joint_state.position.size() > 0) - joint_state_pub->publish(joint_state); - } - - rclcpp::Publisher::SharedPtr joint_state_pub; - rclcpp::TimerBase::SharedPtr timer; -}; - -void signalHandler(int sig) { - (void) sig; - running = false; - rclcpp::shutdown(); -} - -int main(int argc, char **argv) { - rclcpp::init(argc, argv); - signal(SIGINT, signalHandler); - rclcpp::spin(std::make_shared()); - return 0; -} \ No newline at end of file diff --git a/src/examples/src/show_wave_l20.cpp b/src/examples/src/show_wave_l20.cpp deleted file mode 100644 index 1c29b5c..0000000 --- a/src/examples/src/show_wave_l20.cpp +++ /dev/null @@ -1,95 +0,0 @@ -// 本demo仅支持L20,默认为右手,如需左手,请修改话题名 - -#include -#include -#include -#include -#include -#include - -using namespace std; -using namespace std::chrono_literals; - -bool running = true; - -int show_count = 0; -int show_count_obj = 0; -int show_step = 0; - -vector show_left() { - vector position; - show_count++; - if (show_count >= show_count_obj) { - show_count = 0; - switch (show_step) { - case 0: - show_step++; - show_count_obj = 40; - position = {128, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 1: - show_step++; - show_count_obj = 30; - position = {128, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 0, 0, 0, 0}; - break; - case 2: - show_step++; - show_count_obj = 33; - position = {128, 0, 0, 0, 0, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 0, 0, 0, 0}; - break; - case 3: - show_step++; - show_count_obj = 30; - position = {128, 0, 0, 0, 0, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - case 4: - show_step++; - show_count_obj = 30; - position = {128, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - default: - show_step = 0; - position = {128, 250, 250, 250, 250, 250, 128, 128, 128, 128, 250, 0, 0, 0, 0, 250, 250, 250, 250, 250}; - break; - } - } - return position; -} - -class JointStatePublisher : public rclcpp::Node { -public: - JointStatePublisher() : Node("dong_test_sender") { - joint_state_pub = this->create_publisher("/right_hand_control", 10); - timer = this->create_wall_timer(33ms, std::bind(&JointStatePublisher::publishJointState, this)); - } - -private: - void publishJointState() { - sensor_msgs::msg::JointState joint_state; - joint_state.header.stamp = this->now(); - joint_state.name = {"joint1", "joint2", "joint3", "joint4", "joint5", "joint6", "joint7", "joint8", "joint9", "joint10", "joint11", "joint12", "joint13", "joint14", "joint15", "joint16", "joint17", "joint18", "joint19", "joint20"}; - vector position = show_left(); - if (!position.empty()) { - joint_state.position = position; - } - joint_state.velocity.resize(joint_state.position.size(), 100.0); - joint_state.effort.resize(joint_state.position.size(), 200.0); - if (joint_state.position.size() > 0) joint_state_pub->publish(joint_state); - } - - rclcpp::Publisher::SharedPtr joint_state_pub; - rclcpp::TimerBase::SharedPtr timer; -}; - -void signalHandler(int sig) { - (void) sig; - running = false; - rclcpp::shutdown(); -} - -int main(int argc, char **argv) { - rclcpp::init(argc, argv); - signal(SIGINT, signalHandler); - rclcpp::spin(std::make_shared()); - return 0; -} \ No newline at end of file From 27c56d99c45c1739782601995733180bb51cd9d5 Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Tue, 30 Jun 2026 11:36:46 +0800 Subject: [PATCH 10/19] refactor: rename GUI script to hand_control_gui --- src/linker_hand_cpp_ros2/CMakeLists.txt | 2 +- .../scripts/{left_hand_control_gui.py => hand_control_gui.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/linker_hand_cpp_ros2/scripts/{left_hand_control_gui.py => hand_control_gui.py} (100%) diff --git a/src/linker_hand_cpp_ros2/CMakeLists.txt b/src/linker_hand_cpp_ros2/CMakeLists.txt index 9293c39..5743a41 100644 --- a/src/linker_hand_cpp_ros2/CMakeLists.txt +++ b/src/linker_hand_cpp_ros2/CMakeLists.txt @@ -38,7 +38,7 @@ install(TARGETS linker_hand_node DESTINATION lib/${PROJECT_NAME}) install(PROGRAMS - scripts/left_hand_control_gui.py + scripts/hand_control_gui.py DESTINATION lib/${PROJECT_NAME} ) diff --git a/src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py b/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py similarity index 100% rename from src/linker_hand_cpp_ros2/scripts/left_hand_control_gui.py rename to src/linker_hand_cpp_ros2/scripts/hand_control_gui.py From 6d20b38db3d14a38a7b2093486a6ad55b91e1773 Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Tue, 30 Jun 2026 13:55:52 +0800 Subject: [PATCH 11/19] fix: resolve publisher thread data race and guard exceptions --- .../src/linker_hand_node.cpp | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp b/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp index 6a06b07..ac27124 100644 --- a/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp +++ b/src/linker_hand_cpp_ros2/src/linker_hand_node.cpp @@ -233,8 +233,12 @@ class LinkerHand : public rclcpp::Node pub_touch_thread = std::thread([this]() { while (rclcpp::ok() && node_active) { // rclcpp::spin_some(this); - if (pub_hand_touch_->get_subscription_count() > 0) { - publishTouchData(hand_api, *this->pub_hand_touch_); + try { + if (pub_hand_touch_->get_subscription_count() > 0) { + publishTouchData(hand_api, *this->pub_hand_touch_); + } + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "touch publish error: %s", e.what()); } std::this_thread::sleep_for(std::chrono::milliseconds(10)); } @@ -243,9 +247,13 @@ class LinkerHand : public rclcpp::Node pub_state_thread = std::thread([this]() { while (rclcpp::ok() && node_active) { - if (pub_hand_state_->get_subscription_count() > 0) { - publishJointState(hand_api, *this->pub_hand_state_); - // publishJointState(hand_api, *this->pub_hand_state_arc_, true); + try { + if (pub_hand_state_->get_subscription_count() > 0) { + publishJointState(hand_api, *this->pub_hand_state_); + // publishJointState(hand_api, *this->pub_hand_state_arc_, true); + } + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "state publish error: %s", e.what()); } std::this_thread::sleep_for(std::chrono::milliseconds(10)); } @@ -253,8 +261,12 @@ class LinkerHand : public rclcpp::Node pub_info_thread = std::thread([this]() { while (rclcpp::ok() && node_active) { - if (pub_hand_info_->get_subscription_count() > 0) { - publishLinkerHandInfo(hand_api, *this->pub_hand_info_); + try { + if (pub_hand_info_->get_subscription_count() > 0) { + publishLinkerHandInfo(hand_api, *this->pub_hand_info_); + } + } catch (const std::exception & e) { + RCLCPP_ERROR(this->get_logger(), "info publish error: %s", e.what()); } std::this_thread::sleep_for(std::chrono::milliseconds(100)); } @@ -265,11 +277,14 @@ class LinkerHand : public rclcpp::Node { // Ensure the release of resources in the destructor node_active = false; - hand_api.reset(); + // Stop publisher threads before releasing hand_api, so no thread + // dereferences a freed hand_api during shutdown. if (pub_touch_thread.joinable()) {pub_touch_thread.join();} if (pub_state_thread.joinable()) {pub_state_thread.join();} if (pub_info_thread.joinable()) {pub_info_thread.join();} + + hand_api.reset(); } bool isActive() const @@ -599,7 +614,7 @@ class LinkerHand : public rclcpp::Node std::string version; bool hand_exists; bool hand_touch; - bool node_active{false}; + std::atomic node_active{false}; std::string hand_joints; std::string comm_type; From fbd9e7bbbfdb2ca52cb5fb4c752546e0dbb00efa Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Wed, 1 Jul 2026 11:52:52 +0800 Subject: [PATCH 12/19] feat: add per-model position slider defaults in hand_control_gui --- .../scripts/hand_control_gui.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py b/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py index cca67eb..b585a72 100755 --- a/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py +++ b/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py @@ -24,6 +24,19 @@ "O20": 34, } +# Per-model position slider defaults (joint order matches README). Length should +# equal JOINT_COUNTS[model]; position_defaults() pads with 255 / truncates as a +# safety net. Currently all 255 — tune per joint as needed. +POSITION_DEFAULTS = {model: [255] * count for model, count in JOINT_COUNTS.items()} +POSITION_DEFAULTS["G20"] = [255, 255, 255, 255, 255, 255, 130, 125, 125, + 125, 255, 255, 255, 255, 255, 255] + +def position_defaults(model): + count = JOINT_COUNTS.get(model, 0) + values = POSITION_DEFAULTS.get(model, []) + return [values[i] if i < len(values) else 255 for i in range(count)] + + FINGER_NAMES = ("Thumb", "Index", "Middle", "Ring", "Little") @@ -141,7 +154,7 @@ def __init__(self, node, model, side, topic, live_publish, publish_rate_hz): self.main = None self.table_container = None self.feedback_container = None - self.position_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] + self.position_vars = [tk.IntVar(value=v) for v in position_defaults(model)] self.speed_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] self.torque_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] self.watch_command_vars() @@ -360,11 +373,11 @@ def apply_topic(self): self.update_summary() def apply_model(self): - old_positions = [var.get() for var in self.position_vars] old_speeds = [var.get() for var in self.speed_vars] old_torques = [var.get() for var in self.torque_vars] - self.joint_count = JOINT_COUNTS[self.model_var.get()] - self.position_vars = self.resize_vars(old_positions, 255) + model = self.model_var.get() + self.joint_count = JOINT_COUNTS[model] + self.position_vars = [tk.IntVar(value=v) for v in position_defaults(model)] self.speed_vars = self.resize_vars(old_speeds, 255) self.torque_vars = self.resize_vars(old_torques, 255) self.watch_command_vars() From 75a04d015759e26204b3476c32151bbab713afe6 Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Thu, 2 Jul 2026 13:57:54 +0800 Subject: [PATCH 13/19] doc: update readme --- .gitignore | 5 +++- README.md | 76 ++++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 58 insertions(+), 23 deletions(-) diff --git a/.gitignore b/.gitignore index e8b072e..82063c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ build install -log \ No newline at end of file +log +.vscode/ +src/linker_hand_cpp_ros2/scripts/_* +CLAUDE.md \ No newline at end of file diff --git a/README.md b/README.md index 285519c..e38a157 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- - [环境要求](#-环境要求) - [快速开始](#-快速开始) - [话题详情](#-话题详情) -- [使用示例](#-使用示例) +- [控制 GUI](#-控制-gui) - [许可证](#-许可证) - [联系我们](#-联系我们) - [更新日志](#-更新日志) @@ -26,9 +26,10 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- - **架构**: x86_64 - **编译器**: GCC 7.0+ 或 Clang 6.0+(C++17) - **CMake**: 3.15+ -- **依赖**: ROS2 (推荐 Foxy 或 Humble)、[linkerhand-cpp-sdk v2.1.7](https://github.com/linker-bot/linkerhand-cpp-sdk/releases/tag/v2.1.7) +- **依赖**: ROS2 (推荐 Foxy 或 Humble)、[linkerhand-cpp-sdk v2.1.8](https://github.com/linker-bot/linkerhand-cpp-sdk/releases/tag/v2.1.8)、`nlohmann_json` +- **GUI 可选依赖**: `python3-tk`、`rclpy`(用于 `hand_control_gui.py`) -> 注:本项目已适配 linkerhand-cpp-sdk v2.1.7;O20 依赖 CAN-FD,当前 ROS2 节点仍以普通 CAN 为主,暂未完成 O20 实机运行适配。 +> 注:CI 已适配 linkerhand-cpp-sdk v2.1.8;O20 依赖 CAN-FD,当前 ROS2 节点仍以普通 CAN 为主,暂未完成 O20 实机运行适配。Modbus 通信当前仅支持 O6 / L7 / L10。 ## 🚀 快速开始 @@ -41,22 +42,26 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- #### 配置XML文件 -```bash +`launch/run.xml` 用于双手同时启动,`launch/run_left.xml` 用于仅启动单手(默认左手 G20)。 + +```xml - + - + - + + - - + + + @@ -65,13 +70,16 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- ...... - + ``` #### 运行 source install/setup.bash + # 双手 ros2 launch linker_hand_cpp_ros2 run.xml + # 仅左手 + ros2 launch linker_hand_cpp_ros2 run_left.xml ## 📚 话题详情 @@ -79,15 +87,15 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- | 话题名称 | I/O | 消息类型 | 描述 | | :--- | :--- | :--- | :--- | | /left_hand_control | Input | sensor_msgs/msg/JointState | 左手控制指令 | -| /left_hand_settings | Input | sensor_msgs/msg/String | 左手设置指令 | +| /left_hand_settings | Input | std_msgs/msg/String | 左手设置指令 | | /left_hand_touch | Output | std_msgs/msg/Float32MultiArray | 左手触觉传感器数据 | | /left_hand_state | Output | sensor_msgs/msg/JointState | 左手关节状态 | | /left_hand_info | Output | std_msgs/msg/String | 左手基本信息 | -| /right_hand_control | Input | sensor_msgs/msg/JointState | 左手控制指令 | -| /right_hand_settings | Input | sensor_msgs/msg/String | 右手设置指令 | -| /right_hand_touch | Output | std_msgs/msg/Float32MultiArray | 左手触觉传感器数据 | -| /right_hand_state | Output | sensor_msgs/msg/JointState | 左手关节状态 | -| /right_hand_info | Output | std_msgs/msg/String | 左手基本信息 | +| /right_hand_control | Input | sensor_msgs/msg/JointState | 右手控制指令 | +| /right_hand_settings | Input | std_msgs/msg/String | 右手设置指令 | +| /right_hand_touch | Output | std_msgs/msg/Float32MultiArray | 右手触觉传感器数据 | +| /right_hand_state | Output | sensor_msgs/msg/JointState | 右手关节状态 | +| /right_hand_info | Output | std_msgs/msg/String | 右手基本信息 | 针对以上话题的具体字段及其详细描述如下表所示: @@ -135,16 +143,16 @@ L25: ["大拇指根部", "食指根部", "中指根部","无名指根部","小 ```bash # 清除故障码 - $ ros2 topic pub /left_hand_setting std_msgs/msg/String "data: '{\"setting_cmd\": \"clear_faults\"}'" + $ ros2 topic pub /left_hand_settings std_msgs/msg/String "data: '{\"setting_cmd\": \"clear_faults\"}'" - # 设置电流(linkerhand-cpp-sdk v2.1.7 暂不支持,节点会输出警告) - $ ros2 topic pub /left_hand_setting std_msgs/msg/String "data: '{\"setting_cmd\": \"set_electric_current\", \"params\": {\"electric_current\": 50}}'" + # 设置电流(linkerhand-cpp-sdk 当前版本暂不支持,节点会输出警告) + $ ros2 topic pub /left_hand_settings std_msgs/msg/String "data: '{\"setting_cmd\": \"set_electric_current\", \"params\": {\"electric_current\": 50}}'" # 使能 - $ ros2 topic pub /left_hand_setting std_msgs/msg/String "data: '{\"setting_cmd\": \"enable\"}'" + $ ros2 topic pub /left_hand_settings std_msgs/msg/String "data: '{\"setting_cmd\": \"enable\"}'" # 失能 - $ ros2 topic pub /left_hand_setting std_msgs/msg/String "data: '{\"setting_cmd\": \"disable\"}'" + $ ros2 topic pub /left_hand_settings std_msgs/msg/String "data: '{\"setting_cmd\": \"disable\"}'" ``` @@ -184,7 +192,11 @@ data: 'Hand direction: Left hand ``` - 压感数据 /left_hand_touch - 注意:反馈数据为一个一维数组,长度为360,由每个手指的压感数据组成。单个指头压感数据长度是72(6*12矩阵),每个手指压感数据需要单独拆分。(仅适用点阵式传感器) + 注意:反馈数据为一个一维数组,长度按型号区分: + - 常规型号:长度 360,5 指 × 72(6×12 矩阵)/指; + - O6:长度 200,5 指 × 40(4×10 矩阵)/指。 + + 每个手指压感数据需要自行按指拆分。仅适用点阵式传感器。 ```bash $ ros2 topic echo /left_hand_touch @@ -200,6 +212,26 @@ data: - '...' ``` +## 🎛️ 控制 GUI + +`hand_control_gui.py` 提供基于 tkinter 的可视化面板,可拖动滑块直接下发 `JointState`,并 +显示 `state` / `touch` / `info` 反馈(含触觉热力图)。它只依赖标准话题,可与任意 `run.xml` 场景配合。 + +```bash +source install/setup.bash +# 自动从 /linker_hand_{side}_node 读取 HAND_JOINTS 型号参数 +ros2 run linker_hand_cpp_ros2 hand_control_gui.py --side left +# 或显式指定型号 +ros2 run linker_hand_cpp_ros2 hand_control_gui.py --side right --model L10 +``` + +常用参数:`--side left|right`、`--model L6|O6|L7|L10|L20|L21|L25|G20|O20`、`--rate `。 +GUI 顶部可切换 side / model / 话题;三列滑块分别对应 `position` / `speed` / `torque`, +取值范围 0–255。position 初始值按型号可在 `hand_control_gui.py` 的 `POSITION_DEFAULTS` +字典中按型号定制(默认全 255)。 + +依赖 `python3-tk` 与 `rclpy`。 + ## 📄 许可证 本项目采用 [Apache 2.0 许可证](LICENSE)。 From 298c96397085f6ba69a7f6d15cb4fbcf9c633a4e Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Wed, 8 Jul 2026 16:39:24 +0800 Subject: [PATCH 14/19] refactor: overhaul hand_control_gui layout, perf, and interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Toolbar: place 型号 before 侧别, drop the manual 话题 input (topic is derived from side). - Model switch: skip feedback panel rebuild when touch shape is unchanged; only redraw the heatmap. Build slider pages lazily so non-active metric tabs cost nothing until first click. - Control panel: bind mouse wheel while the cursor is over the sliders canvas so the joint list scrolls. - Logo loader: look for the image under assets/ in both the installed share directory and the source-tree fallback (paired with the companion commit that relocates logo.png). --- .../scripts/hand_control_gui.py | 847 +++++++++++++----- 1 file changed, 603 insertions(+), 244 deletions(-) diff --git a/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py b/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py index b585a72..de47e3d 100755 --- a/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py +++ b/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py @@ -1,7 +1,9 @@ #!/usr/bin/env python3 import argparse +import os import threading import tkinter as tk +from tkinter import font as tkfont from tkinter import ttk import rclpy @@ -26,18 +28,20 @@ # Per-model position slider defaults (joint order matches README). Length should # equal JOINT_COUNTS[model]; position_defaults() pads with 255 / truncates as a -# safety net. Currently all 255 — tune per joint as needed. +# safety net. POSITION_DEFAULTS = {model: [255] * count for model, count in JOINT_COUNTS.items()} -POSITION_DEFAULTS["G20"] = [255, 255, 255, 255, 255, 255, 130, 125, 125, +POSITION_DEFAULTS["G20"] = [255, 255, 255, 255, 255, 255, 130, 125, 125, 125, 255, 255, 255, 255, 255, 255] + def position_defaults(model): count = JOINT_COUNTS.get(model, 0) values = POSITION_DEFAULTS.get(model, []) return [values[i] if i < len(values) else 255 for i in range(count)] -FINGER_NAMES = ("Thumb", "Index", "Middle", "Ring", "Little") +FINGER_NAMES = ("拇指", "食指", "中指", "无名指", "小指") +STATE_LABELS = {"position": "位置", "speed": "速度", "torque": "力矩"} class HandControlPublisher(Node): @@ -130,14 +134,29 @@ def get_remote_parameter(self, node_name, parameter_name, timeout_sec=1.0): class HandControlGui: + BG = "#eaedf3" + CARD = "#ffffff" + TEXT = "#111827" + MUTED = "#4b5563" + BORDER = "#c4cad6" + TROUGH = "#d1d5db" + ACCENT = "#0a5fff" + ACCENT_HOVER = "#0846d1" + ACCENT_PRESS = "#0637a3" + GREEN = "#22c55e" + GRAY_DOT = "#9ca3af" + def __init__(self, node, model, side, topic, live_publish, publish_rate_hz): self.node = node self.closed = False self.root = tk.Tk() - self.root.title("LinkerHand Control Panel") + self.root.title("LinkerHand") self.root.protocol("WM_DELETE_WINDOW", self.close) - self.root.minsize(980, 720) + self.root.minsize(1080, 720) + self.root.configure(background=self.BG) + + self.font_family = self._pick_font_family() self.configure_style() self.model_var = tk.StringVar(value=model) @@ -151,203 +170,583 @@ def __init__(self, node, model, side, topic, live_publish, publish_rate_hz): self.joint_count = JOINT_COUNTS[model] self.command_dirty = False - self.main = None - self.table_container = None + self.middle_container = None self.feedback_container = None + self.control_container = None + self.touch_canvases = [] + self.latest_touch_values = [] + self.position_vars = [tk.IntVar(value=v) for v in position_defaults(model)] self.speed_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] self.torque_vars = [tk.IntVar(value=255) for _ in range(self.joint_count)] self.watch_command_vars() - self.state_vars = { - "position": tk.StringVar(value="-"), - "speed": tk.StringVar(value="-"), - "torque": tk.StringVar(value="-"), - } + self.current_metric = "position" + self._metric_tab_buttons = {} + self._sliders_body = None + self._slider_traces = [] + + self.state_vars = {key: tk.StringVar(value="—") for key in STATE_LABELS} self.info_vars = [] - self.touch_labels = [] - self.touch_canvases = [] - self.latest_touch_values = [] - self.update_feedback_topics() + self.status_var = tk.StringVar(value="已就绪") + self.status_dot = None + self.live_dot = None + self.summary_var = tk.StringVar() + + self.update_feedback_topics() self._build() self.publish_loop() self.refresh_feedback() - def _build(self): - self.main = ttk.Frame(self.root, padding=10) - self.main.grid(row=0, column=0, sticky="nsew") - self.root.columnconfigure(0, weight=1) - self.root.rowconfigure(0, weight=1) - self.main.columnconfigure(0, weight=1) - self.main.rowconfigure(1, weight=1) - - self._build_config(self.main) - self._build_joint_table() - self._build_feedback(self.main) - self._build_controls(self.main) + # ---------- style ---------- - self.status = tk.StringVar(value="Ready") - ttk.Label(self.main, textvariable=self.status, style="Status.TLabel").grid( - row=4, column=0, sticky="ew", pady=(8, 0) - ) + def _pick_font_family(self): + try: + families = set(tkfont.families(self.root)) + except tk.TclError: + families = set() + for name in ( + "SF Pro Text", "SF Pro Display", ".AppleSystemUIFont", + "PingFang SC", "Helvetica Neue", "Segoe UI", + "Noto Sans CJK SC", "Ubuntu", + ): + if name in families: + return name + return tkfont.nametofont("TkDefaultFont").actual("family") + + def _load_logo(self): + # Resolve logo.png: prefer installed share dir, fall back to source tree + # (for `colcon build --symlink-install` or direct script execution). + path = None + try: + from ament_index_python.packages import get_package_share_directory + candidate = os.path.join( + get_package_share_directory("linker_hand_cpp_ros2"), + "assets", "logo.png", + ) + if os.path.exists(candidate): + path = candidate + except Exception: + pass + if path is None: + script_dir = os.path.dirname(os.path.abspath(__file__)) + for rel in ("../assets/logo.png", "../../assets/logo.png"): + cand = os.path.abspath(os.path.join(script_dir, rel)) + if os.path.exists(cand): + path = cand + break + if path is None: + return None + try: + img = tk.PhotoImage(file=path) + except tk.TclError: + return None + # Downscale so the ~181px-tall source fits inside the 58px toolbar. + target_h = 40 + h = img.height() or target_h + factor = max(1, round(h / target_h)) + return img.subsample(factor, factor) if factor > 1 else img def configure_style(self): + f = self.font_family style = ttk.Style(self.root) try: style.theme_use("clam") except tk.TclError: pass - style.configure("TLabelframe", padding=8) - style.configure("TLabelframe.Label", font=("TkDefaultFont", 10, "bold")) - style.configure("Header.TLabel", font=("TkDefaultFont", 10, "bold")) - style.configure("Status.TLabel", foreground="#555555") - def _build_config(self, parent): - config = ttk.LabelFrame(parent, text="Configuration") - config.grid(row=0, column=0, sticky="ew", pady=(0, 8)) - config.columnconfigure(5, weight=1) + style.configure("App.TFrame", background=self.BG) + style.configure("Card.TFrame", background=self.CARD) + + style.configure("TLabel", background=self.BG, foreground=self.TEXT, font=(f, 11)) + style.configure("Muted.TLabel", background=self.BG, foreground=self.MUTED, font=(f, 10)) + style.configure("Brand.TLabel", background=self.CARD, foreground=self.TEXT, + font=(f, 15, "bold")) + style.configure("ToolbarLabel.TLabel", background=self.CARD, foreground=self.MUTED, + font=(f, 11)) + style.configure("Section.TLabel", background=self.BG, foreground=self.MUTED, + font=(f, 10, "bold")) + + style.configure( + "Flat.TButton", + background=self.CARD, foreground=self.TEXT, + bordercolor=self.BORDER, lightcolor=self.BORDER, darkcolor=self.BORDER, + borderwidth=1, padding=(14, 6), font=(f, 11), relief="flat", focusthickness=0, + ) + style.map( + "Flat.TButton", + background=[("active", "#e5e7ec"), ("pressed", "#d5d8df")], + bordercolor=[("active", self.BORDER)], + ) + style.configure( + "Accent.TButton", + background=self.ACCENT, foreground="#ffffff", + borderwidth=0, padding=(16, 6), font=(f, 11, "bold"), relief="flat", + focusthickness=0, + ) + style.map( + "Accent.TButton", + background=[("active", self.ACCENT_HOVER), ("pressed", self.ACCENT_PRESS)], + ) + + for name in ("TEntry",): + style.configure( + name, + fieldbackground=self.CARD, foreground=self.TEXT, + bordercolor=self.BORDER, lightcolor=self.BORDER, darkcolor=self.BORDER, + insertcolor=self.TEXT, padding=6, + ) + style.map( + name, + foreground=[("!disabled", self.TEXT), ("disabled", self.MUTED)], + fieldbackground=[("!disabled", self.CARD)], + bordercolor=[("focus", self.ACCENT)], + lightcolor=[("focus", self.ACCENT)], + darkcolor=[("focus", self.ACCENT)], + ) - ttk.Label(config, text="Side").grid(row=0, column=0, padx=(0, 4), sticky="w") - side_box = ttk.Combobox( - config, - textvariable=self.side_var, - values=("left", "right"), - state="readonly", - width=7, + style.configure( + "TCombobox", + fieldbackground=self.CARD, background=self.CARD, foreground=self.TEXT, + bordercolor=self.BORDER, lightcolor=self.BORDER, darkcolor=self.BORDER, + arrowcolor=self.TEXT, padding=5, + selectbackground=self.CARD, selectforeground=self.TEXT, + ) + style.map( + "TCombobox", + foreground=[("readonly", self.TEXT), ("!disabled", self.TEXT)], + fieldbackground=[("readonly", self.CARD), ("!disabled", self.CARD)], + selectbackground=[("readonly", self.CARD)], + selectforeground=[("readonly", self.TEXT)], + bordercolor=[("focus", self.ACCENT)], + lightcolor=[("focus", self.ACCENT)], + darkcolor=[("focus", self.ACCENT)], + ) + # Combobox dropdown listbox (a separate Tk widget outside ttk styling). + self.root.option_add("*TCombobox*Listbox.background", self.CARD) + self.root.option_add("*TCombobox*Listbox.foreground", self.TEXT) + self.root.option_add("*TCombobox*Listbox.selectBackground", self.ACCENT) + self.root.option_add("*TCombobox*Listbox.selectForeground", "#ffffff") + self.root.option_add("*TCombobox*Listbox.font", (f, 11)) + + style.configure( + "TSpinbox", + fieldbackground=self.CARD, background=self.CARD, foreground=self.TEXT, + bordercolor=self.BORDER, lightcolor=self.BORDER, darkcolor=self.BORDER, + arrowcolor=self.TEXT, padding=4, + selectbackground=self.ACCENT, selectforeground="#ffffff", + ) + style.map( + "TSpinbox", + foreground=[("!disabled", self.TEXT), ("disabled", self.MUTED)], + fieldbackground=[("!disabled", self.CARD)], + bordercolor=[("focus", self.ACCENT)], + lightcolor=[("focus", self.ACCENT)], + darkcolor=[("focus", self.ACCENT)], ) - side_box.grid(row=0, column=1, padx=(0, 10), sticky="w") - side_box.bind("<>", lambda _event: self.apply_side_topic()) - ttk.Label(config, text="Model").grid(row=0, column=2, padx=(0, 4), sticky="w") - model_box = ttk.Combobox( - config, - textvariable=self.model_var, - values=sorted(JOINT_COUNTS), - state="readonly", - width=7, + # Scale: slider (thumb) uses `background`, track uses `troughcolor`. + # Setting slider to accent blue makes it clearly visible on the white card. + style.configure( + "Horizontal.TScale", + background=self.ACCENT, troughcolor=self.TROUGH, + bordercolor=self.ACCENT, lightcolor=self.ACCENT, darkcolor=self.ACCENT, + ) + style.map( + "Horizontal.TScale", + background=[("active", self.ACCENT_HOVER), ("pressed", self.ACCENT_PRESS)], ) - model_box.grid(row=0, column=3, padx=(0, 10), sticky="w") - model_box.bind("<>", lambda _event: self.apply_model()) - ttk.Label(config, text="Rate").grid(row=0, column=4, padx=(0, 4), sticky="w") - ttk.Spinbox(config, from_=1, to=200, width=5, textvariable=self.rate_var).grid( - row=0, column=5, sticky="w" + style.configure( + "Switch.TCheckbutton", + background=self.CARD, foreground=self.TEXT, + indicatorbackground=self.CARD, indicatorforeground=self.ACCENT, + font=(f, 11), focusthickness=0, padding=2, ) - ttk.Label(config, text="Hz").grid(row=0, column=6, padx=(4, 10), sticky="w") - ttk.Checkbutton(config, text="Live", variable=self.live_var).grid( - row=0, column=7, padx=(0, 10), sticky="w" + style.map( + "Switch.TCheckbutton", + background=[("active", self.CARD)], ) - ttk.Label(config, text="Topic").grid(row=1, column=0, padx=(0, 4), pady=(6, 0), sticky="w") - topic_entry = ttk.Entry(config, textvariable=self.topic_var) - topic_entry.grid(row=1, column=1, columnspan=6, pady=(6, 0), sticky="ew") - ttk.Button(config, text="Apply", command=self.apply_topic).grid( - row=1, column=7, padx=(0, 10), pady=(6, 0), sticky="w" + style.configure( + "Vertical.TScrollbar", + background=self.BG, troughcolor=self.CARD, + bordercolor=self.CARD, arrowcolor=self.MUTED, gripcount=0, ) + style.map("Vertical.TScrollbar", background=[("active", self.TROUGH)]) - self.summary = tk.StringVar() - ttk.Label(config, textvariable=self.summary, style="Status.TLabel").grid( - row=2, column=0, columnspan=8, pady=(6, 0), sticky="w" + # ---------- build ---------- + + def _build(self): + self._build_toolbar() + + tk.Frame(self.root, background=self.BORDER, height=1).grid( + row=1, column=0, sticky="ew" ) - self.update_summary() - def _build_joint_table(self): - if self.table_container is not None: - self.table_container.destroy() + self.main = ttk.Frame(self.root, padding=(20, 14, 20, 14), style="App.TFrame") + self.main.grid(row=2, column=0, sticky="nsew") + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(2, weight=1) + self.main.columnconfigure(0, weight=1) + self.main.rowconfigure(0, weight=1) + + self.middle_container = ttk.Frame(self.main, style="App.TFrame") + self.middle_container.grid(row=0, column=0, sticky="nsew") + self.middle_container.columnconfigure(0, weight=2) + self.middle_container.columnconfigure(1, weight=1) + self.middle_container.rowconfigure(0, weight=1) - columns = ("joint", "position", "speed", "torque") - table = self._create_scrollable_table(self.main) - self.main.rowconfigure(1, weight=1) - for col in range(4): - table.columnconfigure(col, weight=1 if col else 0) + self._build_feedback(self.middle_container) + self._build_control(self.middle_container) + self._build_status(self.main) + self.update_summary() - for col, text in enumerate(columns): - ttk.Label(table, text=text).grid(row=0, column=col, padx=4, pady=4) + def _build_toolbar(self): + bar = tk.Frame(self.root, background=self.CARD, height=58) + bar.grid(row=0, column=0, sticky="ew") + bar.grid_propagate(False) + bar.columnconfigure(5, weight=1) + + self._logo_image = self._load_logo() + if self._logo_image is not None: + tk.Label( + bar, image=self._logo_image, background=self.CARD, + borderwidth=0, highlightthickness=0, + ).grid(row=0, column=0, padx=(20, 24), pady=8, sticky="w") + else: + ttk.Label(bar, text="LinkerHand", style="Brand.TLabel").grid( + row=0, column=0, padx=(22, 28), pady=14, sticky="w" + ) - for index in range(self.joint_count): - row = index + 1 - ttk.Label(table, text=f"J{index + 1}").grid(row=row, column=0, padx=4, sticky="w") - self._add_slider(table, row, 1, self.position_vars[index]) - self._add_slider(table, row, 2, self.speed_vars[index]) - self._add_slider(table, row, 3, self.torque_vars[index]) + ttk.Label(bar, text="型号", style="ToolbarLabel.TLabel").grid( + row=0, column=1, padx=(0, 6) + ) + model_box = ttk.Combobox( + bar, textvariable=self.model_var, values=sorted(JOINT_COUNTS), + state="readonly", width=6, + ) + model_box.grid(row=0, column=2, padx=(0, 22)) + model_box.bind("<>", lambda _e: self.apply_model()) - def _build_controls(self, parent): - controls = ttk.LabelFrame(parent, text="Commands") - controls.grid(row=3, column=0, sticky="ew", pady=(10, 0)) - controls.columnconfigure(5, weight=1) + ttk.Label(bar, text="侧别", style="ToolbarLabel.TLabel").grid( + row=0, column=3, padx=(0, 6) + ) + side_box = ttk.Combobox( + bar, textvariable=self.side_var, values=("left", "right"), + state="readonly", width=6, + ) + side_box.grid(row=0, column=4, padx=(0, 18)) + side_box.bind("<>", lambda _e: self.apply_side_topic()) - ttk.Button(controls, text="Open", command=lambda: self.set_all_positions(255)).grid( - row=0, column=0, padx=(0, 6), pady=2 + self.live_dot = tk.Canvas( + bar, width=10, height=10, background=self.CARD, highlightthickness=0 ) - ttk.Button(controls, text="Half", command=lambda: self.set_all_positions(128)).grid( - row=0, column=1, padx=6, pady=2 + self.live_dot.grid(row=0, column=6, padx=(0, 6)) + ttk.Checkbutton( + bar, text="实时", variable=self.live_var, style="Switch.TCheckbutton", + command=self._refresh_live_dot, + ).grid(row=0, column=7, padx=(0, 22)) + self._refresh_live_dot() + self.live_var.trace_add("write", lambda *_a: self._refresh_live_dot()) + + def _refresh_live_dot(self): + if self.live_dot is None: + return + color = self.GREEN if self.live_var.get() else self.GRAY_DOT + self.live_dot.delete("all") + self.live_dot.create_oval(1, 1, 9, 9, fill=color, outline="") + + def _card(self, parent): + return tk.Frame( + parent, background=self.CARD, + highlightthickness=1, highlightbackground=self.BORDER, + ) + + def _build_feedback(self, parent): + if self.feedback_container is not None: + self.feedback_container.destroy() + + container = ttk.Frame(parent, style="App.TFrame") + container.grid(row=0, column=0, sticky="nsew", padx=(0, 10)) + container.columnconfigure(0, weight=1) + # Touch card (row 3) takes remaining vertical space; state card (row 1) + # sits on top with natural single-line height. + container.rowconfigure(3, weight=1) + self.feedback_container = container + + ttk.Label(container, text="反馈", style="Section.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 6) ) - ttk.Button(controls, text="Close", command=lambda: self.set_all_positions(0)).grid( - row=0, column=2, padx=6, pady=2 + state_card = self._card(container) + state_card.grid(row=1, column=0, sticky="ew") + state_card.columnconfigure(0, minsize=76) + state_card.columnconfigure(1, weight=1) + + self._feedback_value_labels = [] + self._feedback_last_width = 0 + + row_idx = 0 + for key, var in self.state_vars.items(): + self._state_row(state_card, row_idx, STATE_LABELS[key], var) + row_idx += 1 + + self.info_vars = [] + for i in range(6): + var = tk.StringVar(value="—") + self.info_vars.append(var) + self._state_row(state_card, row_idx, f"信息 {i + 1}", var, + last=(i == 5)) + row_idx += 1 + + state_card.bind( + "", + lambda e: self._update_feedback_wraplength(e.width), ) - ttk.Button(controls, text="Publish", command=self.publish).grid( - row=0, column=3, padx=6, pady=2 + + ttk.Label(container, text="触觉", style="Section.TLabel").grid( + row=2, column=0, sticky="w", pady=(14, 6) ) - ttk.Button(controls, text="Quit", command=self.close).grid( - row=0, column=4, padx=6, pady=2 + touch_card = self._card(container) + touch_card.grid(row=3, column=0, sticky="nsew") + touch_card.rowconfigure(0, weight=1) + + self.touch_canvases = [] + cw, ch = self.touch_canvas_size() + for finger in range(5): + touch_card.columnconfigure(finger, weight=1) + cell = tk.Frame(touch_card, background=self.CARD) + cell.grid(row=0, column=finger, sticky="nsew", padx=6, pady=(12, 4)) + cell.columnconfigure(0, weight=1) + cell.rowconfigure(0, weight=1) + canvas = tk.Canvas( + cell, width=cw, height=ch, + background=self.CARD, highlightthickness=0, + ) + canvas.grid(row=0, column=0, sticky="nsew") + canvas.bind("", lambda _e: self.redraw_touch_matrix()) + self.touch_canvases.append(canvas) + tk.Label( + touch_card, text=FINGER_NAMES[finger], + background=self.CARD, foreground=self.MUTED, + font=(self.font_family, 10), + ).grid(row=1, column=finger, pady=(0, 10)) + + def _update_feedback_wraplength(self, card_width): + # Debounce: only apply when width changes meaningfully. + if abs(card_width - self._feedback_last_width) < 4: + return + self._feedback_last_width = card_width + # 14px left + 76px label col + 14px gap + 14px right ≈ 118px chrome. + target = max(120, card_width - 118) + for lbl in self._feedback_value_labels: + try: + lbl.configure(wraplength=target) + except tk.TclError: + pass + + def _state_row(self, parent, row, label_text, var, last=False): + top = 10 if row == 0 else 4 + bottom = 12 if last else 4 + tk.Label( + parent, text=label_text, + background=self.CARD, foreground=self.MUTED, + font=(self.font_family, 10), + ).grid(row=row, column=0, padx=(14, 14), pady=(top, bottom), sticky="nw") + val_lbl = tk.Label( + parent, textvariable=var, + background=self.CARD, foreground=self.TEXT, + font=(self.font_family, 11), anchor="nw", justify="left", + # width=1 lets the column's own weight decide horizontal size, so + # long text never expands the card. wraplength is refreshed by + # _update_feedback_wraplength() so overflow wraps to a new line + # inside the value column instead of being clipped. + width=1, wraplength=320, ) + val_lbl.grid(row=row, column=1, padx=(0, 14), pady=(top, bottom), sticky="ew") + self._feedback_value_labels.append(val_lbl) - def _create_scrollable_table(self, parent): - self.table_container = ttk.LabelFrame(parent, text="Command Values") - self.table_container.grid(row=1, column=0, sticky="nsew") - self.table_container.columnconfigure(0, weight=1) - self.table_container.rowconfigure(0, weight=1) + def _build_control(self, parent): + if self.control_container is not None: + self.control_container.destroy() - canvas = tk.Canvas(self.table_container, highlightthickness=0) - scrollbar = ttk.Scrollbar(self.table_container, orient="vertical", command=canvas.yview) - table = ttk.Frame(canvas) - window_id = canvas.create_window((0, 0), window=table, anchor="nw") + container = ttk.Frame(parent, style="App.TFrame") + container.grid(row=0, column=1, sticky="nsew", padx=(10, 0)) + container.columnconfigure(0, weight=1) + container.rowconfigure(1, weight=1) + self.control_container = container - table.bind( + ttk.Label(container, text="控制", style="Section.TLabel").grid( + row=0, column=0, sticky="w", pady=(0, 6) + ) + card = self._card(container) + card.grid(row=1, column=0, sticky="nsew") + card.columnconfigure(0, weight=1) + card.rowconfigure(1, weight=1) + + # Segmented tabs: 位置 / 速度 / 力矩 + seg = tk.Frame(card, background=self.TROUGH, highlightthickness=0) + seg.grid(row=0, column=0, columnspan=2, sticky="ew", padx=14, pady=(14, 10)) + self._metric_tab_buttons = {} + for i, (key, label) in enumerate( + (("position", "位置"), ("speed", "速度"), ("torque", "力矩")) + ): + seg.columnconfigure(i, weight=1) + btn = tk.Label( + seg, text=label, + background=self.TROUGH, foreground=self.TEXT, + font=(self.font_family, 11), padx=12, pady=6, cursor="hand2", + ) + btn.grid(row=0, column=i, sticky="ew", padx=2, pady=2) + btn.bind("", lambda _e, k=key: self._set_metric(k)) + self._metric_tab_buttons[key] = btn + + # Scrollable body host — three metric pages are stacked at the same cell + # and switched with tkraise() so there is no destroy/rebuild flicker. + canvas = tk.Canvas(card, background=self.CARD, highlightthickness=0) + scrollbar = ttk.Scrollbar( + card, orient="vertical", command=canvas.yview, + style="Vertical.TScrollbar", + ) + body = tk.Frame(canvas, background=self.CARD) + window_id = canvas.create_window((0, 0), window=body, anchor="nw") + + body.bind( "", - lambda _event: canvas.configure(scrollregion=canvas.bbox("all")), + lambda _e: canvas.configure(scrollregion=canvas.bbox("all")), ) canvas.bind( "", - lambda event: canvas.itemconfigure(window_id, width=event.width), + lambda e: canvas.itemconfigure(window_id, width=e.width), ) canvas.configure(yscrollcommand=scrollbar.set) - canvas.grid(row=0, column=0, sticky="nsew", padx=(0, 4)) - scrollbar.grid(row=0, column=1, sticky="ns") - return table + canvas.grid(row=1, column=0, sticky="nsew", padx=(14, 0), pady=(0, 14)) + scrollbar.grid(row=1, column=1, sticky="ns", padx=(0, 6), pady=(0, 14)) + self._bind_mousewheel(canvas) + + body.columnconfigure(0, weight=1) + body.rowconfigure(0, weight=1) + self._sliders_body = body + self._slider_traces = [] + self._metric_frames = {} + self._metric_vars = { + "position": self.position_vars, + "speed": self.speed_vars, + "torque": self.torque_vars, + } + + self._ensure_metric_page(self.current_metric) + self._refresh_metric_tabs() + self._metric_frames[self.current_metric].tkraise() + + def _bind_mousewheel(self, canvas): + def _on_wheel(event): + if event.num == 4 or getattr(event, "delta", 0) > 0: + canvas.yview_scroll(-3, "units") + elif event.num == 5 or getattr(event, "delta", 0) < 0: + canvas.yview_scroll(3, "units") + + def _bind(_e): + canvas.bind_all("", _on_wheel) + canvas.bind_all("", _on_wheel) + canvas.bind_all("", _on_wheel) + + def _unbind(_e): + canvas.unbind_all("") + canvas.unbind_all("") + canvas.unbind_all("") + + canvas.bind("", _bind) + canvas.bind("", _unbind) + + def _ensure_metric_page(self, key): + if key in self._metric_frames: + return + vars_list = self._metric_vars[key] + page = tk.Frame(self._sliders_body, background=self.CARD) + page.grid(row=0, column=0, sticky="nsew") + page.columnconfigure(0, weight=0, minsize=44) + page.columnconfigure(1, weight=1) + for i, var in enumerate(vars_list): + tk.Label( + page, text=f"J{i + 1}", + background=self.CARD, foreground=self.MUTED, + font=(self.font_family, 11), + ).grid(row=i, column=0, sticky="w", padx=(0, 12), pady=8) + self._add_slider(page, i, 1, var) + self._metric_frames[key] = page + + def _set_metric(self, key): + if key == self.current_metric: + return + self.current_metric = key + self._ensure_metric_page(key) + self._refresh_metric_tabs() + page = self._metric_frames.get(key) + if page is not None: + page.tkraise() + + def _refresh_metric_tabs(self): + for k, btn in self._metric_tab_buttons.items(): + if k == self.current_metric: + btn.configure(background=self.CARD, foreground=self.TEXT, + font=(self.font_family, 11, "bold")) + else: + btn.configure(background=self.TROUGH, foreground=self.MUTED, + font=(self.font_family, 11)) def _add_slider(self, parent, row, column, variable): - frame = ttk.Frame(parent) - frame.grid(row=row, column=column, padx=4, pady=2, sticky="ew") + frame = tk.Frame(parent, background=self.CARD) + frame.grid(row=row, column=column, padx=(0, 8), pady=4, sticky="ew") frame.columnconfigure(0, weight=1) validate_int = (self.root.register(self.validate_byte_text), "%P") scale = ttk.Scale( - frame, - from_=0, - to=255, - orient="horizontal", - command=lambda value, var=variable: self.set_int_value(var, value), + frame, from_=0, to=255, orient="horizontal", + command=lambda v, var=variable: self.set_int_value(var, v), ) scale.set(variable.get()) scale.grid(row=0, column=0, sticky="ew") - # variable.trace_add( - # "write", - # lambda *_args, var=variable, widget=scale: widget.set(var.get()) - # ) - variable.trace_add( - "write", - lambda *_args, var=variable, widget=scale: widget.set(var.get()), - ) - spin = ttk.Spinbox( - frame, - from_=0, - to=255, - width=4, - textvariable=variable, - validate="key", - validatecommand=validate_int, - ) - spin.grid(row=0, column=1, padx=(4, 0)) + + def _sync_scale(*_a, var=variable, w=scale): + try: + w.set(var.get()) + except tk.TclError: + pass + + trace_id = variable.trace_add("write", _sync_scale) + self._slider_traces.append((variable, trace_id)) + + def _jump(event, s=scale, var=variable): + width = max(1, s.winfo_width()) + frac = max(0.0, min(1.0, event.x / width)) + self.set_int_value(var, frac * 255) + return "break" + + scale.bind("", _jump) + scale.bind("", _jump) + + ttk.Spinbox( + frame, from_=0, to=255, width=4, textvariable=variable, + validate="key", validatecommand=validate_int, + ).grid(row=0, column=1, padx=(8, 0)) + + def _build_status(self, parent): + row = ttk.Frame(parent, style="App.TFrame") + row.grid(row=1, column=0, sticky="ew", pady=(12, 0)) + row.columnconfigure(2, weight=1) + self.status_dot = tk.Canvas( + row, width=10, height=10, background=self.BG, highlightthickness=0, + ) + self.status_dot.grid(row=0, column=0, padx=(0, 8)) + self.status_dot.create_oval(1, 1, 9, 9, fill=self.GREEN, outline="") + ttk.Label(row, textvariable=self.status_var, style="Muted.TLabel").grid( + row=0, column=1, padx=(0, 12), sticky="w" + ) + ttk.Label(row, textvariable=self.summary_var, style="Muted.TLabel").grid( + row=0, column=2, sticky="w" + ) + + # ---------- helpers ---------- def set_int_value(self, variable, value): variable.set(max(0, min(255, int(round(float(value)))))) @@ -375,14 +774,20 @@ def apply_topic(self): def apply_model(self): old_speeds = [var.get() for var in self.speed_vars] old_torques = [var.get() for var in self.torque_vars] + old_touch_shape = self.touch_shape() + old_canvas_size = self.touch_canvas_size() model = self.model_var.get() self.joint_count = JOINT_COUNTS[model] self.position_vars = [tk.IntVar(value=v) for v in position_defaults(model)] self.speed_vars = self.resize_vars(old_speeds, 255) self.torque_vars = self.resize_vars(old_torques, 255) self.watch_command_vars() - self._build_joint_table() - self._build_feedback(self.main) + if (self.touch_shape() != old_touch_shape + or self.touch_canvas_size() != old_canvas_size): + self._build_feedback(self.middle_container) + else: + self.redraw_touch_matrix() + self._build_control(self.middle_container) self.update_summary() self.command_dirty = False @@ -401,11 +806,10 @@ def mark_command_dirty(self, *_args): self.command_dirty = True def update_summary(self): - self.summary.set( - f"{self.side_var.get()} {self.model_var.get()} | " - f"{self.joint_count} joints | {self.topic_var.get()} | " - f"{self.state_topic_var.get()} | {self.touch_topic_var.get()} | " - f"{self.info_topic_var.get()}" + self.summary_var.set( + f"{self.side_var.get()} · {self.model_var.get()} · " + f"{self.joint_count} 关节 · 状态 {self.state_topic_var.get()} · " + f"触觉 {self.touch_topic_var.get()}" ) def update_feedback_topics(self): @@ -419,18 +823,15 @@ def update_feedback_topics(self): self.info_topic_var.get(), ) - def set_all_positions(self, value): - for var in self.position_vars: - var.set(value) - self.publish() - def publish(self): self.command_dirty = False positions = [var.get() for var in self.position_vars] speeds = [var.get() for var in self.speed_vars] torques = [var.get() for var in self.torque_vars] self.node.publish_command(positions, speeds, torques) - self.status.set(f"Published {len(positions)} joints to {self.node.topic}") + self.status_var.set( + f"已发送 {len(positions)} 关节 → {self.node.topic}" + ) def publish_loop(self): if self.closed: @@ -440,57 +841,7 @@ def publish_loop(self): period_ms = max(1, round(1000 / max(1, self.rate_var.get()))) self.root.after(period_ms, self.publish_loop) - def _build_feedback(self, parent): - if self.feedback_container is not None: - self.feedback_container.destroy() - - self.feedback_container = ttk.LabelFrame(parent, text="Feedback") - self.feedback_container.grid(row=2, column=0, sticky="nsew", pady=(8, 0)) - self.feedback_container.columnconfigure(0, weight=3) - self.feedback_container.columnconfigure(1, weight=2) - self.feedback_container.rowconfigure(0, weight=1) - - state_frame = ttk.LabelFrame(self.feedback_container, text="State / Info") - state_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) - state_frame.columnconfigure(1, weight=1) - for row, (name, var) in enumerate(self.state_vars.items()): - ttk.Label(state_frame, text=name).grid(row=row, column=0, padx=4, pady=2, sticky="w") - ttk.Label(state_frame, textvariable=var, width=64).grid( - row=row, column=1, padx=4, pady=2, sticky="ew" - ) - - self.info_vars = [] - for index in range(6): - var = tk.StringVar(value="-") - self.info_vars.append(var) - row = len(self.state_vars) + index - ttk.Label(state_frame, text=f"info {index + 1}").grid( - row=row, column=0, padx=4, pady=2, sticky="w" - ) - ttk.Label(state_frame, textvariable=var, width=64).grid( - row=row, column=1, padx=4, pady=2, sticky="ew" - ) - - touch_frame = ttk.LabelFrame(self.feedback_container, text="Touch") - touch_frame.grid(row=0, column=1, sticky="nsew", padx=(6, 0)) - touch_frame.rowconfigure(0, weight=1) - self.touch_canvases = [] - canvas_width, canvas_height = self.touch_canvas_size() - for finger in range(5): - touch_frame.columnconfigure(finger, weight=1) - finger_frame = ttk.LabelFrame(touch_frame, text=FINGER_NAMES[finger]) - finger_frame.grid(row=0, column=finger, padx=3, pady=3, sticky="nsew") - finger_frame.columnconfigure(0, weight=1) - finger_frame.rowconfigure(0, weight=1) - canvas = tk.Canvas( - finger_frame, - width=canvas_width, - height=canvas_height, - highlightthickness=0, - ) - canvas.grid(row=0, column=0, padx=2, pady=2, sticky="nsew") - canvas.bind("", lambda _event: self.redraw_touch_matrix()) - self.touch_canvases.append(canvas) + # ---------- feedback rendering ---------- def refresh_feedback(self): if self.closed: @@ -509,7 +860,7 @@ def refresh_feedback(self): def update_info_lines(self, info): lines = [part.strip() for part in info.splitlines() if part.strip()] for index, var in enumerate(self.info_vars): - var.set(lines[index] if index < len(lines) else "-") + var.set(lines[index] if index < len(lines) else "—") def format_values(self, values): return " ".join(str(int(value)) for value in values[: self.joint_count]) @@ -542,44 +893,38 @@ def display_shape(self): def touch_canvas_size(self): if self.model_var.get() == "O6": - return 56, 120 - return 72, 144 + return 60, 130 + return 78, 156 def draw_heatmap(self, canvas, matrix): display_rows, display_cols = self.display_shape() width = max(1, canvas.winfo_width()) height = max(1, canvas.winfo_height()) padding = 4 - usable_width = max(1, width - padding * 2) - usable_height = max(1, height - padding * 2) + usable_w = max(1, width - padding * 2) + usable_h = max(1, height - padding * 2) target_ratio = display_cols / display_rows - usable_ratio = usable_width / usable_height + usable_ratio = usable_w / usable_h if usable_ratio > target_ratio: - draw_height = usable_height - draw_width = draw_height * target_ratio + draw_h = usable_h + draw_w = draw_h * target_ratio else: - draw_width = usable_width - draw_height = draw_width / target_ratio - x_offset = (width - draw_width) / 2 - y_offset = (height - draw_height) / 2 - cell_w = draw_width / display_cols - cell_h = draw_height / display_rows + draw_w = usable_w + draw_h = draw_w / target_ratio + x_off = (width - draw_w) / 2 + y_off = (height - draw_h) / 2 + cell_w = draw_w / display_cols + cell_h = draw_h / display_rows canvas.delete("all") for row in range(display_rows): for col in range(display_cols): value = self.display_value(matrix, row, col) - x1 = x_offset + col * cell_w - y1 = y_offset + row * cell_h - x2 = x1 + cell_w - y2 = y1 + cell_h + x1 = x_off + col * cell_w + y1 = y_off + row * cell_h canvas.create_rectangle( - x1, - y1, - x2, - y2, + x1, y1, x1 + cell_w, y1 + cell_h, fill=self.heat_color(value), - outline="#333333", - width=1, + outline="#dfe3ea", width=1, ) def display_value(self, matrix, display_row, display_col): @@ -587,19 +932,33 @@ def display_value(self, matrix, display_row, display_col): return matrix[display_row][display_col] return 0 + # Heatmap gradient: near-neutral idle, then cool→warm spectrum + # (cyan → green → amber → red) so each pressure level has a distinct hue. + _HEAT_STOPS = ( + (0.00, (0xee, 0xf2, 0xf7)), + (0.25, (0x06, 0xb6, 0xd4)), + (0.50, (0x22, 0xc5, 0x5e)), + (0.75, (0xf5, 0x9e, 0x0b)), + (1.00, (0xdc, 0x26, 0x26)), + ) + def heat_color(self, value): value = max(0, min(255, int(value))) - if value <= 127: - ratio = value / 127.0 - red = int(255 * ratio) - green = int(80 + 175 * ratio) - blue = int(255 * (1 - ratio)) - else: - ratio = (value - 127) / 128.0 - red = 255 - green = int(255 * (1 - ratio)) - blue = 0 - return f"#{red:02x}{green:02x}{blue:02x}" + ratio = value / 255.0 + stops = self._HEAT_STOPS + for i in range(len(stops) - 1): + p0, c0 = stops[i] + p1, c1 = stops[i + 1] + if ratio <= p1: + t = (ratio - p0) / (p1 - p0) if p1 > p0 else 0.0 + r = int(c0[0] + (c1[0] - c0[0]) * t) + g = int(c0[1] + (c1[1] - c0[1]) * t) + b = int(c0[2] + (c1[2] - c0[2]) * t) + return f"#{r:02x}{g:02x}{b:02x}" + r, g, b = stops[-1][1] + return f"#{r:02x}{g:02x}{b:02x}" + + # ---------- lifecycle ---------- def run(self): self.root.mainloop() @@ -625,9 +984,9 @@ def parse_args(): parser.add_argument( "--no-live", action="store_true", - help="only publish when pressing Publish/Open/Half/Close", + help="disable auto-publish; useful for silent debugging", ) - parser.add_argument("--rate", type=int, default=50, help="live publish rate in Hz") + parser.add_argument("--rate", type=int, default=60, help="live publish rate in Hz") return parser.parse_args() From 43c2b6b1761e9a72cc67072dd1064af36317fd37 Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Wed, 8 Jul 2026 16:39:46 +0800 Subject: [PATCH 15/19] refactor: move logo.png under assets/ subdirectory Relocate logo.png from the package root into assets/ to keep the root reserved for metadata files. CMakeLists installs the whole assets/ directory to share/, and the GUI loader (previous commit) already resolves the new path. --- src/linker_hand_cpp_ros2/CMakeLists.txt | 4 ++++ src/linker_hand_cpp_ros2/assets/logo.png | Bin 0 -> 21757 bytes 2 files changed, 4 insertions(+) create mode 100644 src/linker_hand_cpp_ros2/assets/logo.png diff --git a/src/linker_hand_cpp_ros2/CMakeLists.txt b/src/linker_hand_cpp_ros2/CMakeLists.txt index 5743a41..9a66cad 100644 --- a/src/linker_hand_cpp_ros2/CMakeLists.txt +++ b/src/linker_hand_cpp_ros2/CMakeLists.txt @@ -46,6 +46,10 @@ install (DIRECTORY launch DESTINATION share/${PROJECT_NAME} ) +install(DIRECTORY assets + DESTINATION share/${PROJECT_NAME} +) + # install(DIRECTORY config # DESTINATION share/${PROJECT_NAME} # ) diff --git a/src/linker_hand_cpp_ros2/assets/logo.png b/src/linker_hand_cpp_ros2/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7ef15bdb15d2d30093e2e5f7c71839ca5e19ac9c GIT binary patch literal 21757 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 literal 0 HcmV?d00001 From f27d42db5ddfbd96ffd45e0159e77f33f82a3ed3 Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Thu, 9 Jul 2026 10:43:40 +0800 Subject: [PATCH 16/19] refactor: overhaul hand_control_gui toolbar, sliders, and add copy-positions link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sliders: replace ttk.Scale with a Canvas-based custom widget — thin line track and circular thumb, click/drag to jump; keep Spinbox coupling. - Layout: shrink top toolbar to 42px, drop the logo, vertically center controls; add a bottom bar that hosts the logo centered (with brand text fallback). Hide the standalone status row while keeping status_var and summary_var for internal state. - Control card: append a subtle right-aligned "复制位置值" link footer that copies current position values (\"255, 255, ...\") to the clipboard. - Combobox: clear text selection and drop focus after choosing to avoid the stuck blue-highlighted state; short-circuit apply_model when the requested model equals the current one to prevent redundant rebuilds. --- .../scripts/hand_control_gui.py | 163 ++++++++++++------ 1 file changed, 109 insertions(+), 54 deletions(-) diff --git a/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py b/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py index de47e3d..1eee962 100755 --- a/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py +++ b/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py @@ -168,6 +168,7 @@ def __init__(self, node, model, side, topic, live_publish, publish_rate_hz): self.live_var = tk.BooleanVar(value=live_publish) self.rate_var = tk.IntVar(value=max(1, int(publish_rate_hz))) self.joint_count = JOINT_COUNTS[model] + self._current_model = model self.command_dirty = False self.middle_container = None @@ -399,54 +400,71 @@ def _build(self): self._build_feedback(self.middle_container) self._build_control(self.middle_container) - self._build_status(self.main) self.update_summary() - def _build_toolbar(self): - bar = tk.Frame(self.root, background=self.CARD, height=58) - bar.grid(row=0, column=0, sticky="ew") - bar.grid_propagate(False) - bar.columnconfigure(5, weight=1) + tk.Frame(self.root, background=self.BORDER, height=1).grid( + row=3, column=0, sticky="ew" + ) + self._build_footer() - self._logo_image = self._load_logo() + def _build_footer(self): + bar = tk.Frame(self.root, background=self.CARD, height=60) + bar.grid(row=4, column=0, sticky="ew") + bar.grid_propagate(False) + bar.columnconfigure(0, weight=1) if self._logo_image is not None: tk.Label( bar, image=self._logo_image, background=self.CARD, borderwidth=0, highlightthickness=0, - ).grid(row=0, column=0, padx=(20, 24), pady=8, sticky="w") + ).grid(row=0, column=0, pady=8) else: ttk.Label(bar, text="LinkerHand", style="Brand.TLabel").grid( - row=0, column=0, padx=(22, 28), pady=14, sticky="w" + row=0, column=0, pady=14 ) + def _build_toolbar(self): + bar = tk.Frame(self.root, background=self.CARD, height=45) + bar.grid(row=0, column=0, sticky="ew") + bar.grid_propagate(False) + bar.rowconfigure(0, weight=1) + bar.columnconfigure(4, weight=1) + + self._logo_image = self._load_logo() + ttk.Label(bar, text="型号", style="ToolbarLabel.TLabel").grid( - row=0, column=1, padx=(0, 6) + row=0, column=0, padx=(22, 6) ) model_box = ttk.Combobox( bar, textvariable=self.model_var, values=sorted(JOINT_COUNTS), state="readonly", width=6, ) - model_box.grid(row=0, column=2, padx=(0, 22)) - model_box.bind("<>", lambda _e: self.apply_model()) + model_box.grid(row=0, column=1, padx=(0, 22)) + model_box.bind( + "<>", + lambda _e, w=model_box: (self.apply_model(), self._clear_combo_selection(w)), + ) ttk.Label(bar, text="侧别", style="ToolbarLabel.TLabel").grid( - row=0, column=3, padx=(0, 6) + row=0, column=2, padx=(0, 6) ) side_box = ttk.Combobox( bar, textvariable=self.side_var, values=("left", "right"), state="readonly", width=6, ) - side_box.grid(row=0, column=4, padx=(0, 18)) - side_box.bind("<>", lambda _e: self.apply_side_topic()) + side_box.grid(row=0, column=3, padx=(0, 22)) + side_box.bind( + "<>", + lambda _e, w=side_box: (self.apply_side_topic(), self._clear_combo_selection(w)), + ) self.live_dot = tk.Canvas( bar, width=10, height=10, background=self.CARD, highlightthickness=0 ) - self.live_dot.grid(row=0, column=6, padx=(0, 6)) + self.live_dot.grid(row=0, column=5, padx=(0, 6)) ttk.Checkbutton( bar, text="实时", variable=self.live_var, style="Switch.TCheckbutton", command=self._refresh_live_dot, - ).grid(row=0, column=7, padx=(0, 22)) + ).grid(row=0, column=6, padx=(0, 22)) self._refresh_live_dot() self.live_var.trace_add("write", lambda *_a: self._refresh_live_dot()) @@ -457,6 +475,13 @@ def _refresh_live_dot(self): self.live_dot.delete("all") self.live_dot.create_oval(1, 1, 9, 9, fill=color, outline="") + def _clear_combo_selection(self, widget): + try: + widget.selection_clear() + except tk.TclError: + pass + self.root.focus_set() + def _card(self, parent): return tk.Frame( parent, background=self.CARD, @@ -620,10 +645,24 @@ def _build_control(self, parent): lambda e: canvas.itemconfigure(window_id, width=e.width), ) canvas.configure(yscrollcommand=scrollbar.set) - canvas.grid(row=1, column=0, sticky="nsew", padx=(14, 0), pady=(0, 14)) - scrollbar.grid(row=1, column=1, sticky="ns", padx=(0, 6), pady=(0, 14)) + canvas.grid(row=1, column=0, sticky="nsew", padx=(14, 0), pady=(0, 10)) + scrollbar.grid(row=1, column=1, sticky="ns", padx=(0, 6), pady=(0, 10)) self._bind_mousewheel(canvas) + footer = tk.Frame(card, background=self.CARD) + footer.grid(row=2, column=0, columnspan=2, sticky="ew", + padx=10, pady=(0, 6)) + footer.columnconfigure(0, weight=1) + link = tk.Label( + footer, text="复制位置值", + background=self.CARD, foreground=self.MUTED, + font=(self.font_family, 10), cursor="hand2", padx=2, pady=2, + ) + link.grid(row=0, column=1, sticky="e") + link.bind("", lambda _e: self._copy_positions()) + link.bind("", lambda _e: link.configure(foreground=self.ACCENT)) + link.bind("", lambda _e: link.configure(foreground=self.MUTED)) + body.columnconfigure(0, weight=1) body.rowconfigure(0, weight=1) self._sliders_body = body @@ -700,57 +739,70 @@ def _add_slider(self, parent, row, column, variable): frame.grid(row=row, column=column, padx=(0, 8), pady=4, sticky="ew") frame.columnconfigure(0, weight=1) validate_int = (self.root.register(self.validate_byte_text), "%P") - scale = ttk.Scale( - frame, from_=0, to=255, orient="horizontal", - command=lambda v, var=variable: self.set_int_value(var, v), + + slider_h = 24 + thumb_r = 7 + pad = thumb_r + 2 + canvas = tk.Canvas( + frame, height=slider_h, background=self.CARD, + highlightthickness=0, borderwidth=0, ) - scale.set(variable.get()) - scale.grid(row=0, column=0, sticky="ew") + canvas.grid(row=0, column=0, sticky="ew") + + y = slider_h // 2 + track_id = canvas.create_line(0, y, 0, y, fill=self.TROUGH, + width=2, capstyle="round") + fill_id = canvas.create_line(0, y, 0, y, fill=self.ACCENT, + width=2, capstyle="round") + thumb_id = canvas.create_oval(0, 0, 0, 0, fill=self.ACCENT, outline="") - def _sync_scale(*_a, var=variable, w=scale): + def _redraw(): + w = max(1, canvas.winfo_width()) try: - w.set(var.get()) + v = variable.get() except tk.TclError: - pass - - trace_id = variable.trace_add("write", _sync_scale) - self._slider_traces.append((variable, trace_id)) - - def _jump(event, s=scale, var=variable): - width = max(1, s.winfo_width()) - frac = max(0.0, min(1.0, event.x / width)) - self.set_int_value(var, frac * 255) + v = 0 + frac = max(0.0, min(1.0, v / 255.0)) + x0, x1 = pad, w - pad + if x1 <= x0: + x1 = x0 + 1 + cx = x0 + frac * (x1 - x0) + canvas.coords(track_id, x0, y, x1, y) + canvas.coords(fill_id, x0, y, cx, y) + canvas.coords(thumb_id, cx - thumb_r, y - thumb_r, + cx + thumb_r, y + thumb_r) + + def _jump(event): + w = max(1, canvas.winfo_width()) + x0, x1 = pad, w - pad + span = max(1, x1 - x0) + frac = max(0.0, min(1.0, (event.x - x0) / span)) + self.set_int_value(variable, frac * 255) return "break" - scale.bind("", _jump) - scale.bind("", _jump) + canvas.bind("", lambda _e: _redraw()) + canvas.bind("", _jump) + canvas.bind("", _jump) + + trace_id = variable.trace_add("write", lambda *_a: _redraw()) + self._slider_traces.append((variable, trace_id)) ttk.Spinbox( frame, from_=0, to=255, width=4, textvariable=variable, validate="key", validatecommand=validate_int, ).grid(row=0, column=1, padx=(8, 0)) - def _build_status(self, parent): - row = ttk.Frame(parent, style="App.TFrame") - row.grid(row=1, column=0, sticky="ew", pady=(12, 0)) - row.columnconfigure(2, weight=1) - self.status_dot = tk.Canvas( - row, width=10, height=10, background=self.BG, highlightthickness=0, - ) - self.status_dot.grid(row=0, column=0, padx=(0, 8)) - self.status_dot.create_oval(1, 1, 9, 9, fill=self.GREEN, outline="") - ttk.Label(row, textvariable=self.status_var, style="Muted.TLabel").grid( - row=0, column=1, padx=(0, 12), sticky="w" - ) - ttk.Label(row, textvariable=self.summary_var, style="Muted.TLabel").grid( - row=0, column=2, sticky="w" - ) - # ---------- helpers ---------- def set_int_value(self, variable, value): variable.set(max(0, min(255, int(round(float(value)))))) + def _copy_positions(self): + text = ", ".join(str(var.get()) for var in self.position_vars) + self.root.clipboard_clear() + self.root.clipboard_append(text) + self.status_var.set(f"已复制位置到剪贴板 ({len(self.position_vars)} 关节)") + def validate_byte_text(self, text): if text == "": return True @@ -772,11 +824,14 @@ def apply_topic(self): self.update_summary() def apply_model(self): + model = self.model_var.get() + if model == getattr(self, "_current_model", None): + return + self._current_model = model old_speeds = [var.get() for var in self.speed_vars] old_torques = [var.get() for var in self.torque_vars] old_touch_shape = self.touch_shape() old_canvas_size = self.touch_canvas_size() - model = self.model_var.get() self.joint_count = JOINT_COUNTS[model] self.position_vars = [tk.IntVar(value=v) for v in position_defaults(model)] self.speed_vars = self.resize_vars(old_speeds, 255) From be68ea0d190c99164b4570abfedcd9a750645ffa Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Thu, 9 Jul 2026 11:29:18 +0800 Subject: [PATCH 17/19] chore: bump launch defaults to SDK 2.1.8 and refresh model presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VERSION 2.1.7 → 2.1.8 in both run.xml and run_left.xml. - run.xml: enable LEFT by default (G20), disable RIGHT (L6); set HAND_SPEED/HAND_EFFORT defaults to 255; drop LEFT_CANBUS/RIGHT_CANBUS args and their CAN_CHANNEL passthrough so the node falls back to its own default (can0). - run_left.xml: switch single-hand launch to CAN/G20 to match the double-hand default; align HAND_SPEED/HAND_EFFORT to 255. --- src/linker_hand_cpp_ros2/launch/run.xml | 19 +++++++------------ src/linker_hand_cpp_ros2/launch/run_left.xml | 10 +++++----- 2 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/linker_hand_cpp_ros2/launch/run.xml b/src/linker_hand_cpp_ros2/launch/run.xml index aa3e3fe..db741a9 100644 --- a/src/linker_hand_cpp_ros2/launch/run.xml +++ b/src/linker_hand_cpp_ros2/launch/run.xml @@ -1,29 +1,26 @@ - + - + - + - - + - + - - - + + - @@ -34,7 +31,6 @@ - @@ -56,7 +52,6 @@ - diff --git a/src/linker_hand_cpp_ros2/launch/run_left.xml b/src/linker_hand_cpp_ros2/launch/run_left.xml index ae01108..43f6a47 100644 --- a/src/linker_hand_cpp_ros2/launch/run_left.xml +++ b/src/linker_hand_cpp_ros2/launch/run_left.xml @@ -1,18 +1,18 @@ - + - - + + - - + + From d4c47853de31d017401997ad7ea11c9055b53abd Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Thu, 9 Jul 2026 11:29:27 +0800 Subject: [PATCH 18/19] =?UTF-8?q?chore:=20shorten=20GUI=20copy-values=20li?= =?UTF-8?q?nk=20to=20"=E5=A4=8D=E5=88=B6=E4=BD=8D=E7=BD=AE"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/linker_hand_cpp_ros2/scripts/hand_control_gui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py b/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py index 1eee962..f2fa90b 100755 --- a/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py +++ b/src/linker_hand_cpp_ros2/scripts/hand_control_gui.py @@ -654,7 +654,7 @@ def _build_control(self, parent): padx=10, pady=(0, 6)) footer.columnconfigure(0, weight=1) link = tk.Label( - footer, text="复制位置值", + footer, text="复制位置", background=self.CARD, foreground=self.MUTED, font=(self.font_family, 10), cursor="hand2", padx=2, pady=2, ) From 9d2b6c2b4b4750c0a8b22bfee4a6a7a20bb55bce Mon Sep 17 00:00:00 2001 From: liangshaoteng Date: Thu, 9 Jul 2026 11:29:38 +0800 Subject: [PATCH 19/19] docs: overhaul README with SDK/CAN setup, tests, and GUI screenshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quick start: add SDK install step, SocketCAN bring-up commands, single package build tip, parameter override example, colcon test section. - Sync the launch XML snippet with the current defaults (LEFT=G20 enabled, RIGHT=L6 disabled, HAND_SPEED/HAND_EFFORT=255). - GUI section: rewrite to match the new interface (top model/side + live toggle, tabbed 位置/速度/力矩 sliders with line + circle style, right Spinbox, "复制位置" link, bottom logo). Insert screenshot from assets/. - Update changelog pointer to git log instead of the missing CHANGELOG.md. --- README.md | 101 +++++++++++------- .../assets/hand_control_gui.png | Bin 0 -> 65419 bytes 2 files changed, 65 insertions(+), 36 deletions(-) create mode 100644 src/linker_hand_cpp_ros2/assets/hand_control_gui.png diff --git a/README.md b/README.md index e38a157..86b23c9 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- - [环境要求](#-环境要求) - [快速开始](#-快速开始) - [话题详情](#-话题详情) -- [控制 GUI](#-控制-gui) +- [控制 GUI](#️-控制-gui) - [许可证](#-许可证) - [联系我们](#-联系我们) - [更新日志](#-更新日志) @@ -33,16 +33,33 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- ## 🚀 快速开始 + #### 下载 + git clone https://github.com/linker-bot/linkerhand-cpp-ros2.git +#### 安装 SDK + +先安装匹配版本的 [linkerhand-cpp-sdk v2.1.8](https://github.com/linker-bot/linkerhand-cpp-sdk/releases/tag/v2.1.8)(见其 README)。本仓库通过 `find_package(linkerhand-cpp-sdk 2.0 CONFIG REQUIRED)` 引用;节点的 RPATH 会指向 `${linkerhand-cpp-sdk_LIBRARY_DIR}`,SDK 位置变更后需要重新 `colcon build`。 + #### 编译 + cd linkerhand-cpp-ros2/ - colcon build + colcon build # 全量构建 + colcon build --packages-select linker_hand_cpp_ros2 # 仅构建核心节点包 + +#### 启动 CAN 设备 + +节点通过 SocketCAN 打开 `can0` / `can1` 等接口。使用前需先把 CAN 通道拉起(`CAN_BITRATE` 与 launch 参数保持一致): -#### 配置XML文件 + sudo ip link set can0 down 2>/dev/null + sudo ip link set can0 up type can bitrate 1000000 -`launch/run.xml` 用于双手同时启动,`launch/run_left.xml` 用于仅启动单手(默认左手 G20)。 +> Modbus 通信当前仅支持 O6 / L7 / L10;O20 依赖 CAN-FD,暂未完成 O20 实机运行适配。 + +#### 配置 XML 文件 + +`launch/run.xml` 用于双手同时启动,`launch/run_left.xml` 用于仅启动单手。以 `run.xml` 为例(省略部分与实际默认值对齐): ```xml @@ -51,35 +68,44 @@ LinkerHand-CPP-ROS2 是灵心巧手科技有限公司开发,基于 LinkerHand- - - - - - + + + + - - - - - + + + + - - - + + + ...... ``` +> 手动打开CAN设备后,执行launch会自动检测左手或右手CAN通道。 + #### 运行 + source install/setup.bash - # 双手 + # 双手(按 run.xml 默认配置启动) ros2 launch linker_hand_cpp_ros2 run.xml # 仅左手 ros2 launch linker_hand_cpp_ros2 run_left.xml + # 覆盖某个参数的示例 + ros2 launch linker_hand_cpp_ros2 run.xml LEFT_JOINTS:=L10 LEFT_CANBUS:=can1 + +#### 测试与静态检查 + + colcon test && colcon test-result --verbose + +项目已禁用 cpplint / uncrustify / copyright / xmllint,主要走 `ament_lint_auto` 剩余检查。 ## 📚 话题详情 @@ -144,16 +170,6 @@ L25: ["大拇指根部", "食指根部", "中指根部","无名指根部","小 ```bash # 清除故障码 $ ros2 topic pub /left_hand_settings std_msgs/msg/String "data: '{\"setting_cmd\": \"clear_faults\"}'" - - # 设置电流(linkerhand-cpp-sdk 当前版本暂不支持,节点会输出警告) - $ ros2 topic pub /left_hand_settings std_msgs/msg/String "data: '{\"setting_cmd\": \"set_electric_current\", \"params\": {\"electric_current\": 50}}'" - - # 使能 - $ ros2 topic pub /left_hand_settings std_msgs/msg/String "data: '{\"setting_cmd\": \"enable\"}'" - - # 失能 - $ ros2 topic pub /left_hand_settings std_msgs/msg/String "data: '{\"setting_cmd\": \"disable\"}'" - ``` @@ -214,21 +230,30 @@ data: ## 🎛️ 控制 GUI -`hand_control_gui.py` 提供基于 tkinter 的可视化面板,可拖动滑块直接下发 `JointState`,并 -显示 `state` / `touch` / `info` 反馈(含触觉热力图)。它只依赖标准话题,可与任意 `run.xml` 场景配合。 +`hand_control_gui.py` 提供基于 tkinter 的可视化面板:拖动线性滑块直接下发 `JointState`,同时显示 `state` / `touch` / `info` 反馈(含触觉热力图)。它只依赖标准话题,可与任意 `run.xml` 场景配合。 + +

+ hand_control_gui 截图 +

```bash source install/setup.bash # 自动从 /linker_hand_{side}_node 读取 HAND_JOINTS 型号参数 -ros2 run linker_hand_cpp_ros2 hand_control_gui.py --side left +ros2 run linker_hand_cpp_ros2 hand_control_gui.py # 或显式指定型号 ros2 run linker_hand_cpp_ros2 hand_control_gui.py --side right --model L10 ``` -常用参数:`--side left|right`、`--model L6|O6|L7|L10|L20|L21|L25|G20|O20`、`--rate `。 -GUI 顶部可切换 side / model / 话题;三列滑块分别对应 `position` / `speed` / `torque`, -取值范围 0–255。position 初始值按型号可在 `hand_control_gui.py` 的 `POSITION_DEFAULTS` -字典中按型号定制(默认全 255)。 +功能界面: + +- **顶部**:型号 / 侧别 下拉框;「实时」开关(打开后按 `--rate` 周期 (默认60hz) 把当前滑块值发布出去)。 +- **中部左**:反馈面板 —— `位置` / `速度` / `力矩` 三行数值,下方是 5 指触觉热力图;型号切换会自动重建。 +- **中部右**:控制面板 —— 通过顶部 tab 在 `位置` / `速度` / `力矩` 之间切换;点击或拖动线条即可调整(0–255);右侧 Spinbox 支持精确输入。 +- **底部右下**:`复制位置值` 链接,把当前所有关节位置以 `"255, 255, 255, ..."` 的形式写入剪贴板。 + +常用参数:`--side left|right`、`--model L6|O6|L7|L10|L20|L21|L25|G20|O20`、`--rate `、`--no-live`(禁用自动发布,仅调试用)。 + +初始位置:`hand_control_gui.py` 中的 `POSITION_DEFAULTS` 按型号定制(默认全 255);`G20` 使用 `[255, 255, 255, 255, 255, 255, 130, 125, 125, 125, 255, 255, 255, 255, 255, 255]` 作为张开手掌的安全初值。 依赖 `python3-tk` 与 `rclpy`。 @@ -246,7 +271,11 @@ Copyright (c) 2026 灵心巧手(北京)科技有限公司 ## 📝 更新日志 -详细的版本更新记录请参考 [CHANGELOG.md](CHANGELOG.md)(待创建)。 +详细的版本更新记录请查看 [`git log`](https://github.com/linker-bot/linkerhand-cpp-ros2/commits/main) 或本地执行: + +```bash +git log --oneline --decorate +``` --- diff --git a/src/linker_hand_cpp_ros2/assets/hand_control_gui.png b/src/linker_hand_cpp_ros2/assets/hand_control_gui.png new file mode 100644 index 0000000000000000000000000000000000000000..97c40859ccf16ec39065245a66114b6964ee299c GIT binary patch literal 65419 zcmbrl1yr0*vnNapf`tqaG=wm?J0y6*;1=8=c<|tE!3KA?5Zv7v2*KT526uNDV0Os= z-FNSvd+(mz@9T3C<~-6}U0wG4sw(JNLtRdsSTNxef?WuGc2Y zFiIdbLEv`OWyR3(FcU`Um?dgZT+!WuxD+;1PWI#dd&rfMlX1GB5a`8ozr`0`(1@e6 z$}0A&^jWffe!G5_u!}`ntd&9dOP4xS7u`ovkdR`ZQVqi)_g)<=M~75H_dECYBCq{Y z4~{4pdUXCiu&lVgNin;Fa%Plp0PN=Qu zK~fG=8Yl3xHPd~#21B$Ky`g%1kCs9bWq@CuHWQGzK;02PMCga) zvJSlg1y!RhxptIz_KxD;H@$~nw6iH_6Eb6Ck6~Y^k#O-E=z+m{K{UJuD?wPQUAu|I z!45eCu(OMQR_eoRXHoEI2yf76ZA%$zDbOoKSs-X$J#pOCoO*y)>LO|62 z??T>PtQc9=X1H0Bs}irpU!#N2bZEs10aIwt9$y}zi8snoD3GQyQ0HSS zI96RA&I6@4=!ON309xzNC{ChkxFL}v;I zUhYi~&&+&aXFux-BM!l1loS`I;iE(FjMVF{!g9VAJBWgYCO=k?2nmTZy4Ntn))_D! z^ux@BSupb{TN3v4pR>E0vn>b&(y4zU2)`2sk?`0jvKjTw)tC#qi%aR%y!11vLPBys z)TpzvG&Ho|=zG(krPJzppW*dz(;0&AHjg7uDXd$uYsDr&UGd^%Rr===BY@@A?9N{C4ix4zrhm`<0b zy??p1wB#>V^KJB(Ia)2;7WKoeVnAV z^$}t|V``Sp!WR+{lvrLy zmRqQ(sQBaVZEYn)M@QdR`T7bkoua&YJ5#1BXl}l@pP+9e;52?Js$65H9H4gyZOzbp zkA$RT@zhlV;i8!1Bt=LU4o}a{>>M1@1Uzn=UCwNw7gtxh4Yo#y0w*UY{)<16s$H6muls0D0^-2YO?ugJ}5q@3TT|1-u?o*Xolw%~gt24iIls=Hns> zxvkHSFt0Z1D7}l0e?`L6%pF1L=Gh(dOn9LSPW~XB9b~|FB54Oy~CYc4udY z)MJJcKuJNd4{dGLZS`z&I=VbLVddapu*yLg5OR9@y>^j00clyZkIfx5U)pBnO(*|U zTI@}goDCWDrbEXN*3b)ne+7BbfmZe?i9(%QySvFSyWaTe^Ru)0dYd(b7;X-w&9{2N zDW&2&Iy&rk#&Qt4fmpk(t&NiY5Dn_8bMm*RPk?))artJwf7NqNkU7?HA2o=)ZOID-VZsN}*$7ThVOAPWn*WLlQgo4P@szwaalI-FZios2)EJo@<(KJv_kQQ^oMXMS=k{SnG^PZ-=wm6 zn~<2O)**nFqmUFEd%lz!iKdHAwfff~ej^w@laP?GZP1dFEBonk!#A9lmuG@7ctuxP z`V&90Upy`?boTsHPfyPm8eD|}CAzp^(69Z#UE88nn@pm!n@7RFInSwc%9@4c#5;BW>S&gGa_cZyX0W zSsBzF_t1-`5U8R&z91mdaP-eg-ogJT>i_#K9>>3bnfz+SBj+I%kBpy+gF*gcD5$bA zK0IC&2n3QH4t0gKC45}vOD##BP8nCox<)Pj61y^sT)7R!)dqK|s5^3pg0dV=ygZicK3@+9 zRf48F;yjZ2nHe<_y91sZ8@&WkWJ6~g><2w2=MB_jhU~mCUsIokF}5xtj=Yvp7~gxEanqQ}y|Dz0#< z$b_CcXZW1Tx2}1}R5KI)GE;Da`gtCy)53ZbWf=P8fney1FLO-9YKn-kz< zRBRKDD^$3=N6j>0I9X) zFEt|s*|yMK+T2b6d0yMBmeyaX_Bhr%HcZp8)3<_*SbqW#2eV9G3$HCbCJ6{lj6B03 zJv&;6s`6Mm`cz+UvENx&MuEBYPLQ=Pof3#WP2A+EV}5@fBPE(n3jqt}PQ>6b{=#T& zFd8iNvKDl~i#JuOk-w^lY35ZOr4(5nQj-(^w_#kR1KRM@3`K!2!e1SS}bGE@Yu2DxO6nKY0sKBqa zR58x{q_Ek)4ZKw(qi>vs%Ba7{in144~03&}YCI%&_qqCdp<@(Q|myeoP?ROUT z$;FFCAMtJ?n8s4|Iz$hM~Z)LxHA8wpEikV3KzH?5%?J#H~KR@|s(|c5{x}u$= zMw_(Gkrn5{Q$s09ix(5KS`5H6{d%A9p*HNI%9Ywi$)9*!c|Mv3Ru5V~i9q|1$XdFX z>!F=K{I0F++@;xARjY*S-ZjCD8XXY#aLiH>1xbSYn9$aiq;T3)g`X#|vKegc4%JHA zLaZBSc(z;EUrPW;uPcvXS3zyV*ys#fGY*p^IEXtrXa*>6(EYzsrr_cTUf+XWk&P3d!)wem#tSCT}I zLWSN5j)red?6|v#z#1|<~CZ5KL`1uL3xdT$8KHZ4(jf;@jY0! zEqrQxrMl@nK~V4PcwFC5$G3UU)ojE`FIA>owMsqEy7du0-R2NxK{EM)URV3C%la9+ zu}RlJDD`BK!To$gaG1Dm4|3)dR!d0e}>VjXmoZurFrCGA80K~?=w}y$(yZ}zk4RfWP9zKs^<(exG-qeT!4YAK)86^bjB9M>I90MbmMH4f(3y8iu@L{z8SmwK zX@QkK|Ng5~OY`lNs*WC+{UV|SW9dR_lxf|sZu@Ei6>+cRaq__QGPM~eY4X!ml4(SE z*rH~KOZA^HQKO7JoX}CpIm>2es)FXWs`EE?AJubxt=%eKzjwY{L8$9FjwhQi7_TFmQ0BCP_#%d~$A_LmPK=o0)Y8eTop5!>eTn>+)%66`OC$jULG!;KVm-8&-ovu zIwXPBx)0%+Fm2O-?3XVwR66;NRIehqHPsZiCdP_0?ekauGQ*vwd}m0mSMxL+t^XZ$ zJXTbDISI_ZS!{U5v9A?2lN)uFz_WI7U^@kjY)e29X{7v zPg6%GuDD=9JaNOVugt4=8OU@6rAlM&_v3kcis6?jfXh9SRF30wV1RRBo2$#Q8rjLkhhbS{hCrn6(N z%rm2UwLn^6KNpGMWYO+>wGx!3)m3r$zP zfi|b;PyBR@3vLE-L`B9&Q*qONYX$-UHf6#;K883>0kLtD(4zZtFe(%pLYZ^aDWj7e zcENHBmq8P-LgN)IK%96-wo?3|9|D#@LOQt8Gm#oKSs71}*LyM4sk)R5wfVDBeH93O zn>=&yB|V{1MUej{tB4g46-jV+TpRIezbKZw8=Q*5z%wEMA260~Nyu+@Nh#m7<5B1jT59yS;L#iz60q#YyQ+I&OxY zd=kLQ8r+>?GXf7w_rKTH{zzWHdvnzY0+FVKDt$lTakUM=YJlr1!SJ}!x*VWOUj4CA zt~e;u)99@>JUs@x16-n{6biuZ!dJgKh?|_WmW$>&zNu(04#z(_u8~xIBie?gJ*8eR zi^NB_H0;^$lIePp=hQ8sx^H5gQQ&n|BNaH59#2~ATHM&=sT6nt0s#X9KF*&PPL!z3 z^>ads7#gv&H-}0$t#xP=m?|t9(~30H^f}p;^@e?88?9>bDAU$~cErs`i3hiF^H^5e zErKFSvjws+b>civ6Wki7)zm6^x?cw9-K69|;8f+XuG1!00zqw3t7k>IMgKqnZ7!!< zu@*;(=Stl}KfB6sgS>XDAf8Agx9d%RANwF=Ybq*OMq?H`!O1)QwVU11&9{_V%v2Bs z1tq180{i_KK?7x_IW({H@`cz`8sVsgcjt777rbOF_yUH9aV?^+SY_IpE9tZC3w<+H z+r7S%kXNd~aicNm(c>h=Yj>4gNt!J66+}2l<57F5Z9~82yR+yL5C{OU zBJ0?3W0@X%ZhV&9KJzvVN>7z>bHDpsTx@K)C1;wvt8`POAb%0_t`~Rf0ry*8x6xWbZO?t9v~yrMLkODiLxNE;9_*K$Fn!oEq~!l1H%g6x?twWsf6g283Z>bF((cajk{LSW zX-z5-Vx*dt0Pz?kaJ?(v8^EIE)qP~caBn04C%Cx`czpM;C^kgO>%lEV8rkzggU2*PG_{w7+HkPKuwi!V>Ol!w0CKS}aVc zAJ1e#FbAwXH<&)Vp0yT0U_-g(-sQnMHltY-uOsyu4A@a~b3B+^E@z8!7YfdIv&GAbPY|aDq z9y1`h7vHT9(k~#&aC*3*Q$hZFCz0})`6DopFW%=NC*|)YDcT^=W=rBOuXK&`9C3XFS>x)v#NQ>n zEoQM27_RvLK{~!v{O8&K7h>^m5;pkqUJPgbL*hYfOm+Q-c~h>xdqeVe*K;QLd*{D` zPyTxV;{O$h`TsprHKi%w`KQ+$BnXDMTRoSJLO?^UO;KAP+$o8UYxn1>AM;qgWky0sr0}>(gPIW}$_Ck25Y9 zXEIA(Rv1_`eDu?se>tIi?2BIOA^imvhfdWo}R3ZT z-7^D!x(3@m@UzOWOqg8v93Ly?q_m_0o(t^m&&_%TexHSP=-0ffwzvMMot`8Si!f80eX|Ng_P!VwXPWgtK+e!zUaN53KT;ffUEsvgGc2l4i z1ZXKZ0_0<%>E)Dq{~3XleG!#x$nyt}+T2v?hR_Avx*{XrA|w94Z5(Sh81Y+QRz)qm zE|TijF`~$=De4DtS(?b%v^0LN(#=9;w`7)H>Wy<0N035_t>Am8(+X#kSpEQ|=f?g8 zzd2KpKKxK&Vp7XT4#aM{(qN5rxlL9zBGJYX0-eKRC0b*8a<&FmaNoVAk=tbCa6mv~ z84(I{8sf$~bNMY&pEl4;pU4RlbmQ?8l&k9kEo>|DLLQXmc^vT$)7A1!v}=fNP+omF5a`!m|0kr%EjDJS^hY~iO$f4uHC zE9mpvM}y%&JDDx=+8G-+^Gepw^ZH#@hCXyadm1hgIacY~RU);c%yV8LJ=5|{_sik; z@}l`IElQw%Sc;7HOi5!thcE)#qIqtvkkfd#S~jgCwa{2orbj(gXy?*h@~oR;jk?kJ zrsbtO;G@^T&OX0a2HTDkP_%5tyEYk<5NVlRp-=*S_@pD*i_^^83~ELic(jFOmOcFX zWF>EE;GVjxCcY}ve)o!ym5JqgHQpfllc0n7`+_wkcHqsT1Vd=nSfP6N@_!0 zM(h>RaRUv41DRi5OBlH_Ve*%hYJG4c?>aNLQvrNGlq4|Oo-NL05(YJfI}~>641M&* z;(-`-#;rT4wBt9uQfI4dcoP5h2}0j;rC(tNOTUTz6vzd6FdO(Phc7z~9J;!UORY=W zb^FL=H_n-j!2LYs-!kI?0OXGeAnc^1WKlCVM}%BRno>oV;9C68S^!3OGfA{zC9aW} z8BPoJ3q*D!ka>#G(ChxF2({Q?E1nZgty9hLX0llQUD+$Bc*g(sn%e z${-5cosS0I`diIw$i4~oGBAf;Cwy!_b0Cn3hfSdqku}z*LJ*RVo~2qds#-I! zLHRaffMD{F!&8Zuo3M-h8B%P+*xuZ7*8Lq$Z@w|7qO#WSdv-B7E$66D`W1;%Ej1o8 z#(R!Xjw0op%2={h9PCvW0RfH4WAm%7u#{=6R=4l4%r6pg;p*IFJB}JHxspcbbqI^? z`tm9KB<)n86Zy6?8?Bi3u<8j?zFffuO0YNaw9XlF*nqH?X88bj9CbyI+q{oQYyREc zb!~ecY3j`Va;#2COANJZ?@>m}yS_wD628XP$q;_?x&_a7DZNB)m#|!o{R$E>*$jc5 zxl*|!CeG0BTZjsiKBdhp$T;^6MNCi%V5`dLw#%)s?ubN(P4$Kq z0{}=lx$E-k?Q+mA0exO=kJ<7t_#^DhmXJ{0jxZ&`KPRH`f^x+A=U52`iMb@g&gJEO zRR&wXbH}iQWZdq{WY1vJWjY>uBTM6cdd?{LSDT!3=hj~t6cWK}?4jn>7Skv6%qaW; zT}-C8`x8Df@`Uvsh>VMotV)Uck=5moFmr8c3CXHM--XjX`zW=Uo>Z~od=Z39PynY& z*CJBfj7(_gOvkeN;k2Rqd)!5OU_}1o=ROOked-TfKpQJ-j(oC^QnTRP+wKt1Q4Y-y zA&iIfzlgebR#H;TK%qU1BRI|6J+k68nZe%_iZ>t2>wnTR{~?9)?||h0Dr))nLsSpv zz`JL{f{@pUbJn-9sii-uxZXtgv^=?+khBuqXguKQsXcw1Ekgc-R~v_`azIBzcYRph zIP)%7^N)mN)XQdI5_@Xgm83O4{)#W&-eIuYsoYJ-NeP^eHv{g+cBAB-Z-0SkEYe}N z9B{22XT|P+xcRB;rm4|DwDn*|(7-M4?Dm$lHcL(z;a@TF_>{~vecbN`WyX2yEL3tX zxkd?o^|xDFmsQJ+B32muht^^VlT&GHQ~(N0K&+&jBe@HxF(;YVwEzNN8}y@2mm z;l8=&*f0`krMYaGQkMB%3!AaudWVwE>X^C470)bdkOlEJn|1ga`Gd~oxMj}1Z`Rw(+gssGwG9Hz6o$a6Jf0bJ5p7yIc9OmX;**27|#h50xS^Tz6fA zuS>IEn89iBRpeLD@@e=v7?KATv}M$QK#8!+lEPy-CM~(^Yi1Kvf#6expPOZDXbiBY z2<4p+A(ucWkCL?4p06%Tw`0~dG&L;sredfC0$bn<$imHSUMZ3tU;> zEVOQr4jXj}+#fwKX29y)522{h+z+*3c|=X>OWlDmfpGE=vr;MyR6+>}$-|9Wt1#Z0 zZw1B>POrGCIEF`Bx^_S`4yeQ5-){>}=s71Q&xBq~(8@(qe*CzXzG!2OJKMUU zkX-kTV`B@VENeY;0@!X%H269(U%YLsjmPPnBLjegrghY~B z%#DvCtT^=4b>|~6UrtN=e9rw$R_^M0n)+m4L7ShDmhJTZ8+{Kgem#U>sX%`qG>G7U-Mb^6|i;53T8ldLb=5HF;0VzZPQiTYk!qQO=QmMNV@ogCYOG z?o&`9&mpmVPEy9~=Y*8K?7i zoflhMXPfVj84p#zGQKN9PtfI?tNb=@m&L4+$S>&j(9u9G-yDgzZg*Shb%%1Gj`@aw zVY_DoBLh*Eq38`{RQ|-%$TI-jP)=3cL|f4LQ#4Fab7wlepo1qjEpz?=eaL4v#;~T) z!=NX`yKgN8is?L{71KPPM)}{j_Uw**&4nf#Ll$+wsCD`7opdQs1UPe zPk!SuldqB$y44z;kM;C_@_Dm4jr;;n0bz@UX2+N9G*0!y!on)`*J56D7A+G+N zS|u*bosxN6&O`a&xz$b+YAtbu+qXX(JjMnW5C7syQV0@Pwx^m$C+|S=X(EMzD zrNZP?+}*j6kQj4F_!&FYxQWZJ>D>$j&RxoTZlpr4<`ihL9(0=bEtWt5AznE^shTcv z5a@H?S{S+5a8s-yG}CPmT%}oe`r~(xwnigZzF5u z@+qfBocvE7wC9Xe6zLjVo7M90lXf1ZC~Uav)rgMb5y!*9P;mI|(SAbQYdl=-?Yl3c z`uay-RjN~1zOemZlcJ56gx<)>f?FJ}e;3htxK6M$-d$F*o!>2u>rFOce$!HyIu!N_ zxF*QZhLI*Yy`5;Ig{Szq@81i`bx!Y(&1nlluQJ6s(yM98N{Jw=Q%zXvIy|>;qp}Yp zwZ|bC1VG@kh$G{$@BPH)U6Z=VcLhTq?6T7qtGD$r#BlBJ5(>Jj>R994W-qia&g4(x z<9KDQHSl7LzG;>XH^XI)bma21aC_#iG;66XXXV_S7#j??A1<;N?c6p=((_&b;;nob zE^gIC+*da+?Q)?5H#N;o9*5e|wOUKXnL1D2aF$&SdQUmk-Bs-t_YLGMfpznS_qUG^ zef5IXm&F9pN|?Wls}jB_%n};W`Fz*w5h$x$UckQ(dj;x9=2e$clBgSo4(`qXkJytM zM)+e=m@k#_lwJXX(mX%7T3BfpcYZOBUBP#J>!}YL9C1`W$CZ!|Lh}KGBZ7j$f>=XC zgH_l-pis5p4kfwI6owry0;!9ZsFF=-O;4B)mps2JEWYPH01(f7NZeiR@}2u0yzXas z{{t?pAc`!Ao-j2e^Pv2I5)Z#%*-P{27&^$kyc+3NrYHCZ@&vraQCxito*{gT!5)c` zuI5)$nvNa;^+urF^{RNPA`>f8CyPIgnm3T7kWo`Mi?icoqG1R^bJGx|PIix>_DbjN z=BWpf5U6IcD88PDQ}M>yYT9@(COM|;Es4Ag2o&pXC>i2V0NG+pnd3Y9%&oP|?2*hA zcG&3JP6KIeA$1Yr+E37Jq_QUDS(7V@LP@B8HP5jbnANz}r#u$zQPKB?>9DP}6;5n; zEHeBA4}R}{tllD{J5}7fJ<4dIw)$hJc*cRbV1za(R41)1W?q$^8^2^>jlAr>azk)$ zX|1Vg9hP}kHf%j_&&id?-%r4K0A)M)-5SN&VzxrjzZk?>?&wsg)k0J)0x!A_@~R*i z97r`(NrrtVxbJ#K%|!(SX8q`LQNLQc)3m1u$LRB(7f!@5h^jzDmVxD5!~t|5aphOKmV4Kf)uHhaHfq6> zx+9UKN%P&%lnW!do|kT``0;1XT-XYK#$PaX!5>OGnpHzM9ZL`N|3pU;VJ90ZlsVM& zNMT`Wo?93y&N5u^u_AL#?oCX!chWVU#*HHUwh)YE_V6iuzpKAPq@XELjdU`e40t3mD_p`n`}9jo$>PP5$(1eO5>Hpeg@FBLw&07NI+LXV zNxy?PZX2XyKY%U)RQW7rPxR@Byd=(|8wYHdK2MwsgyPoc3q)3BSzNu5>fh(Fgt6 zsN%)J&WL1k^}b-akKV)ur0JTDaBi$@6;Fuw#Tvh|eM~ySEyXVO25Cs)knP0m z2q!UBRsgAk)!8BSBuc1t+^{zrjnro|{pjdX_6aXo8@jqWT69k~qKkx%m-9@r$Mu4a zPJ5XG@Z?FVlAz*y`N?pD0FDs@@bz@Ulm*tb*4+m3Be>}u?8CZue!opGdlJR#1&M=* z9BNI9+TV7r36&NFg3-}2*F@s2CQlc9RTp(HwP7-3T89DUHH=!*n|BO02)s=0Xg6_0 zJAgt~=N{%t^P`NkIRWB>{_$+Dn0;1oi|}2%8`7{H1vaFC>htEBcF4O{mx!AD()4^X zW@SU?;-5!WxhW$yRy=-O5CauR=t$7IwM`CW~}WKkj6({w*DS<6m8S zHCGrIc<6ejvA(`avr;N>A_d^1qy6FA{-GkF=?n(F2kvo^yK)RPl@gp}ZaLkZ2=RaN zno@fvh5t;!P~dA2nDvr~rSwT&X#L4FDfh3~j-HZT``vZrL2-(xt!!E?Z>M zD@0zHXq%l5j_{W`A}E4a3G5(utyP#W2bths+<}gP#En-<KUhRX&pei zxY>+yUmdxgT7|r4XQ~-7@ewM(sa(AuPUUf91SxDarHB1`ScujeZ`O5-2oZa=4<(r{ zu(pnnWI#nll>2OdRWIr^>|U<&dW&9uT`9+#AjuFF`LKEymF3A6;TqnfHW3)oVzc4W z_z81(*M)SGHaj;vstZYreS; z(S1jyWePfVErIL4X&szYBuo5rOxDxEyHlx-X^#1f1H*4KqB37Qld<#D()E|NkCYLs zOOgKqg9)a7=g5@l#aXr1PMHLWi$?^@zDY?+>T#g7y?T|V1NS^W&5Th`OV$)!`rtI0 zvB2wf6$epjH#sAeZ)tHnm3+Q8(GnEGaO9}$?vA!Y+%Ap!p1h}Mmf2*#97nBBr^OY5eEIHy|UDuK3zU{~Gy4_CyGt&^y z|L(oKD4Xum2S@HA;|RSpZ9~)tfvqx~1s8#jd%jUFp?ZsCICFivx2-+JN>JGoXwa)%?{8aqs_njb3&SQ;tcJ3GvTA0tJ(=aqVb*WT=v5QiKnDkb1@DR!8NmAC7B z$@Kal9RUaH4am<%s+x~_eTmOW6Ob}gfS3;N%6t?t)7Li(h+ccHuO9yi z`7)LHcO-npwkQEX6cWQs@30im^tJ8k@N`?%Pd;Z`o9)dNUrbxgW_5PN(WzpC!defs z(%h)IjXd#m%rY$j9xxw^p6QzQW_=LsG})1N7oDVb=iEZk;o zP33K4T(kR9Sy~WS+{J1B_uy&)G0Kz&ajX!i*rVH_zfnmKC}+*{8fvCR$_+r3n69@`5bEEM{oq6F|* zQsyZ0@X-1)9lU+o!{0!en(V62Xb!c!yw$k`D&XfFT@Kc<@5;0)y}knEOUh{ ziuLc;R?SYiD|D(gkCQWh!hL0_1b(+Ldm^Lt6g70lbh&R(>}p_Fj~uQ))|CaTd%IZY ztma@^BY+;U4{grOBVxw`GIeZ;60FS?He`XpYJ1F@6Nc^93mCK(H%K)_|7HLZ=ZN?i zkV0#oRj)6VRdG{Rkl>3maqN~e0*_&6ufzbbTD+p#G6|4Bx@I|aP$THpP)joM`VO>{ zl8VPd4;m?qx9@yesg?S-Stj2H0e!OOT0$bKmHF>1Z-@B+0QhZ3wg7b{w4dZ%{5_(F zCJF>J20srlYCwEByQe6E^ON)Do>617y+QMt9CxeINm5dk6AccP&OC0PDEb$h5C=6O zWM`+b6gffK2>%^fO&yr~V1F4GZT~hoEN!7FNBJ__M1Yes4G}2@iLbMK-N=375`pV{ zQKw5t(vZ5MsL1|2niZ5SE~Ok9UK*}a(xAoqg8|C9 z{S3)uUKGnz2%GbtL?4GxwN=IF@eWsehn@VeE9E27ZgDNw;fEVuVuvA9!0dm*oE*wk zOfX25|Kt2gLD$wNy)A*$(rV6BS(8^dn#q;GnA~0Yd z-HSC(6w1S^R9=}}ELYQQ05<4_{(&|vNc%(&Q^?x~ox zg=E^lf&U7N#b}~d7$LUJ2(kV42T@SPG~`Gr(ER){1NVh3;~IZ{bYMWjI43GwMEqhA z-rCMXp6j_+8nf%QF(8;g%8wo-AwH~d0xA{<;z?z*jpMa%O}ezqcDrgZpQLBViYCtS zs1&Tq*Ew3E2n!tM441&J?tbXD1hen0@)y;hYD6M3=5HQjpbe2584WOa%ULCR2bB}C zxa^~QpkbDzG##Bt#rs>3i+D=(vxVUbZ!?<9hJ2Yk;bbYM)VqH&nYD@)x5_kVl3 zkbX{jDqC9p&su&hi}=QWzr`5Vbf_IR=^Dl52yjJoJK z-ebQeVoZEWVaKDhb#{TNO-w<5r2o@Qb}>3cIV}}gv2G7JZ0jXEpZOpp$;4~GT5B5? zaZGCMkSboovq*56(FG=t-c)^qePq`0GhwI8z}+=JY&Q{Ec0`JjGgY~1AV=ZN(A z%37NwkA^hB*HNSP$btGZ65tcb=Cn<`@yqFTKGl-1= zi;GK0;7o2;dG_IpVgM?PIWY-;{isv@{;zB;hj6#uT~vP-V+jV~FYCfiEwhXMq&-yf zG-*OIc1nL7Rnv(a5A(EU6x?b^PjJ0TV(n-^Hs#t_pXvO0E0n8p6J_*e0k_lOP1#(v zjSM3J5PR&`XB}!gm&V^}B#OkaK5R}=1h;>R^j+MFAPI$*#!aS><|xpz0^6ESN`1=(buAyk@}~TBz?J(o>R(j8xd0` zZ}{?sB|fcFwV#Wu#+4SwM3e1tudY!YE&)+k^0^16j#_dZMx4A$-q&3DFCr4!B4u7f z?Nse7c!teUwoj;4f`d=WT+eI6f+NHhKW==h0eYX779RnQqo}t^T6J&c9X!%Nk2T2x zX9>y))37m_t&1DZ_HH=eLdU_ST-f>xOXkmS{Lgc~xp1SKGMLKqYp~lcHpDhdkreOQ z(hk$x)Bw!$9ODIAaK261e2$46tb0opzN^;BVv&*j?EdwCpKJYwVpH>~YOBd`-M#Vi zsTYb0iJbYgfOT%*dnt1iq-zF57b;M{{5dsJY&1q|n88*?tkW#(meg9R4o*SIG4UtG;;Y%Wn*LKatZHXSWVnOBBZCH9}A~h=_#U)-PvP$$?a0R z+tW1ZemcX=!Sq1yld3G?6CXWCnLrn%r6DlpAY%#rS($`*>utZ2s$H-i;~azbc)xZU zqxgRt^lX&&mA&9Cua!Z;v`((FOCByx)1%T>VLC9pyzGDTDSir3K6TR*+~TqmH<*xq ziLKxt*t)4sZa}`!hUBgA^X09Gu@sFo`i_V6rQ$KOyI5_xx9_4^w?A9;{r=`V+nL!a#sA>!ETiIR)n&fqS0l6>Djd!MuSxp&>Q_{H>eRo7H^ zRXy+XR+AGe`Y*M4@BFNh@`ih5huk_ls$K8R#w|4o0m)BEJzzQ%9XcoNQESw-)^Czj z(R*2h?tRf!bU{JS6Y>6b*2BAS)_m$Qtw1(|qqW21fPOS|;o8Pv7#-N+>eTAi6Jl}J zIgL5I`Vn1`L=dGbK0eNY&x($TNs4L~IqIOlb^I-zF(3Wz$QH=u(5?1?&*KJfT6hK; z@FTTmY?NQH9zdrRuIBY4C_z=Ckm3s zF1eDhfXmp@_n0dYu=rGU+pZL-tGbFnx80^h*}liISg&R>uD4bq=kHBx)Qh7zKT*YE z+>p?$x;nfG&|dwq#|hSqm3#EQ>llDq?M+tFTVQ7KMz;I*uag^X9`s0J%>d?}11Wig z6dJlJZG;pJWXNxW)?(s=)hbr_cNrG+W*Fs%&fiEj=RiSRmiw_;R-3f6)orhd&Ax;k zlMvmXXez3=EQZ#g6=zpPXG=(YpB-Y2jYaX3G=?UVMr-{L(d{&ZMM!8;*4xK zIr@s|@-5P26+vBx_flQA|KpDum_RsULVx+i&4fCjs~UV)I;EhC}ko56bfSiog)WK$sl+aFA&?jOW zi!8KOxO7BBbh43odD3sXU;%|R)MZd`5^#cvR8wosK|$0=EY-(%$KEKIh87vng0MvX z-e~m}8T_#9W<(AokOtI1pX#wY&c$z3bX3rk`^jh`N3pvV^G6e#9xqJlEScsqgF9!- zB^%_Jc*+aTDhN6P^2NTs~l>UNea zrKX@#vhXTuWmL9@{o05m9{k?*WW+o)%~$=hUe|C;SY9h*3wwX)X)yOm2$_VR|?(M zqV&UN-HH9Qn(x+OwZ+SuzV(l}%(S9(dY>x#0S68n0ZqBpO&Y^#0!0(4z#;u;7Z7rkR345cWxtcr*I4(G+-livG9_Zak1$O}a2UpAU9e*{Kp;Ao(yz1JEbwGoF)R z)I#esN>N)I9UY!n{E55~>6{0{nu%=BF}cg&o0#-hLf(U$7m=#%hiy_xFwr~V6HZDrvf1QREP;LjQ0Sfn-od?G0LE(ZP2*CORRbD{qfKsxpD|Lf3inUMUyOW0FG#!4XFL%OYE zU_)|*^;{Q!`@157RsxAk=tw80wtp3sRZEfziTyLR5)cmj-MNYU{c*`@|5TGG>zl-1 z-xDvaDPf^i7EypuQ`D7=MRFwlb4U%_BYHXPE+dZaD?BqIz9l{fO70Z!WUh3o$@>B1 zm3ynUo4cZ3=^C>-7|i+x@%QCrWr#pZ9S3-#@xnUi2Hf8s?HuQ>T$7X#M-}$95O}t= zJz@H33PkNEu+g@}_Co`Li7ES5>TivwA>4DkW;m(q5)?2F9Dp}`g9sw|D$cmI#}iqr zs0&hS!$QXC$qZ&!7QI4jv-N$?sRcp5T6xNQ8c!MdITRX-b3)Kqw5M5xfk};g-Vz~2 zhDuZO2Fb*m%CK@Cmo=qxeFnZUlC-SX@uj-;%Ps;;8lUHJsm$( zk3Ipnkr0!9&>%LZ`u*p*vyumF$|Rd$eIMG*{CiF_Y;4+y;>VF5%INOW(!xT+y6Yr` zjLA;Or}zjbRkg!oyA%~4vMR#+_TcrWBS)D)cpX>L^T|38VCG3>dFpao#mxtPYIpf* zrTSNo9IFsBPtGK{MK~|M|B8_12E{g^80E=B+EjA5cDj8*Z{*cd?^#dSeDI~PJC!LN z9c9=emP*zO`-~CfKn9bm#h|Kdc%PKlMjMfh(^M~~v}xQ@@AG*uw-f;}qQS}BRQn56 zsGRFy98QbrytbGE;FB0W78mbh-5?uVV%=Q@f7jTw#CH4H9t`DDYmXy%k;o@3+_vS6 zyFHk<2mhC zU}I(ed*fvV3+D3c3eAGvQrW`74Y6I9lFS1yE2?e4t($aN8B!d?X{ zq`BBzK(;E=z>G?OjCRST?$uX52lL8ZIzCkU)N~-!sCu)>nFN)G? zFpNLg*O@b~ocTEn&x!G*;e2`~wHW9O#?eEP(pfmHiIo5Vc&L;`-k6A<7SdY!`8At> z8EJ|=Y6a4g3$N*s@aW0jfR+;=pNE_UXF2~|9zQINayWaB^%fA+U@7j|%Dd_lUb!1{ zZ}H>(BSQB_4_dA&2T=^peX4lt%AKxC3In$D+yLgQRwa|GvS%@krGfV#G|{6aPH8x{ zaUob1IisT;tkwv3Rj&roxPpL(w8r;ontf4<2Qwb+mvF3&DC~UbSsCx!#?gw*jwx-- z#2N-LzBXtY+Uu-R>#_|37P0ZRpo%g1ay1`gGT z;U7;7K{b=rK6Q653=LQhfjEOMC*Tfy=e}xcXHRreZCH%#6DeD6Me ziHcg87<-c`sk^+q%`rROAs|%{an_#qu*s_g;NQ-`H7VwN($w6F;^@S!xwnKhfRra2 z9ks#bX;%=dtBbnGF6)@*cIekr$C0 zeLgu^(5Y~7S+rA{g&{>(Qkh15_X20kcKUS+$Vy8{d(yQe5T4B061vQ)JGchU4{%dM zXFp7&=ZzU}E)7pX#<)r!1KCu_zyLH $0Gos}J5XB2rXu@yMujUHB82E-8SY7+Xl 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�`V9o39+88l*y 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 jL^_{EMAap0IC#(Z