forked from YikaiFu-cart/acRealman_xr
dual arm control from sample_udp_sender.py
This commit is contained in:
@ -23,13 +23,13 @@ left_arm_teleop:
|
|||||||
kp_linear: 1.8
|
kp_linear: 1.8
|
||||||
deadband_m: 0.002
|
deadband_m: 0.002
|
||||||
low_pass_alpha: 0.35
|
low_pass_alpha: 0.35
|
||||||
max_linear_speed: 0.04
|
max_linear_speed: 0.12
|
||||||
enable_position_axes: [true, true, true]
|
enable_position_axes: [true, true, true]
|
||||||
|
|
||||||
# 来自 acDual-arm 的 bounds_p[left]:
|
# 来自 acDual-arm 的 bounds_p[left]:
|
||||||
# x[-0.50, 0.50],y[-0.60, -0.20],z[0.10, 0.50]。
|
# x[-0.50, 0.50],y[-0.60, -0.20],z[0.10, 0.50]。
|
||||||
workspace_min: [-0.50, -0.60, 0.10]
|
workspace_min: [-0.70, -0.60, 0.10]
|
||||||
workspace_max: [0.50, -0.20, 0.50]
|
workspace_max: [0.70, 0.40, 0.70]
|
||||||
cyl_radius_limit: [0.20, 0.60]
|
cyl_radius_limit: [0.20, 0.60]
|
||||||
low_z_threshold: 0.20
|
low_z_threshold: 0.20
|
||||||
low_z_min_radius: 0.21
|
low_z_min_radius: 0.21
|
||||||
@ -45,7 +45,7 @@ left_arm_teleop:
|
|||||||
mock_initial_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
|
mock_initial_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
|
||||||
robot_ip: 192.168.192.18
|
robot_ip: 192.168.192.18
|
||||||
robot_port: 8080
|
robot_port: 8080
|
||||||
avoid_singularity: 1
|
avoid_singularity: 0
|
||||||
frame_type: 1
|
frame_type: 1
|
||||||
follow: false
|
follow: false
|
||||||
configure_safety_limits: true
|
configure_safety_limits: true
|
||||||
@ -60,6 +60,9 @@ left_arm_teleop:
|
|||||||
initial_joint_pose: [-167.21, 28.48, 28.21, 61.35, -14.40, 84.49, -124.51]
|
initial_joint_pose: [-167.21, 28.48, 28.21, 61.35, -14.40, 84.49, -124.51]
|
||||||
initial_tcp_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
|
initial_tcp_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
|
||||||
init_move_speed: 20
|
init_move_speed: 20
|
||||||
|
command_mode: pose_canfd
|
||||||
|
canfd_trajectory_mode: 2
|
||||||
|
canfd_radio: 0
|
||||||
debug_topic_prefix: /xr_rm
|
debug_topic_prefix: /xr_rm
|
||||||
|
|
||||||
right_arm_teleop:
|
right_arm_teleop:
|
||||||
@ -73,7 +76,7 @@ right_arm_teleop:
|
|||||||
kp_linear: 1.8
|
kp_linear: 1.8
|
||||||
deadband_m: 0.002
|
deadband_m: 0.002
|
||||||
low_pass_alpha: 0.35
|
low_pass_alpha: 0.35
|
||||||
max_linear_speed: 0.04
|
max_linear_speed: 0.12
|
||||||
enable_position_axes: [true, true, true]
|
enable_position_axes: [true, true, true]
|
||||||
|
|
||||||
# 来自 acDual-arm 的 bounds_p[right]:
|
# 来自 acDual-arm 的 bounds_p[right]:
|
||||||
|
|||||||
@ -11,11 +11,11 @@ single_arm_velocity_teleop:
|
|||||||
kp_linear: 1.8
|
kp_linear: 1.8
|
||||||
deadband_m: 0.002
|
deadband_m: 0.002
|
||||||
low_pass_alpha: 0.35
|
low_pass_alpha: 0.35
|
||||||
max_linear_speed: 0.04
|
max_linear_speed: 0.2
|
||||||
enable_position_axes: [true, true, true]
|
enable_position_axes: [true, true, true]
|
||||||
|
|
||||||
workspace_min: [-0.50, -0.60, 0.10]
|
workspace_min: [-0.70, -0.60, 0.10]
|
||||||
workspace_max: [0.50, -0.20, 0.50]
|
workspace_max: [0.70, 0.40, 0.70]
|
||||||
cyl_radius_limit: [0.20, 0.60]
|
cyl_radius_limit: [0.20, 0.60]
|
||||||
low_z_threshold: 0.20
|
low_z_threshold: 0.20
|
||||||
low_z_min_radius: 0.21
|
low_z_min_radius: 0.21
|
||||||
@ -28,7 +28,7 @@ single_arm_velocity_teleop:
|
|||||||
mock_initial_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
|
mock_initial_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
|
||||||
robot_ip: 192.168.192.18
|
robot_ip: 192.168.192.18
|
||||||
robot_port: 8080
|
robot_port: 8080
|
||||||
avoid_singularity: 1
|
avoid_singularity: 0
|
||||||
frame_type: 1
|
frame_type: 1
|
||||||
follow: false
|
follow: false
|
||||||
configure_safety_limits: true
|
configure_safety_limits: true
|
||||||
@ -42,4 +42,7 @@ single_arm_velocity_teleop:
|
|||||||
initial_joint_pose: [-167.21, 28.48, 28.21, 61.35, -14.40, 84.49, -124.51]
|
initial_joint_pose: [-167.21, 28.48, 28.21, 61.35, -14.40, 84.49, -124.51]
|
||||||
initial_tcp_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
|
initial_tcp_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
|
||||||
init_move_speed: 20
|
init_move_speed: 20
|
||||||
|
command_mode: pose_canfd
|
||||||
|
canfd_trajectory_mode: 2
|
||||||
|
canfd_radio: 0
|
||||||
debug_topic_prefix: /xr_rm
|
debug_topic_prefix: /xr_rm
|
||||||
|
|||||||
@ -11,11 +11,11 @@ single_arm_velocity_teleop:
|
|||||||
kp_linear: 1.8
|
kp_linear: 1.8
|
||||||
deadband_m: 0.002
|
deadband_m: 0.002
|
||||||
low_pass_alpha: 0.35
|
low_pass_alpha: 0.35
|
||||||
max_linear_speed: 0.04
|
max_linear_speed: 0.2
|
||||||
enable_position_axes: [true, true, true]
|
enable_position_axes: [true, true, true]
|
||||||
|
|
||||||
workspace_min: [-0.70, -0.60, 0.10]
|
workspace_min: [-0.70, -0.60, 0.10]
|
||||||
workspace_max: [0.50, 0.40, 0.70]
|
workspace_max: [0.70, 0.40, 0.70]
|
||||||
cyl_radius_limit: [0.20, 0.60]
|
cyl_radius_limit: [0.20, 0.60]
|
||||||
low_z_threshold: 0.20
|
low_z_threshold: 0.20
|
||||||
low_z_min_radius: 0.21
|
low_z_min_radius: 0.21
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
"""单臂/双臂通用调试入口。
|
||||||
|
|
||||||
|
该 launch 文件用于现场调试阶段按需启动左臂、右臂或双臂,并可通过
|
||||||
|
`use_mock` 在 mock 模式和 RM75 真机模式之间切换。它会固定启动 UDP
|
||||||
|
手柄接收节点,再根据 `arm:=left|right|both` 选择对应的遥操作节点。
|
||||||
|
"""
|
||||||
|
|
||||||
from launch import LaunchDescription
|
from launch import LaunchDescription
|
||||||
from launch.actions import DeclareLaunchArgument, OpaqueFunction
|
from launch.actions import DeclareLaunchArgument, OpaqueFunction
|
||||||
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
|
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
|
||||||
@ -6,10 +13,12 @@ from launch_ros.substitutions import FindPackageShare
|
|||||||
|
|
||||||
|
|
||||||
def _as_bool(value: str) -> bool:
|
def _as_bool(value: str) -> bool:
|
||||||
|
"""把 launch 字符串参数转换成 Python bool,便于在 OpaqueFunction 中分支。"""
|
||||||
return value.strip().lower() in ("1", "true", "yes", "on")
|
return value.strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
def _config_file(name: str) -> PathJoinSubstitution:
|
def _config_file(name: str) -> PathJoinSubstitution:
|
||||||
|
"""生成 xr_rm_bringup/config 下配置文件的可安装路径。"""
|
||||||
return PathJoinSubstitution([
|
return PathJoinSubstitution([
|
||||||
FindPackageShare("xr_rm_bringup"),
|
FindPackageShare("xr_rm_bringup"),
|
||||||
"config",
|
"config",
|
||||||
@ -18,6 +27,7 @@ def _config_file(name: str) -> PathJoinSubstitution:
|
|||||||
|
|
||||||
|
|
||||||
def _udp_receiver_node() -> Node:
|
def _udp_receiver_node() -> Node:
|
||||||
|
"""接收 PICO/XR UDP 数据,并发布左右手柄 ROS2 话题。"""
|
||||||
return Node(
|
return Node(
|
||||||
package="xr_rm_input",
|
package="xr_rm_input",
|
||||||
executable="udp_controller_receiver",
|
executable="udp_controller_receiver",
|
||||||
@ -32,7 +42,15 @@ def _udp_receiver_node() -> Node:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _single_arm_node(arm: str, use_mock: bool, move_to_initial_pose: bool) -> Node:
|
def _single_arm_node(
|
||||||
|
arm: str,
|
||||||
|
use_mock: bool,
|
||||||
|
move_to_initial_pose: bool,
|
||||||
|
avoid_singularity: int,
|
||||||
|
frame_type: int,
|
||||||
|
configure_safety_limits: bool,
|
||||||
|
) -> Node:
|
||||||
|
"""创建单臂调试节点;左/右臂分别使用独立 YAML,节点名保持单臂默认名。"""
|
||||||
config_name = "left_arm_rm75.yaml" if arm == "left" else "right_arm_rm75.yaml"
|
config_name = "left_arm_rm75.yaml" if arm == "left" else "right_arm_rm75.yaml"
|
||||||
robot_ip = LaunchConfiguration("left_robot_ip" if arm == "left" else "right_robot_ip")
|
robot_ip = LaunchConfiguration("left_robot_ip" if arm == "left" else "right_robot_ip")
|
||||||
return Node(
|
return Node(
|
||||||
@ -46,13 +64,24 @@ def _single_arm_node(arm: str, use_mock: bool, move_to_initial_pose: bool) -> No
|
|||||||
"use_mock": use_mock,
|
"use_mock": use_mock,
|
||||||
"robot_ip": robot_ip,
|
"robot_ip": robot_ip,
|
||||||
"robot_port": LaunchConfiguration("robot_port"),
|
"robot_port": LaunchConfiguration("robot_port"),
|
||||||
|
"avoid_singularity": avoid_singularity,
|
||||||
|
"frame_type": frame_type,
|
||||||
|
"configure_safety_limits": configure_safety_limits,
|
||||||
"move_to_initial_pose_on_connect": move_to_initial_pose,
|
"move_to_initial_pose_on_connect": move_to_initial_pose,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _dual_arm_nodes(use_mock: bool, move_to_initial_pose: bool) -> list[Node]:
|
def _dual_arm_nodes(
|
||||||
|
use_mock: bool,
|
||||||
|
move_to_initial_pose: bool,
|
||||||
|
left_avoid_singularity: int,
|
||||||
|
right_avoid_singularity: int,
|
||||||
|
frame_type: int,
|
||||||
|
configure_safety_limits: bool,
|
||||||
|
) -> list[Node]:
|
||||||
|
"""创建双臂节点;两个节点共用双臂 YAML,但节点名区分左右臂参数命名空间。"""
|
||||||
config_file = _config_file("dual_arm_rm75.yaml")
|
config_file = _config_file("dual_arm_rm75.yaml")
|
||||||
return [
|
return [
|
||||||
Node(
|
Node(
|
||||||
@ -66,6 +95,9 @@ def _dual_arm_nodes(use_mock: bool, move_to_initial_pose: bool) -> list[Node]:
|
|||||||
"use_mock": use_mock,
|
"use_mock": use_mock,
|
||||||
"robot_ip": LaunchConfiguration("left_robot_ip"),
|
"robot_ip": LaunchConfiguration("left_robot_ip"),
|
||||||
"robot_port": LaunchConfiguration("robot_port"),
|
"robot_port": LaunchConfiguration("robot_port"),
|
||||||
|
"avoid_singularity": left_avoid_singularity,
|
||||||
|
"frame_type": frame_type,
|
||||||
|
"configure_safety_limits": configure_safety_limits,
|
||||||
"move_to_initial_pose_on_connect": move_to_initial_pose,
|
"move_to_initial_pose_on_connect": move_to_initial_pose,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -81,6 +113,9 @@ def _dual_arm_nodes(use_mock: bool, move_to_initial_pose: bool) -> list[Node]:
|
|||||||
"use_mock": use_mock,
|
"use_mock": use_mock,
|
||||||
"robot_ip": LaunchConfiguration("right_robot_ip"),
|
"robot_ip": LaunchConfiguration("right_robot_ip"),
|
||||||
"robot_port": LaunchConfiguration("robot_port"),
|
"robot_port": LaunchConfiguration("robot_port"),
|
||||||
|
"avoid_singularity": right_avoid_singularity,
|
||||||
|
"frame_type": frame_type,
|
||||||
|
"configure_safety_limits": configure_safety_limits,
|
||||||
"move_to_initial_pose_on_connect": move_to_initial_pose,
|
"move_to_initial_pose_on_connect": move_to_initial_pose,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -89,33 +124,78 @@ def _dual_arm_nodes(use_mock: bool, move_to_initial_pose: bool) -> list[Node]:
|
|||||||
|
|
||||||
|
|
||||||
def _launch_setup(context, *args, **kwargs):
|
def _launch_setup(context, *args, **kwargs):
|
||||||
|
"""运行时读取 launch 参数,决定启动单臂还是双臂。"""
|
||||||
del args, kwargs
|
del args, kwargs
|
||||||
arm = LaunchConfiguration("arm").perform(context).strip().lower()
|
arm = LaunchConfiguration("arm").perform(context).strip().lower()
|
||||||
use_mock = _as_bool(LaunchConfiguration("use_mock").perform(context))
|
use_mock = _as_bool(LaunchConfiguration("use_mock").perform(context))
|
||||||
move_to_initial_pose = _as_bool(
|
move_to_initial_pose = _as_bool(
|
||||||
LaunchConfiguration("move_to_initial_pose_on_connect").perform(context)
|
LaunchConfiguration("move_to_initial_pose_on_connect").perform(context)
|
||||||
)
|
)
|
||||||
|
avoid_override = LaunchConfiguration("avoid_singularity").perform(context).strip()
|
||||||
|
left_avoid_singularity = int(
|
||||||
|
avoid_override or LaunchConfiguration("left_avoid_singularity").perform(context)
|
||||||
|
)
|
||||||
|
right_avoid_singularity = int(
|
||||||
|
avoid_override or LaunchConfiguration("right_avoid_singularity").perform(context)
|
||||||
|
)
|
||||||
|
frame_type = int(LaunchConfiguration("frame_type").perform(context))
|
||||||
|
configure_safety_limits = _as_bool(
|
||||||
|
LaunchConfiguration("configure_safety_limits").perform(context)
|
||||||
|
)
|
||||||
|
|
||||||
if arm not in ("left", "right", "both"):
|
if arm not in ("left", "right", "both"):
|
||||||
raise ValueError("arm must be one of: left, right, both")
|
raise ValueError("arm must be one of: left, right, both")
|
||||||
|
|
||||||
nodes = [_udp_receiver_node()]
|
nodes = [_udp_receiver_node()]
|
||||||
if arm == "both":
|
if arm == "both":
|
||||||
nodes.extend(_dual_arm_nodes(use_mock, move_to_initial_pose))
|
nodes.extend(
|
||||||
|
_dual_arm_nodes(
|
||||||
|
use_mock,
|
||||||
|
move_to_initial_pose,
|
||||||
|
left_avoid_singularity,
|
||||||
|
right_avoid_singularity,
|
||||||
|
frame_type,
|
||||||
|
configure_safety_limits,
|
||||||
|
)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
nodes.append(_single_arm_node(arm, use_mock, move_to_initial_pose))
|
avoid_singularity = left_avoid_singularity if arm == "left" else right_avoid_singularity
|
||||||
|
nodes.append(
|
||||||
|
_single_arm_node(
|
||||||
|
arm,
|
||||||
|
use_mock,
|
||||||
|
move_to_initial_pose,
|
||||||
|
avoid_singularity,
|
||||||
|
frame_type,
|
||||||
|
configure_safety_limits,
|
||||||
|
)
|
||||||
|
)
|
||||||
return nodes
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
def generate_launch_description() -> LaunchDescription:
|
||||||
return LaunchDescription([
|
return LaunchDescription([
|
||||||
|
# 调试目标:left/right/both;默认右臂方便单臂逐步上真机。
|
||||||
DeclareLaunchArgument("arm", default_value="right"),
|
DeclareLaunchArgument("arm", default_value="right"),
|
||||||
|
# true 时只跑 mock,不连接 RM75;false 时通过 RealMan SDK 连接真机。
|
||||||
DeclareLaunchArgument("use_mock", default_value="true"),
|
DeclareLaunchArgument("use_mock", default_value="true"),
|
||||||
|
# UDP 监听参数,需要与 PICO 端或 sample_udp_sender 保持一致。
|
||||||
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
|
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
|
||||||
DeclareLaunchArgument("udp_port", default_value="15000"),
|
DeclareLaunchArgument("udp_port", default_value="15000"),
|
||||||
|
# 左右 RM75 默认 IP,可在命令行中按现场网络覆盖。
|
||||||
DeclareLaunchArgument("left_robot_ip", default_value="192.168.192.18"),
|
DeclareLaunchArgument("left_robot_ip", default_value="192.168.192.18"),
|
||||||
DeclareLaunchArgument("right_robot_ip", default_value="192.168.192.19"),
|
DeclareLaunchArgument("right_robot_ip", default_value="192.168.192.19"),
|
||||||
DeclareLaunchArgument("robot_port", default_value="8080"),
|
DeclareLaunchArgument("robot_port", default_value="8080"),
|
||||||
|
# 真机速度透传与安全配置参数。左臂现场控制器对硬件避奇异初始化
|
||||||
|
# 可能返回超时,因此默认关闭硬件避奇异,只保留软件侧限幅。
|
||||||
|
DeclareLaunchArgument("left_avoid_singularity", default_value="0"),
|
||||||
|
DeclareLaunchArgument("right_avoid_singularity", default_value="1"),
|
||||||
|
# 非空时作为左右臂全局覆盖,例如 avoid_singularity:=0。
|
||||||
|
DeclareLaunchArgument("avoid_singularity", default_value=""),
|
||||||
|
DeclareLaunchArgument("frame_type", default_value="1"),
|
||||||
|
DeclareLaunchArgument("configure_safety_limits", default_value="true"),
|
||||||
|
# 默认不自动移动到初始点,确认安全区后再显式打开。
|
||||||
DeclareLaunchArgument("move_to_initial_pose_on_connect", default_value="false"),
|
DeclareLaunchArgument("move_to_initial_pose_on_connect", default_value="false"),
|
||||||
|
# OpaqueFunction 允许根据 arm/use_mock 等运行时参数动态生成节点。
|
||||||
OpaqueFunction(function=_launch_setup),
|
OpaqueFunction(function=_launch_setup),
|
||||||
])
|
])
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
"""双臂 mock 遥操作启动入口。
|
||||||
|
|
||||||
|
该 launch 文件用于不连接真实 RM75 的离线闭环验证:启动 UDP 手柄接收节点,
|
||||||
|
并分别启动左、右两个 `single_arm_velocity_teleop` 节点。两个遥操作节点使用
|
||||||
|
同一份双臂配置文件,但通过节点名读取各自的参数命名空间。
|
||||||
|
"""
|
||||||
|
|
||||||
from launch import LaunchDescription
|
from launch import LaunchDescription
|
||||||
from launch.actions import DeclareLaunchArgument
|
from launch.actions import DeclareLaunchArgument
|
||||||
from launch.conditions import IfCondition
|
from launch.conditions import IfCondition
|
||||||
@ -7,6 +14,7 @@ from launch_ros.substitutions import FindPackageShare
|
|||||||
|
|
||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
def generate_launch_description() -> LaunchDescription:
|
||||||
|
# 通过 robot_config 参数选择 xr_rm_bringup/config 下的双臂配置文件。
|
||||||
config_file = PathJoinSubstitution([
|
config_file = PathJoinSubstitution([
|
||||||
FindPackageShare("xr_rm_bringup"),
|
FindPackageShare("xr_rm_bringup"),
|
||||||
"config",
|
"config",
|
||||||
@ -14,9 +22,11 @@ def generate_launch_description() -> LaunchDescription:
|
|||||||
])
|
])
|
||||||
|
|
||||||
return LaunchDescription([
|
return LaunchDescription([
|
||||||
|
# UDP 监听参数,需要和 PICO 端或 sample_udp_sender 的目标地址保持一致。
|
||||||
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
|
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
|
||||||
DeclareLaunchArgument("udp_port", default_value="15000"),
|
DeclareLaunchArgument("udp_port", default_value="15000"),
|
||||||
DeclareLaunchArgument("robot_config", default_value="dual_arm_rm75.yaml"),
|
DeclareLaunchArgument("robot_config", default_value="dual_arm_rm75.yaml"),
|
||||||
|
# 这些开关便于单独验证 UDP、左臂或右臂链路。
|
||||||
DeclareLaunchArgument("run_udp", default_value="true"),
|
DeclareLaunchArgument("run_udp", default_value="true"),
|
||||||
DeclareLaunchArgument("run_left_arm", default_value="true"),
|
DeclareLaunchArgument("run_left_arm", default_value="true"),
|
||||||
DeclareLaunchArgument("run_right_arm", default_value="true"),
|
DeclareLaunchArgument("run_right_arm", default_value="true"),
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
"""双 RM75 真机遥操作启动入口。
|
||||||
|
|
||||||
|
该 launch 文件用于连接左右两台真实 RM75:启动 UDP 手柄接收节点,并分别启动
|
||||||
|
左、右两个真机遥操作节点。默认不会自动移动到初始点,只有显式设置
|
||||||
|
`move_to_initial_pose_on_connect:=true` 后才会执行初始化点位移动。
|
||||||
|
"""
|
||||||
|
|
||||||
from launch import LaunchDescription
|
from launch import LaunchDescription
|
||||||
from launch.actions import DeclareLaunchArgument
|
from launch.actions import DeclareLaunchArgument
|
||||||
from launch.conditions import IfCondition
|
from launch.conditions import IfCondition
|
||||||
@ -8,6 +15,7 @@ from launch_ros.substitutions import FindPackageShare
|
|||||||
|
|
||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
def generate_launch_description() -> LaunchDescription:
|
||||||
|
# 双臂真机默认读取 xr_rm_bringup/config/dual_arm_rm75.yaml。
|
||||||
config_file = PathJoinSubstitution([
|
config_file = PathJoinSubstitution([
|
||||||
FindPackageShare("xr_rm_bringup"),
|
FindPackageShare("xr_rm_bringup"),
|
||||||
"config",
|
"config",
|
||||||
@ -15,16 +23,21 @@ def generate_launch_description() -> LaunchDescription:
|
|||||||
])
|
])
|
||||||
|
|
||||||
return LaunchDescription([
|
return LaunchDescription([
|
||||||
|
# UDP 监听参数,需要与 PICO 端发送地址和端口匹配。
|
||||||
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
|
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
|
||||||
DeclareLaunchArgument("udp_port", default_value="15000"),
|
DeclareLaunchArgument("udp_port", default_value="15000"),
|
||||||
|
# 左右 RM75 的默认现场 IP,可在 launch 命令中按实际网络覆盖。
|
||||||
DeclareLaunchArgument("left_robot_ip", default_value="192.168.192.18"),
|
DeclareLaunchArgument("left_robot_ip", default_value="192.168.192.18"),
|
||||||
DeclareLaunchArgument("right_robot_ip", default_value="192.168.192.19"),
|
DeclareLaunchArgument("right_robot_ip", default_value="192.168.192.19"),
|
||||||
DeclareLaunchArgument("robot_port", default_value="8080"),
|
DeclareLaunchArgument("robot_port", default_value="8080"),
|
||||||
DeclareLaunchArgument("robot_config", default_value="dual_arm_rm75.yaml"),
|
DeclareLaunchArgument("robot_config", default_value="dual_arm_rm75.yaml"),
|
||||||
|
# 调试时可关闭部分节点,只验证 UDP 或单侧机械臂。
|
||||||
DeclareLaunchArgument("run_udp", default_value="true"),
|
DeclareLaunchArgument("run_udp", default_value="true"),
|
||||||
DeclareLaunchArgument("run_left_arm", default_value="true"),
|
DeclareLaunchArgument("run_left_arm", default_value="true"),
|
||||||
DeclareLaunchArgument("run_right_arm", default_value="true"),
|
DeclareLaunchArgument("run_right_arm", default_value="true"),
|
||||||
|
# 真机默认不自动动臂,确认安全区清空后再手动打开。
|
||||||
DeclareLaunchArgument("move_to_initial_pose_on_connect", default_value="false"),
|
DeclareLaunchArgument("move_to_initial_pose_on_connect", default_value="false"),
|
||||||
|
# 接收 PICO/XR 侧 UDP 数据,并按左右手字段分发到对应手柄话题。
|
||||||
Node(
|
Node(
|
||||||
package="xr_rm_input",
|
package="xr_rm_input",
|
||||||
executable="udp_controller_receiver",
|
executable="udp_controller_receiver",
|
||||||
@ -38,6 +51,7 @@ def generate_launch_description() -> LaunchDescription:
|
|||||||
"right_topic": "/xr/right_controller",
|
"right_topic": "/xr/right_controller",
|
||||||
}],
|
}],
|
||||||
),
|
),
|
||||||
|
# 左臂真机节点:连接 left_robot_ip,并读取 left_arm_teleop 参数命名空间。
|
||||||
Node(
|
Node(
|
||||||
package="xr_rm_teleop",
|
package="xr_rm_teleop",
|
||||||
executable="single_arm_velocity_teleop",
|
executable="single_arm_velocity_teleop",
|
||||||
@ -57,6 +71,7 @@ def generate_launch_description() -> LaunchDescription:
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
# 右臂真机节点:连接 right_robot_ip,并读取 right_arm_teleop 参数命名空间。
|
||||||
Node(
|
Node(
|
||||||
package="xr_rm_teleop",
|
package="xr_rm_teleop",
|
||||||
executable="single_arm_velocity_teleop",
|
executable="single_arm_velocity_teleop",
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
"""XR-RM 桌面调试启动器。
|
||||||
|
|
||||||
|
提供 Tkinter 图形界面,按“仿真/左臂/右臂/双臂/诊断”组织常用 ROS2
|
||||||
|
launch、sample_udp_sender、topic 监控和环境检查命令,降低现场调试时的命令输入成本。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
@ -25,6 +31,13 @@ except ImportError as exc:
|
|||||||
DEFAULT_LEFT_IP = "192.168.192.18"
|
DEFAULT_LEFT_IP = "192.168.192.18"
|
||||||
DEFAULT_RIGHT_IP = "192.168.192.19"
|
DEFAULT_RIGHT_IP = "192.168.192.19"
|
||||||
DEFAULT_PORT = "15000"
|
DEFAULT_PORT = "15000"
|
||||||
|
SAMPLE_SENDER_SECONDS = "30"
|
||||||
|
SAMPLE_SENDER_STAGGERED_SECONDS = "60"
|
||||||
|
SAMPLE_SENDER_ARGS = (
|
||||||
|
f"--host 127.0.0.1 --port {DEFAULT_PORT} "
|
||||||
|
"--pattern axis_sweep --amplitude 0.5 "
|
||||||
|
"--hold-seconds 4.0 --center-seconds 1.2 --initial-center-seconds 1.0"
|
||||||
|
)
|
||||||
TERMINAL_TITLE_PREFIX = "XR-RM Terminal - "
|
TERMINAL_TITLE_PREFIX = "XR-RM Terminal - "
|
||||||
TOPIC_MONITOR_TITLE = "XR-RM Topic Monitor"
|
TOPIC_MONITOR_TITLE = "XR-RM Topic Monitor"
|
||||||
TOPIC_MONITOR_ACTION = "__xr_rm_topic_monitor__"
|
TOPIC_MONITOR_ACTION = "__xr_rm_topic_monitor__"
|
||||||
@ -62,7 +75,22 @@ def _with_index(items: list[tuple[str, str]]) -> list[tuple[str, str]]:
|
|||||||
return [(f"{idx}. {title}", cmd) for idx, (title, cmd) in enumerate(items, start=1)]
|
return [(f"{idx}. {title}", cmd) for idx, (title, cmd) in enumerate(items, start=1)]
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_udp_sender_command(
|
||||||
|
hand: str,
|
||||||
|
seconds: str = SAMPLE_SENDER_SECONDS,
|
||||||
|
both_mode: str = "synchronized",
|
||||||
|
) -> str:
|
||||||
|
command = (
|
||||||
|
f"ros2 run xr_rm_input sample_udp_sender --hand {hand} "
|
||||||
|
f"{SAMPLE_SENDER_ARGS} --seconds {seconds}"
|
||||||
|
)
|
||||||
|
if hand == "both":
|
||||||
|
command += f" --both-mode {both_mode}"
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
def _find_workspace_root() -> Path:
|
def _find_workspace_root() -> Path:
|
||||||
|
# 优先使用显式环境变量,便于从安装目录、源码目录或桌面快捷方式启动。
|
||||||
env_workspace = os.environ.get("XR_RM_WS")
|
env_workspace = os.environ.get("XR_RM_WS")
|
||||||
if env_workspace:
|
if env_workspace:
|
||||||
return Path(env_workspace).expanduser().resolve()
|
return Path(env_workspace).expanduser().resolve()
|
||||||
@ -77,6 +105,7 @@ def _find_workspace_root() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _source_lines(workspace_root: Path) -> list[str]:
|
def _source_lines(workspace_root: Path) -> list[str]:
|
||||||
|
# 每个新终端都重新 source ROS 和工作空间,避免依赖启动器自身的 shell 环境。
|
||||||
ros_setup = Path("/opt/ros/humble/setup.bash")
|
ros_setup = Path("/opt/ros/humble/setup.bash")
|
||||||
install_setup = workspace_root / "install" / "setup.bash"
|
install_setup = workspace_root / "install" / "setup.bash"
|
||||||
lines = [
|
lines = [
|
||||||
@ -100,11 +129,13 @@ def _source_lines(workspace_root: Path) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _one_click_mock(arm: str, hand: str) -> str:
|
def _one_click_mock(arm: str, hand: str) -> str:
|
||||||
|
sender_seconds = SAMPLE_SENDER_STAGGERED_SECONDS if hand == "both" else SAMPLE_SENDER_SECONDS
|
||||||
|
both_mode = "staggered" if hand == "both" else "synchronized"
|
||||||
return "\n".join([
|
return "\n".join([
|
||||||
f"ros2 launch xr_rm_bringup arm_debug.launch.py arm:={arm} use_mock:=true &",
|
f"ros2 launch xr_rm_bringup arm_debug.launch.py arm:={arm} use_mock:=true &",
|
||||||
"launch_pid=$!",
|
"launch_pid=$!",
|
||||||
"sleep 2",
|
"sleep 2",
|
||||||
f"ros2 run xr_rm_input sample_udp_sender --hand {hand} --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 20",
|
_sample_udp_sender_command(hand, sender_seconds, both_mode),
|
||||||
"echo",
|
"echo",
|
||||||
"echo 'Sample sender finished. The launch process is still running in this terminal.'",
|
"echo 'Sample sender finished. The launch process is still running in this terminal.'",
|
||||||
"echo 'Press Ctrl-C here, or use the cleanup button in the launcher, to stop it.'",
|
"echo 'Press Ctrl-C here, or use the cleanup button in the launcher, to stop it.'",
|
||||||
@ -133,22 +164,23 @@ def _finalize_items(
|
|||||||
|
|
||||||
|
|
||||||
def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
||||||
|
# UI 列表只维护命令模板;真正执行时统一套上工作空间 source 和终端包装。
|
||||||
if mode == "Simulation":
|
if mode == "Simulation":
|
||||||
items = [
|
items = [
|
||||||
("Left Arm Mock Launch", "ros2 launch xr_rm_bringup arm_debug.launch.py arm:=left use_mock:=true"),
|
("Left Arm Mock Launch", "ros2 launch xr_rm_bringup arm_debug.launch.py arm:=left use_mock:=true"),
|
||||||
("Right Arm Mock Launch", "ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=true"),
|
("Right Arm Mock Launch", "ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=true"),
|
||||||
("Dual Arm Mock Launch", "ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=true"),
|
("Dual Arm Mock Launch", "ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=true"),
|
||||||
(
|
(
|
||||||
"Sample UDP Sender (Left, 10s)",
|
"Sample UDP Sender (Left, 30s)",
|
||||||
f"ros2 run xr_rm_input sample_udp_sender --hand left --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
|
_sample_udp_sender_command("left"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Sample UDP Sender (Right, 10s)",
|
"Sample UDP Sender (Right, 30s)",
|
||||||
f"ros2 run xr_rm_input sample_udp_sender --hand right --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
|
_sample_udp_sender_command("right"),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Sample UDP Sender (Both, 10s)",
|
"Sample UDP Sender (Both Staggered, 60s)",
|
||||||
f"ros2 run xr_rm_input sample_udp_sender --hand both --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
|
_sample_udp_sender_command("both", SAMPLE_SENDER_STAGGERED_SECONDS, "staggered"),
|
||||||
),
|
),
|
||||||
("One-Click Left Mock Demo", _one_click_mock("left", "left")),
|
("One-Click Left Mock Demo", _one_click_mock("left", "left")),
|
||||||
("One-Click Right Mock Demo", _one_click_mock("right", "right")),
|
("One-Click Right Mock Demo", _one_click_mock("right", "right")),
|
||||||
@ -165,8 +197,8 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
|||||||
"move_to_initial_pose_on_connect:=false",
|
"move_to_initial_pose_on_connect:=false",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Sample UDP Sender (Left, 10s)",
|
"Sample UDP Sender (Left, 30s)",
|
||||||
f"ros2 run xr_rm_input sample_udp_sender --hand left --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
|
_sample_udp_sender_command("left"),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
one_click = None
|
one_click = None
|
||||||
@ -180,8 +212,8 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
|||||||
"move_to_initial_pose_on_connect:=false",
|
"move_to_initial_pose_on_connect:=false",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Sample UDP Sender (Right, 10s)",
|
"Sample UDP Sender (Right, 30s)",
|
||||||
f"ros2 run xr_rm_input sample_udp_sender --hand right --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
|
_sample_udp_sender_command("right"),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
one_click = None
|
one_click = None
|
||||||
@ -196,8 +228,8 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
|||||||
"move_to_initial_pose_on_connect:=false",
|
"move_to_initial_pose_on_connect:=false",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Sample UDP Sender (Both, 10s)",
|
"Sample UDP Sender (Both Staggered, 60s)",
|
||||||
f"ros2 run xr_rm_input sample_udp_sender --hand both --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
|
_sample_udp_sender_command("both", SAMPLE_SENDER_STAGGERED_SECONDS, "staggered"),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
one_click = None
|
one_click = None
|
||||||
@ -213,6 +245,8 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
|||||||
|
|
||||||
|
|
||||||
class LauncherApp:
|
class LauncherApp:
|
||||||
|
"""XR-RM 调试启动器主窗口,负责命令列表、终端启动和监控窗口管理。"""
|
||||||
|
|
||||||
def __init__(self, root: tk.Tk) -> None:
|
def __init__(self, root: tk.Tk) -> None:
|
||||||
self.root = root
|
self.root = root
|
||||||
self.workspace_root = _find_workspace_root()
|
self.workspace_root = _find_workspace_root()
|
||||||
@ -421,6 +455,7 @@ class LauncherApp:
|
|||||||
return result.returncode == 0
|
return result.returncode == 0
|
||||||
|
|
||||||
def check_prerequisites(self) -> None:
|
def check_prerequisites(self) -> None:
|
||||||
|
# 环境检查只做轻量探测,避免在 UI 线程里执行耗时的网络或硬件连接动作。
|
||||||
ok: list[str] = []
|
ok: list[str] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
@ -604,16 +639,72 @@ class LauncherApp:
|
|||||||
return Path(script_file.name)
|
return Path(script_file.name)
|
||||||
|
|
||||||
def topic_monitor_rect(self) -> tuple[int, int, int, int]:
|
def topic_monitor_rect(self) -> tuple[int, int, int, int]:
|
||||||
screen_width = max(self.root.winfo_screenwidth(), 1)
|
# 监控窗口尽量放在启动器所在显示器,双屏现场调试时更容易找回窗口。
|
||||||
screen_height = max(self.root.winfo_screenheight(), 1)
|
monitor_left, monitor_top, monitor_width, monitor_height = self.active_monitor_rect()
|
||||||
width = min(screen_width - 160, max(960, int(screen_width * 0.75)))
|
width = min(monitor_width - 120, max(960, int(monitor_width * 0.75)))
|
||||||
height = min(screen_height - 160, max(560, int(screen_height * 0.66)))
|
height = min(monitor_height - 120, max(560, int(monitor_height * 0.66)))
|
||||||
width = max(width, min(screen_width, 900))
|
width = max(width, min(monitor_width, 900))
|
||||||
height = max(height, min(screen_height, 520))
|
height = max(height, min(monitor_height, 520))
|
||||||
left = max((screen_width - width) // 2, 0)
|
left = monitor_left + max((monitor_width - width) // 2, 0)
|
||||||
top = max((screen_height - height) // 2, 0)
|
top = monitor_top + max((monitor_height - height) // 2, 0)
|
||||||
return width, height, left, top
|
return width, height, left, top
|
||||||
|
|
||||||
|
def active_monitor_rect(self) -> tuple[int, int, int, int]:
|
||||||
|
monitors = self.xrandr_monitor_rects()
|
||||||
|
if not monitors:
|
||||||
|
return (0, 0, max(self.root.winfo_screenwidth(), 1), max(self.root.winfo_screenheight(), 1))
|
||||||
|
|
||||||
|
self.root.update_idletasks()
|
||||||
|
root_center = (
|
||||||
|
self.root.winfo_rootx() + max(self.root.winfo_width(), 1) // 2,
|
||||||
|
self.root.winfo_rooty() + max(self.root.winfo_height(), 1) // 2,
|
||||||
|
)
|
||||||
|
pointer = (self.root.winfo_pointerx(), self.root.winfo_pointery())
|
||||||
|
|
||||||
|
for point_x, point_y in (root_center, pointer):
|
||||||
|
for left, top, width, height, _primary in monitors:
|
||||||
|
if left <= point_x < left + width and top <= point_y < top + height:
|
||||||
|
return (left, top, width, height)
|
||||||
|
|
||||||
|
for left, top, width, height, primary in monitors:
|
||||||
|
if primary:
|
||||||
|
return (left, top, width, height)
|
||||||
|
|
||||||
|
left, top, width, height, _primary = monitors[0]
|
||||||
|
return (left, top, width, height)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def xrandr_monitor_rects() -> list[tuple[int, int, int, int, bool]]:
|
||||||
|
if not shutil.which("xrandr"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
["xrandr", "--query"],
|
||||||
|
text=True,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
monitors: list[tuple[int, int, int, int, bool]] = []
|
||||||
|
pattern = re.compile(
|
||||||
|
r"^\S+ connected(?P<primary> primary)? "
|
||||||
|
r"(?P<w>\d+)x(?P<h>\d+)\+(?P<x>-?\d+)\+(?P<y>-?\d+)"
|
||||||
|
)
|
||||||
|
for line in output.splitlines():
|
||||||
|
match = pattern.match(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
monitors.append((
|
||||||
|
int(match.group("x")),
|
||||||
|
int(match.group("y")),
|
||||||
|
int(match.group("w")),
|
||||||
|
int(match.group("h")),
|
||||||
|
bool(match.group("primary")),
|
||||||
|
))
|
||||||
|
return monitors
|
||||||
|
|
||||||
def window_ids_matching_title(self, title: str) -> list[int]:
|
def window_ids_matching_title(self, title: str) -> list[int]:
|
||||||
if not shutil.which("xprop"):
|
if not shutil.which("xprop"):
|
||||||
return []
|
return []
|
||||||
@ -641,16 +732,87 @@ class LauncherApp:
|
|||||||
window_ids.append(int(xid_text, 16))
|
window_ids.append(int(xid_text, 16))
|
||||||
return window_ids
|
return window_ids
|
||||||
|
|
||||||
def force_monitor_window_geometry(self, title: str, attempts_left: int = 40) -> None:
|
def force_monitor_window_geometry(
|
||||||
|
self,
|
||||||
|
title: str,
|
||||||
|
target_rect: tuple[int, int, int, int],
|
||||||
|
attempts_left: int = 30,
|
||||||
|
) -> None:
|
||||||
if attempts_left <= 0 or not self.root.winfo_exists():
|
if attempts_left <= 0 or not self.root.winfo_exists():
|
||||||
return
|
return
|
||||||
|
|
||||||
window_ids = self.window_ids_matching_title(title)
|
window_ids = self.window_ids_matching_title(title)
|
||||||
if window_ids:
|
if not window_ids:
|
||||||
width, height, left, top = self.topic_monitor_rect()
|
self.root.after(
|
||||||
|
80,
|
||||||
|
lambda: self.force_monitor_window_geometry(title, target_rect, attempts_left - 1),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if any(self.monitor_window_needs_geometry(window_id, target_rect) for window_id in window_ids):
|
||||||
|
width, height, left, top = target_rect
|
||||||
self.apply_x11_window_geometry(window_ids, left, top, width, height)
|
self.apply_x11_window_geometry(window_ids, left, top, width, height)
|
||||||
|
|
||||||
self.root.after(250, lambda: self.force_monitor_window_geometry(title, attempts_left - 1))
|
def monitor_window_needs_geometry(self, window_id: int, target_rect: tuple[int, int, int, int]) -> bool:
|
||||||
|
props = self.x11_window_props(window_id)
|
||||||
|
if "_NET_WM_STATE_FULLSCREEN" in props or "_NET_WM_STATE_MAXIMIZED_HORZ" in props:
|
||||||
|
return True
|
||||||
|
if "_NET_WM_STATE_MAXIMIZED_VERT" in props:
|
||||||
|
return True
|
||||||
|
|
||||||
|
geometry = self.x11_window_geometry(window_id)
|
||||||
|
if geometry is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
actual_left, actual_top, actual_width, actual_height = geometry
|
||||||
|
target_width, target_height, target_left, target_top = target_rect
|
||||||
|
if abs(actual_width - target_width) > 180 or abs(actual_height - target_height) > 180:
|
||||||
|
return True
|
||||||
|
if abs(actual_left - target_left) > 180 or abs(actual_top - target_top) > 180:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def x11_window_props(window_id: int) -> str:
|
||||||
|
if not shutil.which("xprop"):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return subprocess.check_output(
|
||||||
|
["xprop", "-id", hex(window_id), "_NET_WM_STATE"],
|
||||||
|
text=True,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def x11_window_geometry(window_id: int) -> tuple[int, int, int, int] | None:
|
||||||
|
if not shutil.which("xwininfo"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
["xwininfo", "-id", hex(window_id)],
|
||||||
|
text=True,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
values: dict[str, int] = {}
|
||||||
|
patterns = {
|
||||||
|
"left": r"Absolute upper-left X:\s+(-?\d+)",
|
||||||
|
"top": r"Absolute upper-left Y:\s+(-?\d+)",
|
||||||
|
"width": r"Width:\s+(\d+)",
|
||||||
|
"height": r"Height:\s+(\d+)",
|
||||||
|
}
|
||||||
|
for key, pattern in patterns.items():
|
||||||
|
match = re.search(pattern, output)
|
||||||
|
if match:
|
||||||
|
values[key] = int(match.group(1))
|
||||||
|
|
||||||
|
if len(values) != 4:
|
||||||
|
return None
|
||||||
|
return values["left"], values["top"], values["width"], values["height"]
|
||||||
|
|
||||||
def apply_x11_window_geometry(self, window_ids: list[int], left: int, top: int, width: int, height: int) -> None:
|
def apply_x11_window_geometry(self, window_ids: list[int], left: int, top: int, width: int, height: int) -> None:
|
||||||
lib_path = ctypes.util.find_library("X11")
|
lib_path = ctypes.util.find_library("X11")
|
||||||
@ -769,8 +931,8 @@ class LauncherApp:
|
|||||||
event_mask = (1 << 19) | (1 << 20) # SubstructureNotifyMask | SubstructureRedirectMask
|
event_mask = (1 << 19) | (1 << 20) # SubstructureNotifyMask | SubstructureRedirectMask
|
||||||
xlib.XSendEvent(display, root, False, event_mask, ctypes.byref(event))
|
xlib.XSendEvent(display, root, False, event_mask, ctypes.byref(event))
|
||||||
|
|
||||||
def write_topic_monitor_config(self) -> Path:
|
def write_topic_monitor_config(self, target_rect: tuple[int, int, int, int] | None = None) -> Path:
|
||||||
width, height, left, top = self.topic_monitor_rect()
|
width, height, left, top = target_rect or self.topic_monitor_rect()
|
||||||
left_script, right_script = [
|
left_script, right_script = [
|
||||||
self.write_topic_monitor_script(title, topic)
|
self.write_topic_monitor_script(title, topic)
|
||||||
for title, topic in TOPIC_MONITORS
|
for title, topic in TOPIC_MONITORS
|
||||||
@ -778,6 +940,8 @@ class LauncherApp:
|
|||||||
layout_name = "xr_rm_topic_monitor"
|
layout_name = "xr_rm_topic_monitor"
|
||||||
config_text = "\n".join([
|
config_text = "\n".join([
|
||||||
"[global_config]",
|
"[global_config]",
|
||||||
|
" geometry_hinting = False",
|
||||||
|
" window_state = normal",
|
||||||
" suppress_multiple_term_dialog = True",
|
" suppress_multiple_term_dialog = True",
|
||||||
"[keybindings]",
|
"[keybindings]",
|
||||||
"[profiles]",
|
"[profiles]",
|
||||||
@ -822,8 +986,8 @@ class LauncherApp:
|
|||||||
config_file.write(config_text)
|
config_file.write(config_text)
|
||||||
return Path(config_file.name)
|
return Path(config_file.name)
|
||||||
|
|
||||||
def write_ros_graph_monitor_config(self) -> Path:
|
def write_ros_graph_monitor_config(self, target_rect: tuple[int, int, int, int] | None = None) -> Path:
|
||||||
width, height, left, top = self.topic_monitor_rect()
|
width, height, left, top = target_rect or self.topic_monitor_rect()
|
||||||
topic_script, node_script = [
|
topic_script, node_script = [
|
||||||
self.write_ros_graph_monitor_script(title, command)
|
self.write_ros_graph_monitor_script(title, command)
|
||||||
for title, command in ROS_GRAPH_MONITORS
|
for title, command in ROS_GRAPH_MONITORS
|
||||||
@ -831,6 +995,8 @@ class LauncherApp:
|
|||||||
layout_name = "xr_rm_ros_graph_monitor"
|
layout_name = "xr_rm_ros_graph_monitor"
|
||||||
config_text = "\n".join([
|
config_text = "\n".join([
|
||||||
"[global_config]",
|
"[global_config]",
|
||||||
|
" geometry_hinting = False",
|
||||||
|
" window_state = normal",
|
||||||
" suppress_multiple_term_dialog = True",
|
" suppress_multiple_term_dialog = True",
|
||||||
"[keybindings]",
|
"[keybindings]",
|
||||||
"[profiles]",
|
"[profiles]",
|
||||||
@ -892,7 +1058,8 @@ class LauncherApp:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
config_path = self.write_topic_monitor_config()
|
target_rect = self.topic_monitor_rect()
|
||||||
|
config_path = self.write_topic_monitor_config(target_rect)
|
||||||
command = [
|
command = [
|
||||||
terminal,
|
terminal,
|
||||||
"--no-dbus",
|
"--no-dbus",
|
||||||
@ -910,7 +1077,7 @@ class LauncherApp:
|
|||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
self.root.after(150, lambda: self.force_monitor_window_geometry(TOPIC_MONITOR_TITLE))
|
self.root.after(60, lambda: self.force_monitor_window_geometry(TOPIC_MONITOR_TITLE, target_rect))
|
||||||
self.status.config(text="Opened Terminator controller topic monitor.")
|
self.status.config(text="Opened Terminator controller topic monitor.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
messagebox.showerror("Topic Monitor Failed", f"Failed to launch topic monitor:\n{exc}")
|
messagebox.showerror("Topic Monitor Failed", f"Failed to launch topic monitor:\n{exc}")
|
||||||
@ -933,7 +1100,8 @@ class LauncherApp:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
config_path = self.write_ros_graph_monitor_config()
|
target_rect = self.topic_monitor_rect()
|
||||||
|
config_path = self.write_ros_graph_monitor_config(target_rect)
|
||||||
command = [
|
command = [
|
||||||
terminal,
|
terminal,
|
||||||
"--no-dbus",
|
"--no-dbus",
|
||||||
@ -951,7 +1119,7 @@ class LauncherApp:
|
|||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
self.root.after(150, lambda: self.force_monitor_window_geometry(ROS_GRAPH_MONITOR_TITLE))
|
self.root.after(60, lambda: self.force_monitor_window_geometry(ROS_GRAPH_MONITOR_TITLE, target_rect))
|
||||||
self.status.config(text="Opened Terminator ROS graph monitor.")
|
self.status.config(text="Opened Terminator ROS graph monitor.")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
messagebox.showerror("ROS Graph Monitor Failed", f"Failed to launch ROS graph monitor:\n{exc}")
|
messagebox.showerror("ROS Graph Monitor Failed", f"Failed to launch ROS graph monitor:\n{exc}")
|
||||||
@ -997,6 +1165,7 @@ class LauncherApp:
|
|||||||
if not confirm:
|
if not confirm:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 只清理由启动器常用命令创建的 ROS/监控进程,并保护当前 UI 进程。
|
||||||
patterns = [
|
patterns = [
|
||||||
"ros2 launch xr_rm_bringup",
|
"ros2 launch xr_rm_bringup",
|
||||||
"ros2 run xr_rm_input",
|
"ros2 run xr_rm_input",
|
||||||
|
|||||||
215
xr_rm_bringup/tools/realman_dual_arm_state_monitor.py
Executable file
215
xr_rm_bringup/tools/realman_dual_arm_state_monitor.py
Executable file
@ -0,0 +1,215 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""独立双臂状态监控脚本(轮询版)。
|
||||||
|
|
||||||
|
优化点:
|
||||||
|
- 不再依赖 UDP 回调;
|
||||||
|
- 直接通过 rm_get_current_arm_state() 按句柄轮询左右臂状态;
|
||||||
|
- 避免同进程双回调潜在冲突导致的左臂显示异常。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from realman_pkg.Robotic_Arm.rm_robot_interface import RoboticArm, rm_thread_mode_e
|
||||||
|
from realman_pkg.core.arg_cfg import host_ip, ip_l, ip_r, port_l, port_r
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArmSnapshot:
|
||||||
|
"""单臂状态快照。"""
|
||||||
|
|
||||||
|
connected: bool
|
||||||
|
ret_code: int
|
||||||
|
last_update: float
|
||||||
|
joint_deg: list[float]
|
||||||
|
pose_euler: np.ndarray
|
||||||
|
err_text: str
|
||||||
|
|
||||||
|
|
||||||
|
class ArmStatePoller:
|
||||||
|
"""单臂状态轮询器。"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
robot_ip: str,
|
||||||
|
robot_port: int,
|
||||||
|
level: int = 3,
|
||||||
|
mode: int = 2,
|
||||||
|
):
|
||||||
|
self.name = name
|
||||||
|
self.robot_ip = robot_ip
|
||||||
|
self.robot_port = int(robot_port)
|
||||||
|
self.level = int(level)
|
||||||
|
self.mode = int(mode)
|
||||||
|
|
||||||
|
self.robot: Optional[RoboticArm] = None
|
||||||
|
self.robot_handle = None
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
self.last_snapshot = ArmSnapshot(
|
||||||
|
connected=False,
|
||||||
|
ret_code=-999,
|
||||||
|
last_update=0.0,
|
||||||
|
joint_deg=[0.0] * 7,
|
||||||
|
pose_euler=np.zeros(6, dtype=np.float64),
|
||||||
|
err_text="not connected",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._connect()
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
"""连接机械臂。"""
|
||||||
|
try:
|
||||||
|
self.robot = RoboticArm(rm_thread_mode_e(self.mode))
|
||||||
|
self.robot_handle = self.robot.rm_create_robot_arm(self.robot_ip, self.robot_port, self.level)
|
||||||
|
if self.robot_handle.id == -1:
|
||||||
|
self.connected = False
|
||||||
|
self.last_snapshot.connected = False
|
||||||
|
self.last_snapshot.err_text = f"connect failed ({self.robot_ip}:{self.robot_port})"
|
||||||
|
print(f"[{self.name}] 连接失败: {self.last_snapshot.err_text}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.connected = True
|
||||||
|
self.last_snapshot.connected = True
|
||||||
|
self.last_snapshot.err_text = "ok"
|
||||||
|
print(f"[{self.name}] 已连接, handle_id={self.robot_handle.id}, ip={self.robot_ip}, port={self.robot_port}")
|
||||||
|
except Exception as exc:
|
||||||
|
self.connected = False
|
||||||
|
self.last_snapshot.connected = False
|
||||||
|
self.last_snapshot.err_text = f"connect exception: {exc}"
|
||||||
|
print(f"[{self.name}] 连接异常: {exc}")
|
||||||
|
|
||||||
|
def poll(self) -> ArmSnapshot:
|
||||||
|
"""轮询一次当前状态。"""
|
||||||
|
if (not self.connected) or (self.robot is None):
|
||||||
|
return self.last_snapshot
|
||||||
|
|
||||||
|
try:
|
||||||
|
ret, state = self.robot.rm_get_current_arm_state()
|
||||||
|
now_t = time.time()
|
||||||
|
|
||||||
|
if ret != 0:
|
||||||
|
self.last_snapshot.ret_code = ret
|
||||||
|
self.last_snapshot.err_text = f"rm_get_current_arm_state ret={ret}"
|
||||||
|
return self.last_snapshot
|
||||||
|
|
||||||
|
joint = list(state.get("joint", [0.0] * 7))
|
||||||
|
pose = list(state.get("pose", [0.0] * 6))
|
||||||
|
err = state.get("err", {})
|
||||||
|
|
||||||
|
if len(joint) < 7:
|
||||||
|
joint.extend([0.0] * (7 - len(joint)))
|
||||||
|
elif len(joint) > 7:
|
||||||
|
joint = joint[:7]
|
||||||
|
|
||||||
|
if len(pose) < 6:
|
||||||
|
pose.extend([0.0] * (6 - len(pose)))
|
||||||
|
elif len(pose) > 6:
|
||||||
|
pose = pose[:6]
|
||||||
|
|
||||||
|
self.last_snapshot = ArmSnapshot(
|
||||||
|
connected=True,
|
||||||
|
ret_code=ret,
|
||||||
|
last_update=now_t,
|
||||||
|
joint_deg=joint,
|
||||||
|
pose_euler=np.array(pose, dtype=np.float64),
|
||||||
|
err_text=str(err),
|
||||||
|
)
|
||||||
|
return self.last_snapshot
|
||||||
|
except Exception as exc:
|
||||||
|
self.last_snapshot.err_text = f"poll exception: {exc}"
|
||||||
|
return self.last_snapshot
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
"""断开连接。"""
|
||||||
|
if self.robot is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.robot.rm_delete_robot_arm()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def format_arm_block(name: str, snap: ArmSnapshot, now_t: float) -> str:
|
||||||
|
"""格式化单臂输出。"""
|
||||||
|
if not snap.connected:
|
||||||
|
return f"[{name}] 未连接: {snap.err_text}"
|
||||||
|
|
||||||
|
age = now_t - snap.last_update if snap.last_update > 0 else float("inf")
|
||||||
|
stale_flag = "(stale)" if age > 2.0 else ""
|
||||||
|
|
||||||
|
j = ", ".join(f"{v:7.2f}" for v in snap.joint_deg)
|
||||||
|
p = snap.pose_euler
|
||||||
|
return (
|
||||||
|
f"[{name}] update_age={age:5.2f}s {stale_flag}, ret={snap.ret_code}\n"
|
||||||
|
f" Joint(deg): [{j}]\n"
|
||||||
|
f" Pose[x y z rx ry rz]: [{p[0]: .4f}, {p[1]: .4f}, {p[2]: .4f}, {p[3]: .4f}, {p[4]: .4f}, {p[5]: .4f}]\n"
|
||||||
|
f" Err: {snap.err_text}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_arg_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(description="Realman 双臂状态监控脚本(轮询版)")
|
||||||
|
parser.add_argument("--left-ip", default=ip_l, help="左臂 IP")
|
||||||
|
parser.add_argument("--right-ip", default=ip_r, help="右臂 IP")
|
||||||
|
parser.add_argument("--left-port", type=int, default=port_l, help="左臂 TCP 端口")
|
||||||
|
parser.add_argument("--right-port", type=int, default=port_r, help="右臂 TCP 端口")
|
||||||
|
parser.add_argument("--refresh", type=float, default=0.5, help="刷新周期(秒)")
|
||||||
|
parser.add_argument("--level", type=int, default=3, help="连接级别")
|
||||||
|
parser.add_argument("--mode", type=int, default=2, help="线程模式 0/1/2")
|
||||||
|
parser.add_argument("--host-ip", default=host_ip, help="仅展示当前配置用,轮询版不使用")
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = build_arg_parser().parse_args()
|
||||||
|
|
||||||
|
print(f"配置: left={args.left_ip}:{args.left_port}, right={args.right_ip}:{args.right_port}, host_ip={args.host_ip}")
|
||||||
|
|
||||||
|
left = ArmStatePoller(
|
||||||
|
name="LEFT",
|
||||||
|
robot_ip=args.left_ip,
|
||||||
|
robot_port=args.left_port,
|
||||||
|
level=args.level,
|
||||||
|
mode=args.mode,
|
||||||
|
)
|
||||||
|
right = ArmStatePoller(
|
||||||
|
name="RIGHT",
|
||||||
|
robot_ip=args.right_ip,
|
||||||
|
robot_port=args.right_port,
|
||||||
|
level=args.level,
|
||||||
|
mode=args.mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
print("\n开始监控双臂状态(轮询),按 Ctrl+C 退出。\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
now_t = time.time()
|
||||||
|
l_snap = left.poll()
|
||||||
|
r_snap = right.poll()
|
||||||
|
|
||||||
|
print("\033[2J\033[H", end="")
|
||||||
|
print(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now_t)))
|
||||||
|
print("=" * 88)
|
||||||
|
print(format_arm_block("LEFT", l_snap, now_t))
|
||||||
|
print("-" * 88)
|
||||||
|
print(format_arm_block("RIGHT", r_snap, now_t))
|
||||||
|
print("=" * 88)
|
||||||
|
time.sleep(max(args.refresh, 0.05))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\n退出监控...")
|
||||||
|
finally:
|
||||||
|
left.close()
|
||||||
|
right.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@ -1,3 +1,10 @@
|
|||||||
|
"""XR 手柄 UDP 接收节点的独立启动入口。
|
||||||
|
|
||||||
|
该 launch 文件只启动 `udp_controller_receiver`,用于低层通信调试:确认 PICO
|
||||||
|
或 sample_udp_sender 发出的 UDP JSON 能被 ROS2 接收,并发布到指定手柄话题。
|
||||||
|
完整单臂/双臂遥操作请优先使用 xr_rm_bringup 中的 launch 文件。
|
||||||
|
"""
|
||||||
|
|
||||||
from launch import LaunchDescription
|
from launch import LaunchDescription
|
||||||
from launch.actions import DeclareLaunchArgument
|
from launch.actions import DeclareLaunchArgument
|
||||||
from launch.substitutions import LaunchConfiguration
|
from launch.substitutions import LaunchConfiguration
|
||||||
@ -6,9 +13,12 @@ from launch_ros.actions import Node
|
|||||||
|
|
||||||
def generate_launch_description() -> LaunchDescription:
|
def generate_launch_description() -> LaunchDescription:
|
||||||
return LaunchDescription([
|
return LaunchDescription([
|
||||||
|
# UDP 监听地址和端口,需要与发送端配置一致。
|
||||||
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
|
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
|
||||||
DeclareLaunchArgument("udp_port", default_value="15000"),
|
DeclareLaunchArgument("udp_port", default_value="15000"),
|
||||||
|
# 兼容旧版单手柄调试入口;双臂调试会使用 left_topic/right_topic。
|
||||||
DeclareLaunchArgument("topic", default_value="/xr/right_controller"),
|
DeclareLaunchArgument("topic", default_value="/xr/right_controller"),
|
||||||
|
# 仅启动 UDP 接收节点,不启动任何机械臂控制节点。
|
||||||
Node(
|
Node(
|
||||||
package="xr_rm_input",
|
package="xr_rm_input",
|
||||||
executable="udp_controller_receiver",
|
executable="udp_controller_receiver",
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
"""xr_rm_input 包安装配置。
|
||||||
|
|
||||||
|
该包负责把 XR/PICO 端发来的 UDP 手柄数据接入 ROS2,并提供一个
|
||||||
|
sample_udp_sender 方便在没有真实手柄时做链路调试。
|
||||||
|
"""
|
||||||
|
|
||||||
from glob import glob
|
from glob import glob
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
|
|||||||
@ -1 +1,4 @@
|
|||||||
|
"""XR 输入包。
|
||||||
|
|
||||||
|
包含 UDP 手柄数据接收节点和用于现场调试的模拟 UDP 发送脚本。
|
||||||
|
"""
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
"""XR 手柄 UDP 数据模拟发送器。
|
||||||
|
|
||||||
|
用于在没有真实 PICO/XR 手柄时向 udp_controller_receiver 发送测试数据。
|
||||||
|
默认轨迹按 +X/-X、+Y/-Y、+Z/-Z 成对扫轴,便于用肉眼检查机械臂方向映射。
|
||||||
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
@ -5,14 +11,188 @@ import socket
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
BASE_POS = [0.0, 1.0, 0.0]
|
||||||
|
# 方向名称会打印到终端,方便现场观察机械臂运动时对照坐标轴。
|
||||||
|
AXIS_DIRECTIONS = (
|
||||||
|
("XR +X", (1.0, 0.0, 0.0)),
|
||||||
|
("XR -X", (-1.0, 0.0, 0.0)),
|
||||||
|
("XR +Y", (0.0, 1.0, 0.0)),
|
||||||
|
("XR -Y", (0.0, -1.0, 0.0)),
|
||||||
|
("XR +Z", (0.0, 0.0, 1.0)),
|
||||||
|
("XR -Z", (0.0, 0.0, -1.0)),
|
||||||
|
)
|
||||||
|
AXIS_SWEEP_SEGMENTS = (
|
||||||
|
("XR +X right", (1.0, 0.0, 0.0), "hold"),
|
||||||
|
("XR -X left", (-1.0, 0.0, 0.0), "hold"),
|
||||||
|
("CENTER / X done", (0.0, 0.0, 0.0), "center"),
|
||||||
|
("XR +Y up", (0.0, 1.0, 0.0), "hold"),
|
||||||
|
("XR -Y down", (0.0, -1.0, 0.0), "hold"),
|
||||||
|
("CENTER / Y done", (0.0, 0.0, 0.0), "center"),
|
||||||
|
("XR +Z back", (0.0, 0.0, 1.0), "hold"),
|
||||||
|
("XR -Z front", (0.0, 0.0, -1.0), "hold"),
|
||||||
|
("CENTER / Z done", (0.0, 0.0, 0.0), "center"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_positive(name: str, value: float) -> float:
|
||||||
|
if value <= 0.0:
|
||||||
|
raise argparse.ArgumentTypeError(f"{name} must be > 0")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _positive_float(name: str):
|
||||||
|
def parser(value: str) -> float:
|
||||||
|
return _validate_positive(name, float(value))
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def _position_from_direction(direction: tuple[float, float, float], amplitude: float) -> list[float]:
|
||||||
|
return [
|
||||||
|
BASE_POS[0] + amplitude * direction[0],
|
||||||
|
BASE_POS[1] + amplitude * direction[1],
|
||||||
|
BASE_POS[2] + amplitude * direction[2],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern_period(pattern: str, hold_seconds: float, center_seconds: float) -> float:
|
||||||
|
if pattern == "axis_sweep":
|
||||||
|
return sum(
|
||||||
|
hold_seconds if segment_type == "hold" else center_seconds
|
||||||
|
for _, _, segment_type in AXIS_SWEEP_SEGMENTS
|
||||||
|
)
|
||||||
|
if pattern == "axis_steps":
|
||||||
|
return (hold_seconds + center_seconds) * len(AXIS_DIRECTIONS)
|
||||||
|
return 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_sweep_position(
|
||||||
|
t: float,
|
||||||
|
amplitude: float,
|
||||||
|
hold_seconds: float,
|
||||||
|
center_seconds: float,
|
||||||
|
initial_center_seconds: float,
|
||||||
|
) -> tuple[str, list[float]]:
|
||||||
|
if t < initial_center_seconds:
|
||||||
|
return "CENTER / lock origin", BASE_POS.copy()
|
||||||
|
|
||||||
|
# +方向和-方向连续切换,比每次都很快回中心更容易看清实际运动方向。
|
||||||
|
cycle_t = (t - initial_center_seconds) % _pattern_period(
|
||||||
|
"axis_sweep",
|
||||||
|
hold_seconds,
|
||||||
|
center_seconds,
|
||||||
|
)
|
||||||
|
for label, direction, segment_type in AXIS_SWEEP_SEGMENTS:
|
||||||
|
duration = hold_seconds if segment_type == "hold" else center_seconds
|
||||||
|
if cycle_t < duration:
|
||||||
|
return label, _position_from_direction(direction, amplitude)
|
||||||
|
cycle_t -= duration
|
||||||
|
return "CENTER", BASE_POS.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def _axis_step_position(
|
||||||
|
t: float,
|
||||||
|
amplitude: float,
|
||||||
|
hold_seconds: float,
|
||||||
|
center_seconds: float,
|
||||||
|
initial_center_seconds: float,
|
||||||
|
) -> tuple[str, list[float]]:
|
||||||
|
if t < initial_center_seconds:
|
||||||
|
return "CENTER / lock origin", BASE_POS.copy()
|
||||||
|
|
||||||
|
# 每个方向由“保持目标点”和“回中心”两段组成,循环遍历六个方向。
|
||||||
|
segment_seconds = hold_seconds + center_seconds
|
||||||
|
cycle_t = (t - initial_center_seconds) % (segment_seconds * len(AXIS_DIRECTIONS))
|
||||||
|
direction_index = int(cycle_t // segment_seconds)
|
||||||
|
segment_t = cycle_t - direction_index * segment_seconds
|
||||||
|
label, direction = AXIS_DIRECTIONS[direction_index]
|
||||||
|
|
||||||
|
if segment_t >= hold_seconds:
|
||||||
|
return f"CENTER after {label}", BASE_POS.copy()
|
||||||
|
|
||||||
|
return label, _position_from_direction(direction, amplitude)
|
||||||
|
|
||||||
|
|
||||||
|
def _sine_position(t: float, amplitude: float, phase: float) -> tuple[str, list[float]]:
|
||||||
|
return "SINE X", [
|
||||||
|
amplitude * math.sin(2.0 * math.pi * 0.2 * t + phase),
|
||||||
|
BASE_POS[1],
|
||||||
|
BASE_POS[2],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern_position(
|
||||||
|
pattern: str,
|
||||||
|
t: float,
|
||||||
|
amplitude: float,
|
||||||
|
hold_seconds: float,
|
||||||
|
center_seconds: float,
|
||||||
|
initial_center_seconds: float,
|
||||||
|
phase: float,
|
||||||
|
) -> tuple[str, list[float]]:
|
||||||
|
if pattern == "sine":
|
||||||
|
return _sine_position(t, amplitude, phase)
|
||||||
|
if pattern == "axis_steps":
|
||||||
|
return _axis_step_position(
|
||||||
|
t,
|
||||||
|
amplitude,
|
||||||
|
hold_seconds,
|
||||||
|
center_seconds,
|
||||||
|
initial_center_seconds,
|
||||||
|
)
|
||||||
|
return _axis_sweep_position(
|
||||||
|
t,
|
||||||
|
amplitude,
|
||||||
|
hold_seconds,
|
||||||
|
center_seconds,
|
||||||
|
initial_center_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="通过 UDP 发送一段模拟 XR 左/右手柄数据。")
|
parser = argparse.ArgumentParser(description="通过 UDP 发送一段模拟 XR 左/右手柄数据。")
|
||||||
parser.add_argument("--host", default="127.0.0.1")
|
parser.add_argument("--host", default="127.0.0.1")
|
||||||
parser.add_argument("--port", type=int, default=15000)
|
parser.add_argument("--port", type=int, default=15000)
|
||||||
parser.add_argument("--seconds", type=float, default=5.0)
|
parser.add_argument("--seconds", type=_positive_float("seconds"), default=30.0)
|
||||||
parser.add_argument("--hz", type=float, default=60.0)
|
parser.add_argument("--hz", type=_positive_float("hz"), default=60.0)
|
||||||
parser.add_argument("--amplitude", type=float, default=0.04)
|
parser.add_argument("--amplitude", type=_positive_float("amplitude"), default=0.50)
|
||||||
|
parser.add_argument(
|
||||||
|
"--pattern",
|
||||||
|
choices=("axis_sweep", "axis_steps", "sine"),
|
||||||
|
default="axis_sweep",
|
||||||
|
help="axis_sweep 成对扫轴最适合肉眼看方向;axis_steps 每个方向后回中心;sine 保留旧轨迹。",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--hold-seconds",
|
||||||
|
type=_positive_float("hold-seconds"),
|
||||||
|
default=4.0,
|
||||||
|
help="axis_sweep/axis_steps 模式下,每个方向保持多久。增大可让机械臂走得更明显。",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--center-seconds",
|
||||||
|
type=_positive_float("center-seconds"),
|
||||||
|
default=1.2,
|
||||||
|
help="axis_sweep/axis_steps 模式下,回中心段保持多久。",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--initial-center-seconds",
|
||||||
|
type=_positive_float("initial-center-seconds"),
|
||||||
|
default=1.0,
|
||||||
|
help="axis_sweep/axis_steps 模式下,开头保持中心点多久,用于让遥操节点锁定原点。",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--trigger",
|
||||||
|
type=float,
|
||||||
|
default=0.0,
|
||||||
|
help="发送的 trigger 值。方向调试默认不驱动夹爪。",
|
||||||
|
)
|
||||||
parser.add_argument("--hand", choices=("left", "right", "both"), default="right")
|
parser.add_argument("--hand", choices=("left", "right", "both"), default="right")
|
||||||
|
parser.add_argument(
|
||||||
|
"--both-mode",
|
||||||
|
choices=("synchronized", "staggered"),
|
||||||
|
default="synchronized",
|
||||||
|
help="hand=both 时 synchronized 左右臂同时动;staggered 先左后右,便于逐只观察。",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
@ -20,37 +200,86 @@ def main() -> None:
|
|||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
|
|
||||||
hands = ("left", "right") if args.hand == "both" else (args.hand,)
|
hands = ("left", "right") if args.hand == "both" else (args.hand,)
|
||||||
|
# 每只手柄独立发送同格式数据包,接收端根据 hand 字段分发左右话题。
|
||||||
packets = {
|
packets = {
|
||||||
hand: {
|
hand: {
|
||||||
"t": 0.0,
|
"t": 0.0,
|
||||||
"hand": hand,
|
"hand": hand,
|
||||||
"grip": False,
|
"grip": False,
|
||||||
"trigger": 0.0,
|
"trigger": 0.0,
|
||||||
"pos": [0.0, 1.0, 0.0],
|
"pos": BASE_POS.copy(),
|
||||||
"quat": [0.0, 0.0, 0.0, 1.0],
|
"quat": [0.0, 0.0, 0.0, 1.0],
|
||||||
}
|
}
|
||||||
for hand in hands
|
for hand in hands
|
||||||
}
|
}
|
||||||
|
pattern_period = _pattern_period(args.pattern, args.hold_seconds, args.center_seconds)
|
||||||
|
one_hand_seconds = args.initial_center_seconds + pattern_period
|
||||||
|
full_sweep_seconds = one_hand_seconds * len(hands) if args.both_mode == "staggered" else one_hand_seconds
|
||||||
|
print(
|
||||||
|
"Sample UDP sender: "
|
||||||
|
f"hand={args.hand}, both_mode={args.both_mode}, pattern={args.pattern}, "
|
||||||
|
f"amplitude={args.amplitude:.3f}m, hold={args.hold_seconds:.2f}s, "
|
||||||
|
f"center={args.center_seconds:.2f}s, seconds={args.seconds:.1f}s",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
if args.pattern != "sine" and args.seconds < full_sweep_seconds:
|
||||||
|
print(
|
||||||
|
"提示:当前 seconds 不足以跑完一轮完整方向序列;"
|
||||||
|
f"建议至少 {full_sweep_seconds:.1f}s。",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
last_labels = {hand: None for hand in hands}
|
||||||
|
|
||||||
while time.monotonic() - t0 < args.seconds:
|
while time.monotonic() - t0 < args.seconds:
|
||||||
t = time.monotonic() - t0
|
t = time.monotonic() - t0
|
||||||
# 模拟按住握持键后缓慢移动手柄,用来测试相对位移控制链路。
|
# 默认用清晰的成对扫轴测试相对位移链路,便于肉眼确认坐标映射。
|
||||||
for index, packet in enumerate(packets.values()):
|
for index, packet in enumerate(packets.values()):
|
||||||
phase = index * math.pi
|
hand = packet["hand"]
|
||||||
|
pattern_t = t
|
||||||
|
if args.hand == "both" and args.both_mode == "staggered":
|
||||||
|
hand_start_t = index * one_hand_seconds
|
||||||
|
hand_end_t = hand_start_t + one_hand_seconds
|
||||||
|
if t < hand_start_t:
|
||||||
|
label, pos = "WAIT / center", BASE_POS.copy()
|
||||||
|
elif t >= hand_end_t:
|
||||||
|
label, pos = "DONE / center", BASE_POS.copy()
|
||||||
|
else:
|
||||||
|
pattern_t = t - hand_start_t
|
||||||
|
label, pos = _pattern_position(
|
||||||
|
args.pattern,
|
||||||
|
pattern_t,
|
||||||
|
args.amplitude,
|
||||||
|
args.hold_seconds,
|
||||||
|
args.center_seconds,
|
||||||
|
args.initial_center_seconds,
|
||||||
|
index * math.pi,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
label, pos = _pattern_position(
|
||||||
|
args.pattern,
|
||||||
|
pattern_t,
|
||||||
|
args.amplitude,
|
||||||
|
args.hold_seconds,
|
||||||
|
args.center_seconds,
|
||||||
|
args.initial_center_seconds,
|
||||||
|
index * math.pi,
|
||||||
|
)
|
||||||
|
|
||||||
|
if label != last_labels[hand]:
|
||||||
|
print(f"[{t:5.2f}s] {hand:<5} {label:<22} pos={pos}", flush=True)
|
||||||
|
last_labels[hand] = label
|
||||||
|
|
||||||
packet["t"] = t
|
packet["t"] = t
|
||||||
packet["grip"] = True
|
packet["grip"] = True
|
||||||
packet["trigger"] = max(0.0, math.sin(2.0 * math.pi * 0.1 * t))
|
packet["trigger"] = min(max(args.trigger, 0.0), 1.0)
|
||||||
packet["pos"] = [
|
packet["pos"] = 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))
|
sock.sendto(json.dumps(packet).encode("utf-8"), (args.host, args.port))
|
||||||
time.sleep(dt)
|
time.sleep(dt)
|
||||||
|
|
||||||
for packet in packets.values():
|
for packet in packets.values():
|
||||||
packet["grip"] = False
|
packet["grip"] = False
|
||||||
packet["trigger"] = 0.0
|
packet["trigger"] = 0.0
|
||||||
|
packet["pos"] = BASE_POS.copy()
|
||||||
sock.sendto(json.dumps(packet).encode("utf-8"), (args.host, args.port))
|
sock.sendto(json.dumps(packet).encode("utf-8"), (args.host, args.port))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
"""XR 手柄 UDP 接收节点。
|
||||||
|
|
||||||
|
从 UDP JSON 数据包中解析左右手柄位姿、握持键和扳机值,并发布为
|
||||||
|
`xr_rm_interfaces/XrController` 消息,供遥操作和夹爪节点订阅。
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import socket
|
import socket
|
||||||
from collections.abc import Iterable, Mapping
|
from collections.abc import Iterable, Mapping
|
||||||
@ -90,6 +96,7 @@ class UdpControllerReceiver(Node):
|
|||||||
|
|
||||||
controllers = payload.get("controllers", payload.get("controller"))
|
controllers = payload.get("controllers", payload.get("controller"))
|
||||||
if isinstance(controllers, Mapping):
|
if isinstance(controllers, Mapping):
|
||||||
|
# 兼容两类格式:controllers 本身就是单个手柄,或按 left/right 分组。
|
||||||
if self._contains_pose(controllers):
|
if self._contains_pose(controllers):
|
||||||
hand = self._normalize_hand(controllers.get("hand", self._default_hand))
|
hand = self._normalize_hand(controllers.get("hand", self._default_hand))
|
||||||
yield hand, controllers
|
yield hand, controllers
|
||||||
@ -143,6 +150,7 @@ class UdpControllerReceiver(Node):
|
|||||||
|
|
||||||
def _extract_pose(self, payload: Mapping[str, Any]) -> tuple[list[float], list[float]]:
|
def _extract_pose(self, payload: Mapping[str, Any]) -> tuple[list[float], list[float]]:
|
||||||
pose = payload.get("pose")
|
pose = payload.get("pose")
|
||||||
|
# 支持多种常见字段命名,方便 Unity/OpenXR/Python 调试脚本接入同一节点。
|
||||||
if isinstance(pose, Mapping):
|
if isinstance(pose, Mapping):
|
||||||
pos = pose.get("position", pose.get("pos", pose.get("p")))
|
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])))
|
quat = pose.get("orientation", pose.get("quat", pose.get("q", [0.0, 0.0, 0.0, 1.0])))
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
"""xr_rm_teleop 包安装配置。
|
||||||
|
|
||||||
|
该包提供基于 XR 相对位移的 RM75 笛卡尔速度遥操作节点,以及把
|
||||||
|
手柄 trigger 映射到夹爪力控命令的桥接节点。
|
||||||
|
"""
|
||||||
|
|
||||||
from setuptools import setup
|
from setuptools import setup
|
||||||
|
|
||||||
package_name = "xr_rm_teleop"
|
package_name = "xr_rm_teleop"
|
||||||
|
|||||||
@ -1 +1,4 @@
|
|||||||
|
"""XR 到 RM75 遥操作节点包。
|
||||||
|
|
||||||
|
核心模块包括单臂速度遥操作、RealMan SDK 适配层和夹爪 trigger 桥接。
|
||||||
|
"""
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
"""手柄 trigger 到夹爪力控指令的桥接节点。
|
||||||
|
|
||||||
|
订阅 XR 手柄消息,将 trigger 压力映射为 OmniPicker 可使用的归一化
|
||||||
|
闭合力比例,并在消息超时或未握持时自动回到开爪指令。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import rclpy
|
import rclpy
|
||||||
@ -62,6 +68,7 @@ class GripperTriggerBridge(Node):
|
|||||||
def _publish_command(self) -> None:
|
def _publish_command(self) -> None:
|
||||||
command = self._open_command
|
command = self._open_command
|
||||||
|
|
||||||
|
# 手柄消息超时后立即退回开爪,避免夹爪在通信中断时保持闭合力。
|
||||||
if self._last_msg is not None and self._last_msg_time is not None:
|
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
|
age = (self.get_clock().now() - self._last_msg_time).nanoseconds * 1e-9
|
||||||
if age <= self._stale_timeout_sec:
|
if age <= self._stale_timeout_sec:
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
|
"""RM75 机械臂适配层。
|
||||||
|
|
||||||
|
对上提供统一的当前位姿读取、笛卡尔速度发送和停止接口;对下根据配置
|
||||||
|
选择 mock 积分模拟器或睿尔曼 Python API2 真机通信。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from numbers import Number
|
from numbers import Number
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -72,6 +79,9 @@ class RealManAdapter:
|
|||||||
initial_joint_pose: list[float] | None = None,
|
initial_joint_pose: list[float] | None = None,
|
||||||
initial_tcp_pose: list[float] | None = None,
|
initial_tcp_pose: list[float] | None = None,
|
||||||
init_move_speed: int = 20,
|
init_move_speed: int = 20,
|
||||||
|
command_mode: str = "velocity",
|
||||||
|
canfd_trajectory_mode: int = 2,
|
||||||
|
canfd_radio: int = 0,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._robot_ip = robot_ip
|
self._robot_ip = robot_ip
|
||||||
self._robot_port = robot_port
|
self._robot_port = robot_port
|
||||||
@ -90,7 +100,12 @@ class RealManAdapter:
|
|||||||
self._initial_joint_pose = initial_joint_pose
|
self._initial_joint_pose = initial_joint_pose
|
||||||
self._initial_tcp_pose = initial_tcp_pose
|
self._initial_tcp_pose = initial_tcp_pose
|
||||||
self._init_move_speed = init_move_speed
|
self._init_move_speed = init_move_speed
|
||||||
|
self._command_mode = command_mode
|
||||||
|
self._canfd_trajectory_mode = canfd_trajectory_mode
|
||||||
|
self._canfd_radio = canfd_radio
|
||||||
self._arm: Any | None = None
|
self._arm: Any | None = None
|
||||||
|
if self._command_mode not in ("velocity", "pose_canfd"):
|
||||||
|
raise ValueError("command_mode must be one of: velocity, pose_canfd")
|
||||||
|
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
try:
|
try:
|
||||||
@ -103,17 +118,21 @@ class RealManAdapter:
|
|||||||
self._arm = RoboticArm(rm_thread_mode_e.RM_TRIPLE_MODE_E)
|
self._arm = RoboticArm(rm_thread_mode_e.RM_TRIPLE_MODE_E)
|
||||||
handle = self._arm.rm_create_robot_arm(self._robot_ip, self._robot_port)
|
handle = self._arm.rm_create_robot_arm(self._robot_ip, self._robot_port)
|
||||||
self._check_robot_handle(handle)
|
self._check_robot_handle(handle)
|
||||||
|
self._log_info(
|
||||||
|
"RealMan connected: "
|
||||||
|
f"ip={self._robot_ip}, port={self._robot_port}, "
|
||||||
|
f"avoid_singularity={self._avoid_singularity}, "
|
||||||
|
f"frame_type={self._frame_type}, dt_ms={self._dt_ms}, "
|
||||||
|
f"command_mode={self._command_mode}"
|
||||||
|
)
|
||||||
if self._configure_safety_limits:
|
if self._configure_safety_limits:
|
||||||
self._apply_safety_limits()
|
self._apply_safety_limits()
|
||||||
if self._move_to_initial_pose_on_connect:
|
if self._move_to_initial_pose_on_connect:
|
||||||
self._move_to_initial_pose()
|
self._move_to_initial_pose()
|
||||||
# 速度透传初始化必须和控制循环周期一致,避免真实机械臂出现周期不稳定。
|
if self._command_mode == "velocity":
|
||||||
ret = self._arm.rm_set_movev_canfd_init(
|
self._init_movev_canfd()
|
||||||
self._avoid_singularity,
|
else:
|
||||||
self._frame_type,
|
self._log_info("command_mode=pose_canfd,跳过 rm_set_movev_canfd_init。")
|
||||||
self._dt_ms,
|
|
||||||
)
|
|
||||||
self._check_return(ret, "rm_set_movev_canfd_init")
|
|
||||||
|
|
||||||
def get_current_pose(self) -> ArmPose:
|
def get_current_pose(self) -> ArmPose:
|
||||||
self._require_arm()
|
self._require_arm()
|
||||||
@ -125,13 +144,31 @@ class RealManAdapter:
|
|||||||
|
|
||||||
def send_cartesian_velocity(self, velocity: list[float], follow: bool) -> None:
|
def send_cartesian_velocity(self, velocity: list[float], follow: bool) -> None:
|
||||||
self._require_arm()
|
self._require_arm()
|
||||||
|
if self._command_mode == "pose_canfd":
|
||||||
|
return
|
||||||
ret = self._arm.rm_movev_canfd(velocity, follow, 0, 0)
|
ret = self._arm.rm_movev_canfd(velocity, follow, 0, 0)
|
||||||
self._check_return(ret, "rm_movev_canfd")
|
self._check_return(ret, "rm_movev_canfd")
|
||||||
|
|
||||||
|
def send_cartesian_target(self, pose: ArmPose, follow: bool) -> None:
|
||||||
|
self._require_arm()
|
||||||
|
if self._command_mode != "pose_canfd":
|
||||||
|
raise RuntimeError("send_cartesian_target requires command_mode=pose_canfd")
|
||||||
|
ret = self._arm.rm_movep_canfd(
|
||||||
|
[pose.x, pose.y, pose.z, pose.rx, pose.ry, pose.rz],
|
||||||
|
follow,
|
||||||
|
self._canfd_trajectory_mode,
|
||||||
|
self._canfd_radio,
|
||||||
|
)
|
||||||
|
self._check_return(ret, "rm_movep_canfd")
|
||||||
|
|
||||||
|
def uses_pose_targets(self) -> bool:
|
||||||
|
return self._command_mode == "pose_canfd"
|
||||||
|
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
if self._arm is None:
|
if self._arm is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
if self._command_mode == "velocity":
|
||||||
self._arm.rm_movev_canfd([0.0] * 6, False, 0, 0)
|
self._arm.rm_movev_canfd([0.0] * 6, False, 0, 0)
|
||||||
self._arm.rm_set_arm_slow_stop()
|
self._arm.rm_set_arm_slow_stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
@ -151,7 +188,8 @@ class RealManAdapter:
|
|||||||
raise RuntimeError("睿尔曼机械臂尚未连接")
|
raise RuntimeError("睿尔曼机械臂尚未连接")
|
||||||
|
|
||||||
def _apply_safety_limits(self) -> None:
|
def _apply_safety_limits(self) -> None:
|
||||||
self._try_call("rm_set_avoid_singularity_mode", True)
|
# 真机安全限幅尽量下发到控制器;不支持的 SDK 接口会在 _try_call 中降级为警告。
|
||||||
|
self._try_call("rm_set_avoid_singularity_mode", int(self._avoid_singularity))
|
||||||
self._try_call("rm_set_arm_max_line_speed", self._max_line_speed)
|
self._try_call("rm_set_arm_max_line_speed", self._max_line_speed)
|
||||||
self._try_call("rm_set_arm_max_angular_speed", self._max_angular_speed)
|
self._try_call("rm_set_arm_max_angular_speed", self._max_angular_speed)
|
||||||
self._try_call("rm_set_arm_max_line_acc", self._max_line_acc)
|
self._try_call("rm_set_arm_max_line_acc", self._max_line_acc)
|
||||||
@ -169,6 +207,46 @@ class RealManAdapter:
|
|||||||
ret = self._arm.rm_movel(self._initial_tcp_pose, self._init_move_speed, 0, 0, 1)
|
ret = self._arm.rm_movel(self._initial_tcp_pose, self._init_move_speed, 0, 0, 1)
|
||||||
self._check_return(ret, "rm_movel(initial_tcp_pose)")
|
self._check_return(ret, "rm_movel(initial_tcp_pose)")
|
||||||
|
|
||||||
|
def _init_movev_canfd(self) -> None:
|
||||||
|
attempts = 3
|
||||||
|
candidates = [self._avoid_singularity]
|
||||||
|
if self._avoid_singularity != 0:
|
||||||
|
candidates.append(0)
|
||||||
|
|
||||||
|
# 某些现场控制器初始化避奇异模式会超时,失败后自动降级到 0 再重试。
|
||||||
|
last_ret: Any = None
|
||||||
|
for avoid_singularity in candidates:
|
||||||
|
if avoid_singularity != self._avoid_singularity:
|
||||||
|
self._log_warn("rm_set_movev_canfd_init 降级为 avoid_singularity=0 后重试。")
|
||||||
|
|
||||||
|
for attempt in range(1, attempts + 1):
|
||||||
|
ret = self._arm.rm_set_movev_canfd_init(
|
||||||
|
avoid_singularity,
|
||||||
|
self._frame_type,
|
||||||
|
self._dt_ms,
|
||||||
|
)
|
||||||
|
last_ret = ret
|
||||||
|
code = self._return_code(ret)
|
||||||
|
if code == 0:
|
||||||
|
self._avoid_singularity = avoid_singularity
|
||||||
|
self._log_info(
|
||||||
|
"rm_set_movev_canfd_init 成功:"
|
||||||
|
f"avoid_singularity={avoid_singularity}, "
|
||||||
|
f"frame_type={self._frame_type}, dt_ms={self._dt_ms}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._log_warn(
|
||||||
|
"rm_set_movev_canfd_init 失败:"
|
||||||
|
f"avoid_singularity={avoid_singularity}, "
|
||||||
|
f"frame_type={self._frame_type}, dt_ms={self._dt_ms}, "
|
||||||
|
f"attempt={attempt}/{attempts}, ret={ret!r}"
|
||||||
|
)
|
||||||
|
if attempt < attempts:
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
self._check_return(last_ret, "rm_set_movev_canfd_init")
|
||||||
|
|
||||||
def _try_call(self, name: str, *args: Any) -> None:
|
def _try_call(self, name: str, *args: Any) -> None:
|
||||||
func = getattr(self._arm, name, None)
|
func = getattr(self._arm, name, None)
|
||||||
if func is None:
|
if func is None:
|
||||||
@ -185,18 +263,28 @@ class RealManAdapter:
|
|||||||
if self._logger is not None:
|
if self._logger is not None:
|
||||||
self._logger.warn(message)
|
self._logger.warn(message)
|
||||||
|
|
||||||
@staticmethod
|
def _log_info(self, message: str) -> None:
|
||||||
def _check_robot_handle(handle: Any) -> None:
|
if self._logger is not None:
|
||||||
|
self._logger.info(message)
|
||||||
|
|
||||||
|
def _check_robot_handle(self, handle: Any) -> None:
|
||||||
handle_id = getattr(handle, "id", None)
|
handle_id = getattr(handle, "id", None)
|
||||||
if handle_id == -1:
|
if handle_id == -1:
|
||||||
raise RuntimeError("rm_create_robot_arm failed: socket error or robot unreachable")
|
raise RuntimeError(
|
||||||
|
"rm_create_robot_arm failed: TCP may be reachable, but the RealMan "
|
||||||
|
f"controller did not return robot info from {self._robot_ip}:{self._robot_port}"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _check_return(ret: Any, name: str) -> None:
|
def _check_return(ret: Any, name: str) -> None:
|
||||||
code = ret[0] if isinstance(ret, tuple) and ret else ret
|
code = RealManAdapter._return_code(ret)
|
||||||
if isinstance(code, int) and code != 0:
|
if isinstance(code, int) and code != 0:
|
||||||
raise RuntimeError(f"{name} failed with code {code}: {ret!r}")
|
raise RuntimeError(f"{name} failed with code {code}: {ret!r}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _return_code(ret: Any) -> Any:
|
||||||
|
return ret[0] if isinstance(ret, tuple) and ret else ret
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _find_pose(cls, obj: Any) -> list[float] | None:
|
def _find_pose(cls, obj: Any) -> list[float] | None:
|
||||||
# 不同 SDK 版本返回字段可能略有差异,因此递归查找常见 TCP 位姿字段。
|
# 不同 SDK 版本返回字段可能略有差异,因此递归查找常见 TCP 位姿字段。
|
||||||
|
|||||||
@ -1,3 +1,9 @@
|
|||||||
|
"""RM75 单臂 XR 相对位移遥操作节点。
|
||||||
|
|
||||||
|
节点订阅左/右手柄位姿,在 grip 按下时锁定手柄和 TCP 起点,把手柄相对位移
|
||||||
|
映射到机器人坐标系中的目标 TCP,并通过笛卡尔速度命令跟随该目标。
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
@ -60,6 +66,9 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
self.declare_parameter("initial_joint_pose", [0.0] * 7)
|
self.declare_parameter("initial_joint_pose", [0.0] * 7)
|
||||||
self.declare_parameter("initial_tcp_pose", [0.35, 0.0, 0.30, 0.0, 0.0, 0.0])
|
self.declare_parameter("initial_tcp_pose", [0.35, 0.0, 0.30, 0.0, 0.0, 0.0])
|
||||||
self.declare_parameter("init_move_speed", 20)
|
self.declare_parameter("init_move_speed", 20)
|
||||||
|
self.declare_parameter("command_mode", "velocity")
|
||||||
|
self.declare_parameter("canfd_trajectory_mode", 2)
|
||||||
|
self.declare_parameter("canfd_radio", 0)
|
||||||
self.declare_parameter("debug_topic_prefix", "/xr_rm")
|
self.declare_parameter("debug_topic_prefix", "/xr_rm")
|
||||||
|
|
||||||
self._arm_name = str(self.get_parameter("arm_name").value)
|
self._arm_name = str(self.get_parameter("arm_name").value)
|
||||||
@ -107,6 +116,7 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
self.get_logger().info(f"{self._arm_name} 速度遥操节点已启动,监听话题:{topic}")
|
self.get_logger().info(f"{self._arm_name} 速度遥操节点已启动,监听话题:{topic}")
|
||||||
|
|
||||||
def _make_adapter(self):
|
def _make_adapter(self):
|
||||||
|
# mock 和真机共享同一控制逻辑,只在适配层切换运动命令的执行方式。
|
||||||
if self._bool_parameter("use_mock"):
|
if self._bool_parameter("use_mock"):
|
||||||
return MockRealManAdapter(
|
return MockRealManAdapter(
|
||||||
[float(v) for v in self.get_parameter("mock_initial_pose").value],
|
[float(v) for v in self.get_parameter("mock_initial_pose").value],
|
||||||
@ -131,6 +141,9 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
initial_joint_pose=self._float_list_parameter("initial_joint_pose", 7),
|
initial_joint_pose=self._float_list_parameter("initial_joint_pose", 7),
|
||||||
initial_tcp_pose=self._float_list_parameter("initial_tcp_pose", 6),
|
initial_tcp_pose=self._float_list_parameter("initial_tcp_pose", 6),
|
||||||
init_move_speed=int(self.get_parameter("init_move_speed").value),
|
init_move_speed=int(self.get_parameter("init_move_speed").value),
|
||||||
|
command_mode=str(self.get_parameter("command_mode").value),
|
||||||
|
canfd_trajectory_mode=int(self.get_parameter("canfd_trajectory_mode").value),
|
||||||
|
canfd_radio=int(self.get_parameter("canfd_radio").value),
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_controller(self, msg: XrController) -> None:
|
def _on_controller(self, msg: XrController) -> None:
|
||||||
@ -206,6 +219,23 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
]
|
]
|
||||||
cartesian_velocity = self._filtered_velocity + [0.0, 0.0, 0.0]
|
cartesian_velocity = self._filtered_velocity + [0.0, 0.0, 0.0]
|
||||||
self._publish_debug(robot_pose, target, cartesian_velocity)
|
self._publish_debug(robot_pose, target, cartesian_velocity)
|
||||||
|
if self._adapter_uses_pose_targets():
|
||||||
|
step_target = [
|
||||||
|
robot_now[i] + cartesian_velocity[i] * self._dt
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
step_target = self._clamp_workspace(step_target)
|
||||||
|
self._send_cartesian_target(
|
||||||
|
ArmPose(
|
||||||
|
x=step_target[0],
|
||||||
|
y=step_target[1],
|
||||||
|
z=step_target[2],
|
||||||
|
rx=robot_pose.rx,
|
||||||
|
ry=robot_pose.ry,
|
||||||
|
rz=robot_pose.rz,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
self._send_cartesian_velocity(cartesian_velocity)
|
self._send_cartesian_velocity(cartesian_velocity)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -221,6 +251,7 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
]
|
]
|
||||||
|
|
||||||
def _clamp_workspace(self, target: list[float]) -> list[float]:
|
def _clamp_workspace(self, target: list[float]) -> list[float]:
|
||||||
|
# 先做盒状工作空间限幅,再叠加底座圆柱半径限制。
|
||||||
clamped = [
|
clamped = [
|
||||||
_clamp(target[i], self._workspace_min[i], self._workspace_max[i])
|
_clamp(target[i], self._workspace_min[i], self._workspace_max[i])
|
||||||
for i in range(3)
|
for i in range(3)
|
||||||
@ -259,6 +290,8 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
def _safe_stop(self) -> None:
|
def _safe_stop(self) -> None:
|
||||||
# 所有断连、松手、退出路径都走这里,确保发送零速度。
|
# 所有断连、松手、退出路径都走这里,确保发送零速度。
|
||||||
self._filtered_velocity = [0.0, 0.0, 0.0]
|
self._filtered_velocity = [0.0, 0.0, 0.0]
|
||||||
|
if self._adapter_uses_pose_targets():
|
||||||
|
return
|
||||||
self._send_cartesian_velocity([0.0] * 6)
|
self._send_cartesian_velocity([0.0] * 6)
|
||||||
|
|
||||||
def _send_cartesian_velocity(self, velocity: list[float]) -> None:
|
def _send_cartesian_velocity(self, velocity: list[float]) -> None:
|
||||||
@ -271,6 +304,20 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
)
|
)
|
||||||
self._active = False
|
self._active = False
|
||||||
|
|
||||||
|
def _send_cartesian_target(self, pose: ArmPose) -> None:
|
||||||
|
try:
|
||||||
|
self._adapter.send_cartesian_target(pose, self._follow)
|
||||||
|
except Exception as exc:
|
||||||
|
self.get_logger().error(
|
||||||
|
f"{self._arm_name} 发送位姿透传命令失败:{exc}",
|
||||||
|
throttle_duration_sec=1.0,
|
||||||
|
)
|
||||||
|
self._active = False
|
||||||
|
|
||||||
|
def _adapter_uses_pose_targets(self) -> bool:
|
||||||
|
uses_pose_targets = getattr(self._adapter, "uses_pose_targets", None)
|
||||||
|
return bool(uses_pose_targets is not None and uses_pose_targets())
|
||||||
|
|
||||||
def _publish_debug(self, current_pose: ArmPose, target_xyz: list[float], velocity: list[float]) -> None:
|
def _publish_debug(self, current_pose: ArmPose, target_xyz: list[float], velocity: list[float]) -> None:
|
||||||
stamp = self.get_clock().now().to_msg()
|
stamp = self.get_clock().now().to_msg()
|
||||||
current_msg = self._pose_msg(stamp, current_pose)
|
current_msg = self._pose_msg(stamp, current_pose)
|
||||||
|
|||||||
Reference in New Issue
Block a user