diff --git a/.gitignore b/.gitignore index d1ec0e1..823387d 100644 --- a/.gitignore +++ b/.gitignore @@ -31,11 +31,6 @@ AMENT_IGNORE # ---> VisualStudioCode .vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/*.code-snippets # Local History for Visual Studio Code .history/ diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 9a22ea1..0000000 --- a/LICENSE +++ /dev/null @@ -1,18 +0,0 @@ -MIT License - -Copyright (c) 2026 YikaiFu-cart - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial -portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 2fa9f4d..b30af26 --- a/README.md +++ b/README.md @@ -1,2 +1,155 @@ -# acRealman_xr +# XR-RM75 双臂阶段一遥操作 ROS2 工作空间 +这个 `src/` 目录面向 **Ubuntu 22.04 + ROS2 Humble**,用于先跑通阶段一闭环: + +```text +PICO 4 Ultra 双手柄 UDP 位姿/按键 + -> /xr/left_controller 与 /xr/right_controller + -> 左右 RM75 相对位移速度遥操 + -> 右手 trigger 输出 OmniPicker 归一化力控指令 + -> PC 端查看 D405/D405/D435 视频,后续再接记录节点 +``` + +当前配置参考了最新 `acDual-arm-YikaiFuTest` 中的双臂 acRealman 参数,但本项目仍然采用 PICO 手柄相对位移遥操,不直接搬用戴盟的绝对 `PoseStamped` retarget 链路。 + +## 当前范围 + +- 已纳入:PICO 双手柄输入、左右 RM75 相对位移控制、右手 OmniPicker trigger 指令出口。 +- 暂不纳入:AUBO 移动底盘、升降柱、剪刀手、XR 视频流、自动采摘算法。 + +## 关键配置来源 + +来自 `acDual-arm-YikaiFuTest/realman_pkg/core/arg_cfg.py`: + +- 左臂 IP:`192.168.192.18` +- 右臂 IP:`192.168.192.19` +- 左右臂 TCP 端口:`8080` +- 左右臂工作空间:`x[-0.50, 0.50] y[-0.60, -0.20] z[0.10, 0.50]` +- 左右臂工作空间和 IP 沿用最新 acDual-arm 配置。 + +`dual_arm_rm75.yaml` 里的左右 `xr_to_robot_matrix` 依据论文中的 OpenXR/PICO 手柄坐标和现场双臂 base 坐标图设置: + +- PICO/OpenXR:`+X` 向右,`+Y` 向上,`+Z` 向后。 +- 左臂:`+X` 向下,`+Z` 向右,前方工作区对应 `-Y`。 +- 右臂:`+X` 向上,`+Z` 向左,前方工作区对应 `-Y`。 + +真机上仍需低速确认方向。 + +## 构建 + +在包含 `src/` 的工作空间根目录执行: + +```bash +source /opt/ros/humble/setup.bash +rosdep update +rosdep install --from-paths src -y --ignore-src +colcon build --symlink-install +source install/setup.bash +``` + +## 优先验证:双臂 mock + +mock 模式不会连接真实 RM75,适合先验证 UDP 输入、左右手柄路由、相对位移控制和 trigger 夹爪指令。 + +终端 1: + +```bash +source /opt/ros/humble/setup.bash +source install/setup.bash +ros2 launch xr_rm_bringup dual_arm_mock.launch.py +``` + +终端 2: + +```bash +source /opt/ros/humble/setup.bash +source install/setup.bash +ros2 run xr_rm_input sample_udp_sender --hand both --host 127.0.0.1 --port 15000 +``` + +检查话题: + +```bash +ros2 topic echo /xr/left_controller +ros2 topic echo /xr/right_controller +ros2 topic echo /omnipicker/right/force_ratio +``` + +## 连接双 RM75 + +先安装睿尔曼 Python API2,并确认两台控制器网络可达。 + +```bash +source /opt/ros/humble/setup.bash +source install/setup.bash +ros2 launch xr_rm_bringup dual_arm_realman.launch.py \ + left_robot_ip:=192.168.192.18 \ + right_robot_ip:=192.168.192.19 +``` + +单臂入口仍保留,默认按右臂配置: + +```bash +ros2 launch xr_rm_bringup single_arm_mock.launch.py +ros2 launch xr_rm_bringup single_arm_realman.launch.py robot_ip:=192.168.192.19 +``` + +## UDP JSON 格式 + +单个手柄数据: + +```json +{ + "hand": "right", + "grip": true, + "trigger": 0.2, + "pos": [0.12, 1.05, -0.32], + "quat": [0.0, 0.0, 0.0, 1.0] +} +``` + +也支持一次发送左右手柄: + +```json +{ + "controllers": { + "left": { + "grip": true, + "trigger": 0.0, + "pos": [-0.12, 1.05, -0.32], + "quat": [0.0, 0.0, 0.0, 1.0] + }, + "right": { + "grip": true, + "trigger": 0.4, + "pos": [0.12, 1.05, -0.32], + "quat": [0.0, 0.0, 0.0, 1.0] + } + } +} +``` + +`grip` 是机械臂运动使能:按下时锁定当前手柄和 TCP 起点,之后只跟随相对位移;松开、超时或节点退出都会发送零速度。 + +## OmniPicker 接口 + +`gripper_trigger_bridge` 会把右手 `trigger` 映射为: + +```text +/omnipicker/right/force_ratio std_msgs/Float32 +``` + +默认范围是 `0.0` 开爪、`0.15-0.45` 柔性闭合/保持。实际接 OmniPicker 驱动前,请根据番茄压伤测试收紧 `dual_arm_rm75.yaml` 里的 `max_close_command`。 + +## 真机前方向验证 + +第一轮保持 `max_linear_speed <= 0.04`,先只上右臂: + +1. 按住右手 `grip`,手柄缓慢上移,确认 TCP 方向。 +2. 手柄缓慢前推/后拉,确认 TCP 方向。 +3. 手柄缓慢左移/右移,确认 TCP 方向。 +4. 若任一方向相反,只改 `dual_arm_rm75.yaml` 中对应臂的 `xr_to_robot_matrix` 符号。 +5. 右臂确认后再开放左臂。 +6. 最后接 OmniPicker,并从低力控上限开始抓取番茄。 + +当前双臂是两个独立控制节点,没有跨臂碰撞模型;首次测试请让两臂工作区物理分离。 diff --git a/xr_rm_bringup/CMakeLists.txt b/xr_rm_bringup/CMakeLists.txt new file mode 100755 index 0000000..6fa71e8 --- /dev/null +++ b/xr_rm_bringup/CMakeLists.txt @@ -0,0 +1,12 @@ +cmake_minimum_required(VERSION 3.8) +project(xr_rm_bringup) + +find_package(ament_cmake REQUIRED) + +install(DIRECTORY + config + launch + DESTINATION share/${PROJECT_NAME} +) + +ament_package() diff --git a/xr_rm_bringup/config/dual_arm_rm75.yaml b/xr_rm_bringup/config/dual_arm_rm75.yaml new file mode 100755 index 0000000..e699765 --- /dev/null +++ b/xr_rm_bringup/config/dual_arm_rm75.yaml @@ -0,0 +1,94 @@ +# 阶段一:PICO 遥操作双 RM75 平台配置。 +# +# 参数来源:acDual-arm-YikaiFuTest/realman_pkg/core/arg_cfg.py +# - 左臂 IP:192.168.192.18 +# - 右臂 IP:192.168.192.19 +# - 左右臂工作空间和 IP 沿用最新 acDual-arm 配置。 +# - PICO 手柄位移映射根据论文 OpenXR 坐标和现场双臂 base 坐标图单独设置。 +# +# 注意:当前项目仍然采用“手柄相对位移”控制方式。 +# 按下握持键时锁定当前手柄位姿和 TCP 位姿,之后只跟随相对位移。 +# acDual-arm 项目使用的是戴盟绝对 PoseStamped 重映射,因此这里只迁移 +# 坐标标定和安全参数,不迁移其绝对位姿控制链路。 + +left_arm_teleop: + ros__parameters: + arm_name: left_rm75 + controller_topic: /xr/left_controller + control_rate_hz: 50.0 + command_timeout_sec: 0.12 + + # 实验台番茄测试先采用保守参数;完成三轴方向检查和实体急停测试后再提高速度。 + scale: 0.75 + kp_linear: 1.8 + deadband_m: 0.002 + low_pass_alpha: 0.35 + max_linear_speed: 0.04 + enable_position_axes: [true, true, true] + + # 来自 acDual-arm 的 bounds_p[left]: + # x[-0.50, 0.50],y[-0.60, -0.20],z[0.10, 0.50]。 + workspace_min: [-0.50, -0.60, 0.10] + workspace_max: [0.50, -0.20, 0.50] + + # PICO/OpenXR 位置坐标:+X 向右,+Y 向上,+Z 向后。 + # 左臂 base 坐标:+X 向下,+Z 向右,前方工作区对应 -Y。 + # 映射关系:机器人位移增量 = [-手柄y, -手柄z, 手柄x]。 + xr_to_robot_matrix: [0.0, -1.0, 0.0, + 0.0, 0.0, -1.0, + 1.0, 0.0, 0.0] + + use_mock: false + mock_initial_pose: [-0.1560, -0.3476, 0.2689, -3.1100, -0.2840, -1.3180] + robot_ip: 192.168.192.18 + robot_port: 8080 + avoid_singularity: 1 + frame_type: 1 + follow: false + +right_arm_teleop: + ros__parameters: + arm_name: right_rm75 + controller_topic: /xr/right_controller + control_rate_hz: 50.0 + command_timeout_sec: 0.12 + + scale: 0.75 + kp_linear: 1.8 + deadband_m: 0.002 + low_pass_alpha: 0.35 + max_linear_speed: 0.04 + enable_position_axes: [true, true, true] + + # 来自 acDual-arm 的 bounds_p[right]。 + workspace_min: [-0.50, -0.60, 0.10] + workspace_max: [0.50, -0.20, 0.50] + + # PICO/OpenXR 位置坐标:+X 向右,+Y 向上,+Z 向后。 + # 右臂 base 坐标:+X 向上,+Z 向左,前方工作区对应 -Y。 + # 映射关系:机器人位移增量 = [手柄y, -手柄z, -手柄x]。 + xr_to_robot_matrix: [0.0, 1.0, 0.0, + 0.0, 0.0, -1.0, + -1.0, 0.0, 0.0] + + use_mock: false + mock_initial_pose: [0.1924, -0.3025, 0.2923, 3.0560, 0.1310, 0.9070] + robot_ip: 192.168.192.19 + robot_port: 8080 + avoid_singularity: 1 + frame_type: 1 + follow: false + +right_omnipicker_trigger_bridge: + ros__parameters: + # 阶段一只使用右手两指夹爪;右手扳机键输出归一化力控指令。 + controller_topic: /xr/right_controller + command_topic: /omnipicker/right/force_ratio + publish_rate_hz: 30.0 + stale_timeout_sec: 0.25 + require_grip: true + trigger_deadband: 0.05 + open_command: 0.0 + min_close_command: 0.15 + max_close_command: 0.45 + low_pass_alpha: 0.5 diff --git a/xr_rm_bringup/config/single_arm_rm75.yaml b/xr_rm_bringup/config/single_arm_rm75.yaml new file mode 100755 index 0000000..99efd0c --- /dev/null +++ b/xr_rm_bringup/config/single_arm_rm75.yaml @@ -0,0 +1,33 @@ +# 单臂真机 RM75 入口配置。 +# 默认按最新 acDual-arm 配置中的右臂参数运行,便于先单独验证右臂方向和急停。 + +single_arm_velocity_teleop: + ros__parameters: + arm_name: right_rm75 + controller_topic: /xr/right_controller + control_rate_hz: 50.0 + command_timeout_sec: 0.12 + + scale: 0.75 + kp_linear: 2.0 + deadband_m: 0.002 + low_pass_alpha: 0.35 + max_linear_speed: 0.04 + enable_position_axes: [true, true, true] + + workspace_min: [-0.50, -0.60, 0.10] + workspace_max: [0.50, -0.20, 0.50] + + # 右臂 PICO/OpenXR 相对位移到 RM75 base 坐标的初始映射: + # 机器人位移增量 = [手柄y, -手柄z, -手柄x]。 + xr_to_robot_matrix: [0.0, 1.0, 0.0, + 0.0, 0.0, -1.0, + -1.0, 0.0, 0.0] + + use_mock: false + mock_initial_pose: [0.1924, -0.3025, 0.2923, 3.0560, 0.1310, 0.9070] + robot_ip: 192.168.192.19 + robot_port: 8080 + avoid_singularity: 1 + frame_type: 1 + follow: false diff --git a/xr_rm_bringup/launch/dual_arm_mock.launch.py b/xr_rm_bringup/launch/dual_arm_mock.launch.py new file mode 100755 index 0000000..8816064 --- /dev/null +++ b/xr_rm_bringup/launch/dual_arm_mock.launch.py @@ -0,0 +1,55 @@ +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument +from launch.substitutions import LaunchConfiguration, PathJoinSubstitution +from launch_ros.actions import Node +from launch_ros.substitutions import FindPackageShare + + +def generate_launch_description() -> LaunchDescription: + config_file = PathJoinSubstitution([ + FindPackageShare("xr_rm_bringup"), + "config", + "dual_arm_rm75.yaml", + ]) + + return LaunchDescription([ + DeclareLaunchArgument("udp_host", default_value="0.0.0.0"), + DeclareLaunchArgument("udp_port", default_value="15000"), + # 接收 PICO/XR 侧 UDP 数据,并按左右手字段分发到对应手柄话题。 + Node( + package="xr_rm_input", + executable="udp_controller_receiver", + name="udp_controller_receiver", + output="screen", + parameters=[{ + "udp_host": LaunchConfiguration("udp_host"), + "udp_port": LaunchConfiguration("udp_port"), + "left_topic": "/xr/left_controller", + "right_topic": "/xr/right_controller", + }], + ), + # 左臂模拟节点:不连接真实 RM75,只积分速度命令用于验证坐标方向。 + Node( + package="xr_rm_teleop", + executable="single_arm_velocity_teleop", + name="left_arm_teleop", + output="screen", + parameters=[config_file, {"use_mock": True}], + ), + # 右臂模拟节点:与左臂共用同一配置文件,但读取右手柄话题。 + Node( + package="xr_rm_teleop", + executable="single_arm_velocity_teleop", + name="right_arm_teleop", + output="screen", + parameters=[config_file, {"use_mock": True}], + ), + # 右手扳机键到 OmniPicker 归一化力控指令的桥接,模拟阶段用于检查话题输出。 + Node( + package="xr_rm_teleop", + executable="gripper_trigger_bridge", + name="right_omnipicker_trigger_bridge", + output="screen", + parameters=[config_file], + ), + ]) diff --git a/xr_rm_bringup/launch/dual_arm_realman.launch.py b/xr_rm_bringup/launch/dual_arm_realman.launch.py new file mode 100755 index 0000000..6fe4549 --- /dev/null +++ b/xr_rm_bringup/launch/dual_arm_realman.launch.py @@ -0,0 +1,69 @@ +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument +from launch.substitutions import LaunchConfiguration, PathJoinSubstitution +from launch_ros.actions import Node +from launch_ros.substitutions import FindPackageShare + + +def generate_launch_description() -> LaunchDescription: + config_file = PathJoinSubstitution([ + FindPackageShare("xr_rm_bringup"), + "config", + LaunchConfiguration("robot_config"), + ]) + + return LaunchDescription([ + DeclareLaunchArgument("udp_host", default_value="0.0.0.0"), + DeclareLaunchArgument("udp_port", default_value="15000"), + DeclareLaunchArgument("left_robot_ip", default_value="192.168.192.18"), + DeclareLaunchArgument("right_robot_ip", default_value="192.168.192.19"), + DeclareLaunchArgument("robot_port", default_value="8080"), + DeclareLaunchArgument("robot_config", default_value="dual_arm_rm75.yaml"), + Node( + package="xr_rm_input", + executable="udp_controller_receiver", + name="udp_controller_receiver", + output="screen", + parameters=[{ + "udp_host": LaunchConfiguration("udp_host"), + "udp_port": LaunchConfiguration("udp_port"), + "left_topic": "/xr/left_controller", + "right_topic": "/xr/right_controller", + }], + ), + Node( + package="xr_rm_teleop", + executable="single_arm_velocity_teleop", + name="left_arm_teleop", + output="screen", + parameters=[ + config_file, + { + "use_mock": False, + "robot_ip": LaunchConfiguration("left_robot_ip"), + "robot_port": LaunchConfiguration("robot_port"), + }, + ], + ), + Node( + package="xr_rm_teleop", + executable="single_arm_velocity_teleop", + name="right_arm_teleop", + output="screen", + parameters=[ + config_file, + { + "use_mock": False, + "robot_ip": LaunchConfiguration("right_robot_ip"), + "robot_port": LaunchConfiguration("robot_port"), + }, + ], + ), + Node( + package="xr_rm_teleop", + executable="gripper_trigger_bridge", + name="right_omnipicker_trigger_bridge", + output="screen", + parameters=[config_file], + ), + ]) diff --git a/xr_rm_bringup/launch/single_arm_mock.launch.py b/xr_rm_bringup/launch/single_arm_mock.launch.py new file mode 100755 index 0000000..eee3ec6 --- /dev/null +++ b/xr_rm_bringup/launch/single_arm_mock.launch.py @@ -0,0 +1,36 @@ +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument +from launch.substitutions import LaunchConfiguration, PathJoinSubstitution +from launch_ros.actions import Node +from launch_ros.substitutions import FindPackageShare + + +def generate_launch_description() -> LaunchDescription: + config_file = PathJoinSubstitution([ + FindPackageShare("xr_rm_teleop"), + "config", + "single_arm.yaml", + ]) + + return LaunchDescription([ + DeclareLaunchArgument("udp_host", default_value="0.0.0.0"), + DeclareLaunchArgument("udp_port", default_value="15000"), + Node( + package="xr_rm_input", + executable="udp_controller_receiver", + name="udp_controller_receiver", + output="screen", + parameters=[{ + "udp_host": LaunchConfiguration("udp_host"), + "udp_port": LaunchConfiguration("udp_port"), + "topic": "/xr/right_controller", + }], + ), + Node( + package="xr_rm_teleop", + executable="single_arm_velocity_teleop", + name="single_arm_velocity_teleop", + output="screen", + parameters=[config_file, {"use_mock": True}], + ), + ]) diff --git a/xr_rm_bringup/launch/single_arm_realman.launch.py b/xr_rm_bringup/launch/single_arm_realman.launch.py new file mode 100755 index 0000000..28a416d --- /dev/null +++ b/xr_rm_bringup/launch/single_arm_realman.launch.py @@ -0,0 +1,46 @@ +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument +from launch.substitutions import LaunchConfiguration, PathJoinSubstitution +from launch_ros.actions import Node +from launch_ros.substitutions import FindPackageShare + + +def generate_launch_description() -> LaunchDescription: + config_file = PathJoinSubstitution([ + FindPackageShare("xr_rm_bringup"), + "config", + LaunchConfiguration("robot_config"), + ]) + + return LaunchDescription([ + DeclareLaunchArgument("udp_host", default_value="0.0.0.0"), + DeclareLaunchArgument("udp_port", default_value="15000"), + DeclareLaunchArgument("robot_ip", default_value="192.168.192.19"), + DeclareLaunchArgument("robot_port", default_value="8080"), + DeclareLaunchArgument("robot_config", default_value="single_arm_rm75.yaml"), + Node( + package="xr_rm_input", + executable="udp_controller_receiver", + name="udp_controller_receiver", + output="screen", + parameters=[{ + "udp_host": LaunchConfiguration("udp_host"), + "udp_port": LaunchConfiguration("udp_port"), + "topic": "/xr/right_controller", + }], + ), + Node( + package="xr_rm_teleop", + executable="single_arm_velocity_teleop", + name="single_arm_velocity_teleop", + output="screen", + parameters=[ + config_file, + { + "use_mock": False, + "robot_ip": LaunchConfiguration("robot_ip"), + "robot_port": LaunchConfiguration("robot_port"), + }, + ], + ), + ]) diff --git a/xr_rm_bringup/package.xml b/xr_rm_bringup/package.xml new file mode 100755 index 0000000..701b0db --- /dev/null +++ b/xr_rm_bringup/package.xml @@ -0,0 +1,18 @@ + + + + xr_rm_bringup + 0.1.0 + Launch and configuration package for XR RealMan teleoperation. + Yikai Fu + Apache-2.0 + + ament_cmake + + xr_rm_input + xr_rm_teleop + + + ament_cmake + + diff --git a/xr_rm_input/launch/udp_receiver.launch.py b/xr_rm_input/launch/udp_receiver.launch.py new file mode 100755 index 0000000..b1a85b5 --- /dev/null +++ b/xr_rm_input/launch/udp_receiver.launch.py @@ -0,0 +1,23 @@ +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument +from launch.substitutions import LaunchConfiguration +from launch_ros.actions import Node + + +def generate_launch_description() -> LaunchDescription: + return LaunchDescription([ + DeclareLaunchArgument("udp_host", default_value="0.0.0.0"), + DeclareLaunchArgument("udp_port", default_value="15000"), + DeclareLaunchArgument("topic", default_value="/xr/right_controller"), + Node( + package="xr_rm_input", + executable="udp_controller_receiver", + name="udp_controller_receiver", + output="screen", + parameters=[{ + "udp_host": LaunchConfiguration("udp_host"), + "udp_port": LaunchConfiguration("udp_port"), + "topic": LaunchConfiguration("topic"), + }], + ), + ]) diff --git a/xr_rm_input/package.xml b/xr_rm_input/package.xml new file mode 100755 index 0000000..9efde9c --- /dev/null +++ b/xr_rm_input/package.xml @@ -0,0 +1,23 @@ + + + + xr_rm_input + 0.1.0 + Receives XR controller pose packets and publishes left/right ROS2 controller messages. + Yikai Fu + Apache-2.0 + + ament_python + + geometry_msgs + rclpy + std_msgs + xr_rm_interfaces + + ament_lint_auto + ament_lint_common + + + ament_python + + diff --git a/xr_rm_input/resource/xr_rm_input b/xr_rm_input/resource/xr_rm_input new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/xr_rm_input/resource/xr_rm_input @@ -0,0 +1 @@ + diff --git a/xr_rm_input/setup.cfg b/xr_rm_input/setup.cfg new file mode 100755 index 0000000..c347a30 --- /dev/null +++ b/xr_rm_input/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/xr_rm_input +[install] +install_scripts=$base/lib/xr_rm_input diff --git a/xr_rm_input/setup.py b/xr_rm_input/setup.py new file mode 100755 index 0000000..13b0df3 --- /dev/null +++ b/xr_rm_input/setup.py @@ -0,0 +1,28 @@ +from glob import glob +from setuptools import setup + +package_name = "xr_rm_input" + +setup( + name=package_name, + version="0.1.0", + packages=[package_name], + data_files=[ + ("share/ament_index/resource_index/packages", [f"resource/{package_name}"]), + (f"share/{package_name}", ["package.xml"]), + (f"share/{package_name}/launch", glob("launch/*.launch.py")), + ], + install_requires=["setuptools"], + zip_safe=True, + maintainer="Yikai Fu", + maintainer_email="user@example.com", + description="Receives XR controller pose packets and publishes left/right ROS2 messages.", + license="Apache-2.0", + tests_require=["pytest"], + entry_points={ + "console_scripts": [ + "udp_controller_receiver = xr_rm_input.udp_controller_receiver:main", + "sample_udp_sender = xr_rm_input.sample_udp_sender:main", + ], + }, +) diff --git a/xr_rm_input/xr_rm_input/__init__.py b/xr_rm_input/xr_rm_input/__init__.py new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/xr_rm_input/xr_rm_input/__init__.py @@ -0,0 +1 @@ + diff --git a/xr_rm_input/xr_rm_input/sample_udp_sender.py b/xr_rm_input/xr_rm_input/sample_udp_sender.py new file mode 100755 index 0000000..8419079 --- /dev/null +++ b/xr_rm_input/xr_rm_input/sample_udp_sender.py @@ -0,0 +1,58 @@ +import argparse +import json +import math +import socket +import time + + +def main() -> None: + parser = argparse.ArgumentParser(description="通过 UDP 发送一段模拟 XR 左/右手柄数据。") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=15000) + parser.add_argument("--seconds", type=float, default=5.0) + parser.add_argument("--hz", type=float, default=60.0) + parser.add_argument("--amplitude", type=float, default=0.04) + parser.add_argument("--hand", choices=("left", "right", "both"), default="right") + args = parser.parse_args() + + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + dt = 1.0 / args.hz + t0 = time.monotonic() + + hands = ("left", "right") if args.hand == "both" else (args.hand,) + packets = { + hand: { + "t": 0.0, + "hand": hand, + "grip": False, + "trigger": 0.0, + "pos": [0.0, 1.0, 0.0], + "quat": [0.0, 0.0, 0.0, 1.0], + } + for hand in hands + } + + while time.monotonic() - t0 < args.seconds: + t = time.monotonic() - t0 + # 模拟按住握持键后缓慢移动手柄,用来测试相对位移控制链路。 + for index, packet in enumerate(packets.values()): + phase = index * math.pi + packet["t"] = t + packet["grip"] = True + packet["trigger"] = max(0.0, math.sin(2.0 * math.pi * 0.1 * t)) + packet["pos"] = [ + args.amplitude * math.sin(2.0 * math.pi * 0.2 * t + phase), + 1.0, + 0.0, + ] + sock.sendto(json.dumps(packet).encode("utf-8"), (args.host, args.port)) + time.sleep(dt) + + for packet in packets.values(): + packet["grip"] = False + packet["trigger"] = 0.0 + sock.sendto(json.dumps(packet).encode("utf-8"), (args.host, args.port)) + + +if __name__ == "__main__": + main() diff --git a/xr_rm_input/xr_rm_input/udp_controller_receiver.py b/xr_rm_input/xr_rm_input/udp_controller_receiver.py new file mode 100755 index 0000000..df677c0 --- /dev/null +++ b/xr_rm_input/xr_rm_input/udp_controller_receiver.py @@ -0,0 +1,215 @@ +import json +import socket +from collections.abc import Iterable, Mapping +from typing import Any + +import rclpy +from rclpy.node import Node + +from xr_rm_interfaces.msg import XrController + + +class UdpControllerReceiver(Node): + """将轻量级 XR UDP 数据包转换成 ROS2 左/右手柄消息。""" + + def __init__(self) -> None: + super().__init__("udp_controller_receiver") + + self.declare_parameter("udp_host", "0.0.0.0") + self.declare_parameter("udp_port", 15000) + self.declare_parameter("topic", "/xr/right_controller") + self.declare_parameter("left_topic", "/xr/left_controller") + self.declare_parameter("right_topic", "") + self.declare_parameter("default_hand", "right") + self.declare_parameter("timer_hz", 100.0) + self.declare_parameter("quat_order", "xyzw") + + udp_host = self.get_parameter("udp_host").value + udp_port = int(self.get_parameter("udp_port").value) + legacy_topic = str(self.get_parameter("topic").value) + left_topic = str(self.get_parameter("left_topic").value) + right_topic = str(self.get_parameter("right_topic").value) or legacy_topic + timer_hz = float(self.get_parameter("timer_hz").value) + + self._default_hand = self._normalize_hand( + self.get_parameter("default_hand").value, + fallback="right", + ) + self._quat_order = str(self.get_parameter("quat_order").value).lower() + + # UDP 接收使用非阻塞模式,避免没有手柄数据时卡住 ROS2 定时器。 + self._publishers = { + "left": self.create_publisher(XrController, left_topic, 10), + "right": self.create_publisher(XrController, right_topic, 10), + } + self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self._socket.bind((udp_host, udp_port)) + self._socket.setblocking(False) + + self.create_timer(1.0 / timer_hz, self._poll_socket) + self.get_logger().info( + "正在监听 XR UDP 数据:" + f"{udp_host}:{udp_port} -> left={left_topic}, right={right_topic}" + ) + + def _poll_socket(self) -> None: + while True: + try: + data, _ = self._socket.recvfrom(4096) + except BlockingIOError: + return + + try: + payload = json.loads(data.decode("utf-8")) + controller_payloads = list(self._iter_controller_payloads(payload)) + except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: + self.get_logger().warn(f"XR 数据包格式错误:{exc}", throttle_duration_sec=1.0) + continue + + for hand, controller_payload in controller_payloads: + try: + msg = self._payload_to_msg(controller_payload, hand) + except (KeyError, TypeError, ValueError) as exc: + self.get_logger().warn(f"XR 手柄字段错误:{exc}", throttle_duration_sec=1.0) + continue + self._publishers[msg.hand].publish(msg) + + def _iter_controller_payloads(self, payload: Any) -> Iterable[tuple[str, Mapping[str, Any]]]: + if isinstance(payload, list): + for item in payload: + yield from self._iter_controller_payloads(item) + return + + if not isinstance(payload, Mapping): + raise TypeError("expected JSON object or array") + + if self._contains_pose(payload): + hand = self._normalize_hand(payload.get("hand", self._default_hand)) + yield hand, payload + return + + controllers = payload.get("controllers", payload.get("controller")) + if isinstance(controllers, Mapping): + if self._contains_pose(controllers): + hand = self._normalize_hand(controllers.get("hand", self._default_hand)) + yield hand, controllers + return + for hand_key, controller_payload in controllers.items(): + if isinstance(controller_payload, Mapping): + hand = self._normalize_hand(controller_payload.get("hand", hand_key)) + yield hand, controller_payload + return + + if isinstance(controllers, list): + for controller_payload in controllers: + yield from self._iter_controller_payloads(controller_payload) + return + + for hand_key in ("left", "right", "left_controller", "right_controller"): + controller_payload = payload.get(hand_key) + if isinstance(controller_payload, Mapping): + hand = self._normalize_hand(controller_payload.get("hand", hand_key)) + yield hand, controller_payload + + def _payload_to_msg(self, payload: Mapping[str, Any], hand: str) -> XrController: + pos, quat = self._extract_pose(payload) + if len(pos) != 3 or len(quat) != 4: + raise ValueError("expected pos[3] and quat[4]") + + msg = XrController() + msg.header.stamp = self.get_clock().now().to_msg() + msg.header.frame_id = str(payload.get("frame_id", "xr_world")) + msg.hand = hand + msg.grip = self._as_bool(payload.get("grip", payload.get("grip_button", False))) + msg.trigger = self._clamp_float(payload.get("trigger", 0.0), 0.0, 1.0) + msg.pose.position.x = float(pos[0]) + msg.pose.position.y = float(pos[1]) + msg.pose.position.z = float(pos[2]) + + # Unity/OpenXR 项目里的四元数顺序可能不同,这里用参数统一转换成 ROS 的 xyzw。 + if self._quat_order == "wxyz": + w, x, y, z = quat + else: + x, y, z, w = quat + msg.pose.orientation.x = float(x) + msg.pose.orientation.y = float(y) + msg.pose.orientation.z = float(z) + msg.pose.orientation.w = float(w) + return msg + + @staticmethod + def _contains_pose(payload: Mapping[str, Any]) -> bool: + return any(key in payload for key in ("pos", "position", "p", "pose")) + + def _extract_pose(self, payload: Mapping[str, Any]) -> tuple[list[float], list[float]]: + pose = payload.get("pose") + if isinstance(pose, Mapping): + pos = pose.get("position", pose.get("pos", pose.get("p"))) + quat = pose.get("orientation", pose.get("quat", pose.get("q", [0.0, 0.0, 0.0, 1.0]))) + elif isinstance(pose, list) and len(pose) >= 7: + pos = pose[:3] + quat = pose[3:7] + else: + pos = payload.get("pos", payload.get("position", payload.get("p"))) + quat = payload.get("quat", payload.get("orientation", payload.get("q", [0.0, 0.0, 0.0, 1.0]))) + + if pos is None: + raise KeyError("missing pos/position/p") + return self._vector3(pos), self._quaternion(quat) + + @staticmethod + def _vector3(value: Any) -> list[float]: + if isinstance(value, Mapping): + return [float(value[key]) for key in ("x", "y", "z")] + vector = list(value) + if len(vector) != 3: + raise ValueError("expected 3D position") + return [float(item) for item in vector] + + def _quaternion(self, value: Any) -> list[float]: + if isinstance(value, Mapping): + if self._quat_order == "wxyz": + return [float(value[key]) for key in ("w", "x", "y", "z")] + return [float(value[key]) for key in ("x", "y", "z", "w")] + quat = list(value) + if len(quat) != 4: + raise ValueError("expected quaternion") + return [float(item) for item in quat] + + def _normalize_hand(self, hand: Any, fallback: str | None = None) -> str: + value = str(hand).lower() + if value in ("left", "l", "left_controller"): + return "left" + if value in ("right", "r", "right_controller"): + return "right" + fallback_hand = fallback or self._default_hand + self.get_logger().warn( + f"未知手柄标识 {hand!r},使用默认 {fallback_hand}", + throttle_duration_sec=1.0, + ) + return fallback_hand + + @staticmethod + def _as_bool(value: Any) -> bool: + if isinstance(value, str): + return value.lower() in ("1", "true", "yes", "on") + return bool(value) + + @staticmethod + def _clamp_float(value: Any, low: float, high: float) -> float: + number = float(value) + return min(max(number, low), high) + + +def main(args: list[str] | None = None) -> None: + rclpy.init(args=args) + node = UdpControllerReceiver() + try: + rclpy.spin(node) + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/xr_rm_interfaces/CMakeLists.txt b/xr_rm_interfaces/CMakeLists.txt new file mode 100755 index 0000000..3f70846 --- /dev/null +++ b/xr_rm_interfaces/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.8) +project(xr_rm_interfaces) + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +find_package(ament_cmake REQUIRED) +find_package(geometry_msgs REQUIRED) +find_package(rosidl_default_generators REQUIRED) +find_package(std_msgs REQUIRED) + +rosidl_generate_interfaces(${PROJECT_NAME} + "msg/XrController.msg" + DEPENDENCIES geometry_msgs std_msgs +) + +ament_export_dependencies(rosidl_default_runtime) +ament_package() diff --git a/xr_rm_interfaces/msg/XrController.msg b/xr_rm_interfaces/msg/XrController.msg new file mode 100755 index 0000000..bb9d6fd --- /dev/null +++ b/xr_rm_interfaces/msg/XrController.msg @@ -0,0 +1,5 @@ +std_msgs/Header header +string hand +bool grip +float32 trigger +geometry_msgs/Pose pose diff --git a/xr_rm_interfaces/package.xml b/xr_rm_interfaces/package.xml new file mode 100755 index 0000000..0d2d77b --- /dev/null +++ b/xr_rm_interfaces/package.xml @@ -0,0 +1,24 @@ + + + + xr_rm_interfaces + 0.1.0 + ROS2 interfaces for XR controller based RealMan teleoperation. + Yikai Fu + Apache-2.0 + + ament_cmake + geometry_msgs + rosidl_default_generators + std_msgs + + geometry_msgs + rosidl_default_runtime + std_msgs + + rosidl_interface_packages + + + ament_cmake + + diff --git a/xr_rm_teleop/config/single_arm.yaml b/xr_rm_teleop/config/single_arm.yaml new file mode 100755 index 0000000..c9720cb --- /dev/null +++ b/xr_rm_teleop/config/single_arm.yaml @@ -0,0 +1,34 @@ +# 单臂模拟/默认配置。 +# 默认按最新 acDual-arm 配置中的右臂参数运行,使模拟和真机单臂测试使用同一套坐标约定。 + +single_arm_velocity_teleop: + ros__parameters: + arm_name: right_rm75 + controller_topic: /xr/right_controller + control_rate_hz: 50.0 + command_timeout_sec: 0.12 + + scale: 0.75 + kp_linear: 2.0 + deadband_m: 0.002 + low_pass_alpha: 0.35 + max_linear_speed: 0.04 + enable_position_axes: [true, true, true] + + workspace_min: [-0.50, -0.60, 0.10] + workspace_max: [0.50, -0.20, 0.50] + + # 右臂 PICO/OpenXR 相对位移到 RM75 base 坐标的初始映射: + # 机器人位移增量 = [手柄y, -手柄z, -手柄x]。 + xr_to_robot_matrix: [0.0, 1.0, 0.0, + 0.0, 0.0, -1.0, + -1.0, 0.0, 0.0] + + use_mock: true + mock_initial_pose: [0.1924, -0.3025, 0.2923, 3.0560, 0.1310, 0.9070] + + robot_ip: 192.168.192.19 + robot_port: 8080 + avoid_singularity: 1 + frame_type: 1 + follow: false diff --git a/xr_rm_teleop/package.xml b/xr_rm_teleop/package.xml new file mode 100755 index 0000000..ef25793 --- /dev/null +++ b/xr_rm_teleop/package.xml @@ -0,0 +1,23 @@ + + + + xr_rm_teleop + 0.1.0 + XR relative-motion teleoperation controllers for RealMan RM75 arms. + Yikai Fu + Apache-2.0 + + ament_python + + geometry_msgs + rclpy + std_msgs + xr_rm_interfaces + + ament_lint_auto + ament_lint_common + + + ament_python + + diff --git a/xr_rm_teleop/resource/xr_rm_teleop b/xr_rm_teleop/resource/xr_rm_teleop new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/xr_rm_teleop/resource/xr_rm_teleop @@ -0,0 +1 @@ + diff --git a/xr_rm_teleop/setup.cfg b/xr_rm_teleop/setup.cfg new file mode 100755 index 0000000..12d13a8 --- /dev/null +++ b/xr_rm_teleop/setup.cfg @@ -0,0 +1,4 @@ +[develop] +script_dir=$base/lib/xr_rm_teleop +[install] +install_scripts=$base/lib/xr_rm_teleop diff --git a/xr_rm_teleop/setup.py b/xr_rm_teleop/setup.py new file mode 100755 index 0000000..d0fe314 --- /dev/null +++ b/xr_rm_teleop/setup.py @@ -0,0 +1,28 @@ +from glob import glob +from setuptools import setup + +package_name = "xr_rm_teleop" + +setup( + name=package_name, + version="0.1.0", + packages=[package_name], + data_files=[ + ("share/ament_index/resource_index/packages", [f"resource/{package_name}"]), + (f"share/{package_name}", ["package.xml"]), + (f"share/{package_name}/config", glob("config/*.yaml")), + ], + install_requires=["setuptools"], + zip_safe=True, + maintainer="Yikai Fu", + maintainer_email="user@example.com", + description="XR relative-motion teleoperation controllers for RealMan RM75 arms.", + license="Apache-2.0", + tests_require=["pytest"], + entry_points={ + "console_scripts": [ + "gripper_trigger_bridge = xr_rm_teleop.gripper_trigger_bridge:main", + "single_arm_velocity_teleop = xr_rm_teleop.single_arm_velocity_teleop:main", + ], + }, +) diff --git a/xr_rm_teleop/xr_rm_teleop/__init__.py b/xr_rm_teleop/xr_rm_teleop/__init__.py new file mode 100755 index 0000000..8b13789 --- /dev/null +++ b/xr_rm_teleop/xr_rm_teleop/__init__.py @@ -0,0 +1 @@ + diff --git a/xr_rm_teleop/xr_rm_teleop/gripper_trigger_bridge.py b/xr_rm_teleop/xr_rm_teleop/gripper_trigger_bridge.py new file mode 100755 index 0000000..46d6e5b --- /dev/null +++ b/xr_rm_teleop/xr_rm_teleop/gripper_trigger_bridge.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +import rclpy +from rclpy.node import Node +from std_msgs.msg import Float32 + +from xr_rm_interfaces.msg import XrController + + +def _clamp(value: float, low: float, high: float) -> float: + return min(max(value, low), high) + + +class GripperTriggerBridge(Node): + """将 PICO 扳机键映射为 OmniPicker 可订阅的归一化力控指令。""" + + def __init__(self) -> None: + super().__init__("gripper_trigger_bridge") + + self.declare_parameter("controller_topic", "/xr/right_controller") + self.declare_parameter("command_topic", "/omnipicker/right/force_ratio") + self.declare_parameter("publish_rate_hz", 30.0) + self.declare_parameter("stale_timeout_sec", 0.25) + self.declare_parameter("require_grip", True) + # 扳机键输入小于死区时保持开爪,避免手柄轻微误触导致夹爪闭合。 + self.declare_parameter("trigger_deadband", 0.05) + self.declare_parameter("open_command", 0.0) + # 阶段一以保护番茄为先,闭合力控上限应从低值开始实测。 + self.declare_parameter("min_close_command", 0.15) + self.declare_parameter("max_close_command", 0.45) + self.declare_parameter("low_pass_alpha", 0.5) + + controller_topic = str(self.get_parameter("controller_topic").value) + command_topic = str(self.get_parameter("command_topic").value) + publish_rate_hz = float(self.get_parameter("publish_rate_hz").value) + + self._stale_timeout_sec = float(self.get_parameter("stale_timeout_sec").value) + self._require_grip = bool(self.get_parameter("require_grip").value) + self._trigger_deadband = float(self.get_parameter("trigger_deadband").value) + self._open_command = float(self.get_parameter("open_command").value) + self._min_close_command = float(self.get_parameter("min_close_command").value) + self._max_close_command = float(self.get_parameter("max_close_command").value) + self._low_pass_alpha = float(self.get_parameter("low_pass_alpha").value) + self._validate_parameters() + + self._last_msg: XrController | None = None + self._last_msg_time = None + self._filtered_command = self._open_command + + self._publisher = self.create_publisher(Float32, command_topic, 10) + self.create_subscription(XrController, controller_topic, self._on_controller, 10) + self.create_timer(1.0 / publish_rate_hz, self._publish_command) + + self.get_logger().info( + f"夹爪 trigger 桥接已启动:{controller_topic} -> {command_topic}" + ) + + def _on_controller(self, msg: XrController) -> None: + self._last_msg = msg + self._last_msg_time = self.get_clock().now() + + def _publish_command(self) -> None: + command = self._open_command + + if self._last_msg is not None and self._last_msg_time is not None: + age = (self.get_clock().now() - self._last_msg_time).nanoseconds * 1e-9 + if age <= self._stale_timeout_sec: + command = self._command_from_trigger(self._last_msg) + + # 对夹爪指令做一阶平滑,避免扳机键抖动直接传到力控命令。 + alpha = self._low_pass_alpha + self._filtered_command = alpha * command + (1.0 - alpha) * self._filtered_command + msg = Float32() + msg.data = float(self._filtered_command) + self._publisher.publish(msg) + + def _command_from_trigger(self, msg: XrController) -> float: + if self._require_grip and not msg.grip: + return self._open_command + + trigger = _clamp(float(msg.trigger), 0.0, 1.0) + if trigger <= self._trigger_deadband: + return self._open_command + + ratio = (trigger - self._trigger_deadband) / (1.0 - self._trigger_deadband) + return self._min_close_command + ratio * (self._max_close_command - self._min_close_command) + + def _validate_parameters(self) -> None: + if not 0.0 <= self._trigger_deadband < 1.0: + raise ValueError("trigger_deadband must be in [0, 1)") + if self._min_close_command < self._open_command: + raise ValueError("min_close_command must be >= open_command") + if self._max_close_command < self._min_close_command: + raise ValueError("max_close_command must be >= min_close_command") + if not 0.0 <= self._low_pass_alpha <= 1.0: + raise ValueError("low_pass_alpha must be in [0, 1]") + + +def main(args: list[str] | None = None) -> None: + rclpy.init(args=args) + node = GripperTriggerBridge() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/xr_rm_teleop/xr_rm_teleop/realman_adapter.py b/xr_rm_teleop/xr_rm_teleop/realman_adapter.py new file mode 100755 index 0000000..b4ee73d --- /dev/null +++ b/xr_rm_teleop/xr_rm_teleop/realman_adapter.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from dataclasses import dataclass +from numbers import Number +from typing import Any + + +@dataclass +class ArmPose: + x: float + y: float + z: float + rx: float = 0.0 + ry: float = 0.0 + rz: float = 0.0 + + def xyz(self) -> list[float]: + return [self.x, self.y, self.z] + + +class MockRealManAdapter: + """无机械臂时使用的运动学模拟器,用于验证 ROS2 遥操链路。""" + + def __init__(self, initial_pose: list[float], dt: float) -> None: + self._pose = ArmPose(*initial_pose[:6]) + self._dt = dt + self.last_velocity = [0.0] * 6 + + def connect(self) -> None: + return + + def get_current_pose(self) -> ArmPose: + return self._pose + + def send_cartesian_velocity(self, velocity: list[float], follow: bool) -> None: + del follow + self.last_velocity = velocity + # 模拟模式只做简单积分,便于观察控制器是否在按预期更新末端位置。 + self._pose.x += velocity[0] * self._dt + self._pose.y += velocity[1] * self._dt + self._pose.z += velocity[2] * self._dt + self._pose.rx += velocity[3] * self._dt + self._pose.ry += velocity[4] * self._dt + self._pose.rz += velocity[5] * self._dt + + def stop(self) -> None: + self.last_velocity = [0.0] * 6 + + def close(self) -> None: + self.stop() + + +class RealManAdapter: + """睿尔曼 Python API2 的笛卡尔速度透传适配层。""" + + def __init__( + self, + robot_ip: str, + robot_port: int, + dt: float, + avoid_singularity: int, + frame_type: int, + ) -> None: + self._robot_ip = robot_ip + self._robot_port = robot_port + self._dt_ms = int(round(dt * 1000.0)) + self._avoid_singularity = avoid_singularity + self._frame_type = frame_type + self._arm: Any | None = None + + def connect(self) -> None: + try: + from Robotic_Arm.rm_robot_interface import RoboticArm, rm_thread_mode_e + except ImportError as exc: + raise RuntimeError( + "未安装睿尔曼 Python API2。请安装厂商 SDK,或用 use_mock:=true 先跑模拟模式。" + ) from exc + + self._arm = RoboticArm(rm_thread_mode_e.RM_TRIPLE_MODE_E) + ret = self._arm.rm_create_robot_arm(self._robot_ip, self._robot_port) + self._check_return(ret, "rm_create_robot_arm") + # 速度透传初始化必须和控制循环周期一致,避免真实机械臂出现周期不稳定。 + ret = self._arm.rm_set_movev_canfd_init( + self._avoid_singularity, + self._frame_type, + self._dt_ms, + ) + self._check_return(ret, "rm_set_movev_canfd_init") + + def get_current_pose(self) -> ArmPose: + self._require_arm() + state = self._arm.rm_get_current_arm_state() + pose = self._find_pose(state) + if pose is None: + raise RuntimeError(f"无法从睿尔曼状态中解析当前 TCP 位姿:{state!r}") + return ArmPose(*pose[:6]) + + def send_cartesian_velocity(self, velocity: list[float], follow: bool) -> None: + self._require_arm() + ret = self._arm.rm_movev_canfd(velocity, follow, 0, 0) + self._check_return(ret, "rm_movev_canfd") + + def stop(self) -> None: + if self._arm is None: + return + try: + self._arm.rm_movev_canfd([0.0] * 6, False, 0, 0) + self._arm.rm_set_arm_slow_stop() + except Exception: + pass + + def close(self) -> None: + if self._arm is None: + return + self.stop() + try: + self._arm.rm_delete_robot_arm() + finally: + self._arm = None + + def _require_arm(self) -> None: + if self._arm is None: + raise RuntimeError("睿尔曼机械臂尚未连接") + + @staticmethod + def _check_return(ret: Any, name: str) -> None: + code = ret[0] if isinstance(ret, tuple) and ret else ret + if isinstance(code, int) and code != 0: + raise RuntimeError(f"{name} failed with code {code}: {ret!r}") + + @classmethod + def _find_pose(cls, obj: Any) -> list[float] | None: + # 不同 SDK 版本返回字段可能略有差异,因此递归查找常见 TCP 位姿字段。 + if isinstance(obj, dict): + for key in ("pose", "tool_pose", "tcp_pose", "current_pose"): + pose = cls._as_pose(obj.get(key)) + if pose is not None: + return pose + for value in obj.values(): + pose = cls._find_pose(value) + if pose is not None: + return pose + elif isinstance(obj, (list, tuple)): + pose = cls._as_pose(obj) + if pose is not None: + return pose + for value in obj: + pose = cls._find_pose(value) + if pose is not None: + return pose + return None + + @staticmethod + def _as_pose(value: Any) -> list[float] | None: + if isinstance(value, (list, tuple)) and len(value) >= 6: + if all(isinstance(item, Number) for item in value[:6]): + return [float(item) for item in value[:6]] + return None diff --git a/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py b/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py new file mode 100755 index 0000000..608cb52 --- /dev/null +++ b/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py @@ -0,0 +1,243 @@ +from __future__ import annotations + +import math +from typing import Iterable + +import rclpy +from rclpy.node import Node + +from xr_rm_interfaces.msg import XrController + +from .realman_adapter import MockRealManAdapter, RealManAdapter + + +def _norm(values: Iterable[float]) -> float: + return math.sqrt(sum(value * value for value in values)) + + +def _clamp(value: float, low: float, high: float) -> float: + return min(max(value, low), high) + + +class SingleArmVelocityTeleop(Node): + """基于 XR 手柄相对位移的 RM75 单臂笛卡尔速度遥操节点。""" + + def __init__(self) -> None: + super().__init__("single_arm_velocity_teleop") + + self.declare_parameter("arm_name", "rm75") + self.declare_parameter("controller_topic", "/xr/right_controller") + self.declare_parameter("control_rate_hz", 50.0) + self.declare_parameter("command_timeout_sec", 0.12) + self.declare_parameter("scale", 1.0) + self.declare_parameter("kp_linear", 2.0) + self.declare_parameter("deadband_m", 0.002) + self.declare_parameter("low_pass_alpha", 0.35) + self.declare_parameter("max_linear_speed", 0.05) + self.declare_parameter("enable_position_axes", [True, True, True]) + self.declare_parameter("workspace_min", [0.20, -0.35, 0.10]) + self.declare_parameter("workspace_max", [0.65, 0.35, 0.60]) + self.declare_parameter("xr_to_robot_matrix", [0.0, 0.0, -1.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0]) + self.declare_parameter("use_mock", True) + self.declare_parameter("mock_initial_pose", [0.35, 0.0, 0.30, 0.0, 0.0, 0.0]) + self.declare_parameter("robot_ip", "192.168.1.18") + self.declare_parameter("robot_port", 8080) + self.declare_parameter("avoid_singularity", 1) + self.declare_parameter("frame_type", 1) + self.declare_parameter("follow", False) + + self._arm_name = str(self.get_parameter("arm_name").value) + topic = self.get_parameter("controller_topic").value + control_rate_hz = float(self.get_parameter("control_rate_hz").value) + self._dt = 1.0 / control_rate_hz + self._command_timeout_sec = float(self.get_parameter("command_timeout_sec").value) + self._scale = float(self.get_parameter("scale").value) + self._kp_linear = float(self.get_parameter("kp_linear").value) + self._deadband_m = float(self.get_parameter("deadband_m").value) + self._low_pass_alpha = float(self.get_parameter("low_pass_alpha").value) + self._max_linear_speed = float(self.get_parameter("max_linear_speed").value) + self._enable_position_axes = self._bool_list_parameter("enable_position_axes", 3) + self._workspace_min = self._float_list_parameter("workspace_min", 3) + self._workspace_max = self._float_list_parameter("workspace_max", 3) + self._xr_to_robot_matrix = self._float_list_parameter("xr_to_robot_matrix", 9) + self._follow = bool(self.get_parameter("follow").value) + self._validate_workspace() + + self._last_msg: XrController | None = None + self._last_msg_time = None + self._active = False + self._controller_start: list[float] | None = None + self._robot_start: list[float] | None = None + self._filtered_velocity = [0.0, 0.0, 0.0] + + self._adapter = self._make_adapter() + self._adapter.connect() + + self.create_subscription(XrController, topic, self._on_controller, 10) + self.create_timer(self._dt, self._control_tick) + self.get_logger().info(f"{self._arm_name} 速度遥操节点已启动,监听话题:{topic}") + + def _make_adapter(self): + if bool(self.get_parameter("use_mock").value): + return MockRealManAdapter( + [float(v) for v in self.get_parameter("mock_initial_pose").value], + self._dt, + ) + + return RealManAdapter( + robot_ip=self.get_parameter("robot_ip").value, + robot_port=int(self.get_parameter("robot_port").value), + dt=self._dt, + avoid_singularity=int(self.get_parameter("avoid_singularity").value), + frame_type=int(self.get_parameter("frame_type").value), + ) + + def _on_controller(self, msg: XrController) -> None: + self._last_msg = msg + self._last_msg_time = self.get_clock().now() + + def _control_tick(self) -> None: + if self._last_msg is None or self._last_msg_time is None: + self._safe_stop() + return + + age = (self.get_clock().now() - self._last_msg_time).nanoseconds * 1e-9 + if age > self._command_timeout_sec: + self.get_logger().warn(f"{self._arm_name} XR 手柄数据超时,机械臂停止。", throttle_duration_sec=1.0) + self._safe_stop() + self._active = False + return + + if not self._last_msg.grip: + if self._active: + self.get_logger().info(f"{self._arm_name} Grip 松开,退出相对位移遥操。") + self._safe_stop() + self._active = False + self._controller_start = None + self._robot_start = None + return + + controller_now = self._controller_xyz(self._last_msg) + try: + robot_now = self._adapter.get_current_pose().xyz() + except Exception as exc: + self.get_logger().error( + f"{self._arm_name} 读取 TCP 位姿失败,停止输出:{exc}", + throttle_duration_sec=1.0, + ) + self._safe_stop() + self._active = False + return + + if not self._active: + # 握持键按下的第一帧只锁定手柄和 TCP 起点,不立即运动,避免启停跳变。 + self._active = True + self._controller_start = controller_now + self._robot_start = robot_now + self._filtered_velocity = [0.0, 0.0, 0.0] + self.get_logger().info(f"{self._arm_name} Grip 按下,已锁定手柄和机械臂初始位姿。") + self._safe_stop() + return + + assert self._controller_start is not None + assert self._robot_start is not None + + # 核心控制:手柄相对位移 -> 机器人坐标系相对位移 -> 目标 TCP -> 速度命令。 + controller_delta = [controller_now[i] - self._controller_start[i] for i in range(3)] + robot_delta = self._map_xr_delta_to_robot(controller_delta) + target = [self._robot_start[i] + self._scale * robot_delta[i] for i in range(3)] + target = self._clamp_workspace(target) + error = [target[i] - robot_now[i] for i in range(3)] + error = [error[i] if self._enable_position_axes[i] else 0.0 for i in range(3)] + + if _norm(error) < self._deadband_m: + velocity = [0.0, 0.0, 0.0] + else: + velocity = [self._kp_linear * value for value in error] + velocity = self._clamp_vector_norm(velocity, self._max_linear_speed) + + # 低通滤波可以削弱 XR 追踪抖动,真实机械臂测试时不要关掉。 + alpha = self._low_pass_alpha + self._filtered_velocity = [ + alpha * velocity[i] + (1.0 - alpha) * self._filtered_velocity[i] + for i in range(3) + ] + self._send_cartesian_velocity(self._filtered_velocity + [0.0, 0.0, 0.0]) + + @staticmethod + def _controller_xyz(msg: XrController) -> list[float]: + return [msg.pose.position.x, msg.pose.position.y, msg.pose.position.z] + + def _map_xr_delta_to_robot(self, delta: list[float]) -> list[float]: + matrix = self._xr_to_robot_matrix + return [ + matrix[0] * delta[0] + matrix[1] * delta[1] + matrix[2] * delta[2], + matrix[3] * delta[0] + matrix[4] * delta[1] + matrix[5] * delta[2], + matrix[6] * delta[0] + matrix[7] * delta[1] + matrix[8] * delta[2], + ] + + def _clamp_workspace(self, target: list[float]) -> list[float]: + return [ + _clamp(target[i], self._workspace_min[i], self._workspace_max[i]) + for i in range(3) + ] + + @staticmethod + def _clamp_vector_norm(vector: list[float], max_norm: float) -> list[float]: + norm = _norm(vector) + if norm <= max_norm or norm <= 1e-9: + return vector + scale = max_norm / norm + return [value * scale for value in vector] + + def _safe_stop(self) -> None: + # 所有断连、松手、退出路径都走这里,确保发送零速度。 + self._filtered_velocity = [0.0, 0.0, 0.0] + self._send_cartesian_velocity([0.0] * 6) + + def _send_cartesian_velocity(self, velocity: list[float]) -> None: + try: + self._adapter.send_cartesian_velocity(velocity, self._follow) + except Exception as exc: + self.get_logger().error( + f"{self._arm_name} 发送速度命令失败:{exc}", + throttle_duration_sec=1.0, + ) + self._active = False + + def _float_list_parameter(self, name: str, expected_length: int) -> list[float]: + values = [float(value) for value in self.get_parameter(name).value] + if len(values) != expected_length: + raise ValueError(f"{name} must contain {expected_length} values") + return values + + def _bool_list_parameter(self, name: str, expected_length: int) -> list[bool]: + values = [bool(value) for value in self.get_parameter(name).value] + if len(values) != expected_length: + raise ValueError(f"{name} must contain {expected_length} values") + return values + + def _validate_workspace(self) -> None: + for axis, (low, high) in enumerate(zip(self._workspace_min, self._workspace_max)): + if low >= high: + raise ValueError(f"workspace_min[{axis}] must be smaller than workspace_max[{axis}]") + + def destroy_node(self) -> bool: + self._adapter.close() + return super().destroy_node() + + +def main(args: list[str] | None = None) -> None: + rclpy.init(args=args) + node = SingleArmVelocityTeleop() + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main()