dual arm control from sample_udp_sender.py

This commit is contained in:
2026-05-25 17:02:37 +08:00
parent 340bd9138d
commit 7551f1e8ea
18 changed files with 976 additions and 74 deletions

View File

@ -23,13 +23,13 @@ left_arm_teleop:
kp_linear: 1.8
deadband_m: 0.002
low_pass_alpha: 0.35
max_linear_speed: 0.04
max_linear_speed: 0.12
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]
workspace_min: [-0.70, -0.60, 0.10]
workspace_max: [0.70, 0.40, 0.70]
cyl_radius_limit: [0.20, 0.60]
low_z_threshold: 0.20
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]
robot_ip: 192.168.192.18
robot_port: 8080
avoid_singularity: 1
avoid_singularity: 0
frame_type: 1
follow: false
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_tcp_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
init_move_speed: 20
command_mode: pose_canfd
canfd_trajectory_mode: 2
canfd_radio: 0
debug_topic_prefix: /xr_rm
right_arm_teleop:
@ -73,7 +76,7 @@ right_arm_teleop:
kp_linear: 1.8
deadband_m: 0.002
low_pass_alpha: 0.35
max_linear_speed: 0.04
max_linear_speed: 0.12
enable_position_axes: [true, true, true]
# 来自 acDual-arm 的 bounds_p[right]

View File

@ -11,11 +11,11 @@ single_arm_velocity_teleop:
kp_linear: 1.8
deadband_m: 0.002
low_pass_alpha: 0.35
max_linear_speed: 0.04
max_linear_speed: 0.2
enable_position_axes: [true, true, true]
workspace_min: [-0.50, -0.60, 0.10]
workspace_max: [0.50, -0.20, 0.50]
workspace_min: [-0.70, -0.60, 0.10]
workspace_max: [0.70, 0.40, 0.70]
cyl_radius_limit: [0.20, 0.60]
low_z_threshold: 0.20
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]
robot_ip: 192.168.192.18
robot_port: 8080
avoid_singularity: 1
avoid_singularity: 0
frame_type: 1
follow: false
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_tcp_pose: [-0.2562, -0.2765, 0.1489, -3.0190, -0.1010, 3.1400]
init_move_speed: 20
command_mode: pose_canfd
canfd_trajectory_mode: 2
canfd_radio: 0
debug_topic_prefix: /xr_rm

View File

@ -11,11 +11,11 @@ single_arm_velocity_teleop:
kp_linear: 1.8
deadband_m: 0.002
low_pass_alpha: 0.35
max_linear_speed: 0.04
max_linear_speed: 0.2
enable_position_axes: [true, true, true]
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]
low_z_threshold: 0.20
low_z_min_radius: 0.21

View File

@ -1,3 +1,10 @@
"""单臂/双臂通用调试入口。
该 launch 文件用于现场调试阶段按需启动左臂、右臂或双臂,并可通过
`use_mock` 在 mock 模式和 RM75 真机模式之间切换。它会固定启动 UDP
手柄接收节点,再根据 `arm:=left|right|both` 选择对应的遥操作节点。
"""
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, OpaqueFunction
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
@ -6,10 +13,12 @@ from launch_ros.substitutions import FindPackageShare
def _as_bool(value: str) -> bool:
"""把 launch 字符串参数转换成 Python bool便于在 OpaqueFunction 中分支。"""
return value.strip().lower() in ("1", "true", "yes", "on")
def _config_file(name: str) -> PathJoinSubstitution:
"""生成 xr_rm_bringup/config 下配置文件的可安装路径。"""
return PathJoinSubstitution([
FindPackageShare("xr_rm_bringup"),
"config",
@ -18,6 +27,7 @@ def _config_file(name: str) -> PathJoinSubstitution:
def _udp_receiver_node() -> Node:
"""接收 PICO/XR UDP 数据,并发布左右手柄 ROS2 话题。"""
return Node(
package="xr_rm_input",
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"
robot_ip = LaunchConfiguration("left_robot_ip" if arm == "left" else "right_robot_ip")
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,
"robot_ip": robot_ip,
"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,
},
],
)
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")
return [
Node(
@ -66,6 +95,9 @@ def _dual_arm_nodes(use_mock: bool, move_to_initial_pose: bool) -> list[Node]:
"use_mock": use_mock,
"robot_ip": LaunchConfiguration("left_robot_ip"),
"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,
},
],
@ -81,6 +113,9 @@ def _dual_arm_nodes(use_mock: bool, move_to_initial_pose: bool) -> list[Node]:
"use_mock": use_mock,
"robot_ip": LaunchConfiguration("right_robot_ip"),
"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,
},
],
@ -89,33 +124,78 @@ def _dual_arm_nodes(use_mock: bool, move_to_initial_pose: bool) -> list[Node]:
def _launch_setup(context, *args, **kwargs):
"""运行时读取 launch 参数,决定启动单臂还是双臂。"""
del args, kwargs
arm = LaunchConfiguration("arm").perform(context).strip().lower()
use_mock = _as_bool(LaunchConfiguration("use_mock").perform(context))
move_to_initial_pose = _as_bool(
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"):
raise ValueError("arm must be one of: left, right, both")
nodes = [_udp_receiver_node()]
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:
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
def generate_launch_description() -> LaunchDescription:
return LaunchDescription([
# 调试目标left/right/both默认右臂方便单臂逐步上真机。
DeclareLaunchArgument("arm", default_value="right"),
# true 时只跑 mock不连接 RM75false 时通过 RealMan SDK 连接真机。
DeclareLaunchArgument("use_mock", default_value="true"),
# UDP 监听参数,需要与 PICO 端或 sample_udp_sender 保持一致。
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
DeclareLaunchArgument("udp_port", default_value="15000"),
# 左右 RM75 默认 IP可在命令行中按现场网络覆盖。
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("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"),
# OpaqueFunction 允许根据 arm/use_mock 等运行时参数动态生成节点。
OpaqueFunction(function=_launch_setup),
])

View File

@ -1,3 +1,10 @@
"""双臂 mock 遥操作启动入口。
该 launch 文件用于不连接真实 RM75 的离线闭环验证:启动 UDP 手柄接收节点,
并分别启动左、右两个 `single_arm_velocity_teleop` 节点。两个遥操作节点使用
同一份双臂配置文件,但通过节点名读取各自的参数命名空间。
"""
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.conditions import IfCondition
@ -7,6 +14,7 @@ from launch_ros.substitutions import FindPackageShare
def generate_launch_description() -> LaunchDescription:
# 通过 robot_config 参数选择 xr_rm_bringup/config 下的双臂配置文件。
config_file = PathJoinSubstitution([
FindPackageShare("xr_rm_bringup"),
"config",
@ -14,9 +22,11 @@ def generate_launch_description() -> LaunchDescription:
])
return LaunchDescription([
# UDP 监听参数,需要和 PICO 端或 sample_udp_sender 的目标地址保持一致。
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
DeclareLaunchArgument("udp_port", default_value="15000"),
DeclareLaunchArgument("robot_config", default_value="dual_arm_rm75.yaml"),
# 这些开关便于单独验证 UDP、左臂或右臂链路。
DeclareLaunchArgument("run_udp", default_value="true"),
DeclareLaunchArgument("run_left_arm", default_value="true"),
DeclareLaunchArgument("run_right_arm", default_value="true"),

View File

@ -1,3 +1,10 @@
"""双 RM75 真机遥操作启动入口。
该 launch 文件用于连接左右两台真实 RM75启动 UDP 手柄接收节点,并分别启动
左、右两个真机遥操作节点。默认不会自动移动到初始点,只有显式设置
`move_to_initial_pose_on_connect:=true` 后才会执行初始化点位移动。
"""
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.conditions import IfCondition
@ -8,6 +15,7 @@ from launch_ros.substitutions import FindPackageShare
def generate_launch_description() -> LaunchDescription:
# 双臂真机默认读取 xr_rm_bringup/config/dual_arm_rm75.yaml。
config_file = PathJoinSubstitution([
FindPackageShare("xr_rm_bringup"),
"config",
@ -15,16 +23,21 @@ def generate_launch_description() -> LaunchDescription:
])
return LaunchDescription([
# UDP 监听参数,需要与 PICO 端发送地址和端口匹配。
DeclareLaunchArgument("udp_host", default_value="0.0.0.0"),
DeclareLaunchArgument("udp_port", default_value="15000"),
# 左右 RM75 的默认现场 IP可在 launch 命令中按实际网络覆盖。
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"),
# 调试时可关闭部分节点,只验证 UDP 或单侧机械臂。
DeclareLaunchArgument("run_udp", default_value="true"),
DeclareLaunchArgument("run_left_arm", default_value="true"),
DeclareLaunchArgument("run_right_arm", default_value="true"),
# 真机默认不自动动臂,确认安全区清空后再手动打开。
DeclareLaunchArgument("move_to_initial_pose_on_connect", default_value="false"),
# 接收 PICO/XR 侧 UDP 数据,并按左右手字段分发到对应手柄话题。
Node(
package="xr_rm_input",
executable="udp_controller_receiver",
@ -38,6 +51,7 @@ def generate_launch_description() -> LaunchDescription:
"right_topic": "/xr/right_controller",
}],
),
# 左臂真机节点:连接 left_robot_ip并读取 left_arm_teleop 参数命名空间。
Node(
package="xr_rm_teleop",
executable="single_arm_velocity_teleop",
@ -57,6 +71,7 @@ def generate_launch_description() -> LaunchDescription:
},
],
),
# 右臂真机节点:连接 right_robot_ip并读取 right_arm_teleop 参数命名空间。
Node(
package="xr_rm_teleop",
executable="single_arm_velocity_teleop",

View File

@ -1,4 +1,10 @@
#!/usr/bin/env python3
"""XR-RM 桌面调试启动器。
提供 Tkinter 图形界面,按“仿真/左臂/右臂/双臂/诊断”组织常用 ROS2
launch、sample_udp_sender、topic 监控和环境检查命令,降低现场调试时的命令输入成本。
"""
from __future__ import annotations
import ctypes
@ -25,6 +31,13 @@ except ImportError as exc:
DEFAULT_LEFT_IP = "192.168.192.18"
DEFAULT_RIGHT_IP = "192.168.192.19"
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 - "
TOPIC_MONITOR_TITLE = "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)]
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:
# 优先使用显式环境变量,便于从安装目录、源码目录或桌面快捷方式启动。
env_workspace = os.environ.get("XR_RM_WS")
if env_workspace:
return Path(env_workspace).expanduser().resolve()
@ -77,6 +105,7 @@ def _find_workspace_root() -> Path:
def _source_lines(workspace_root: Path) -> list[str]:
# 每个新终端都重新 source ROS 和工作空间,避免依赖启动器自身的 shell 环境。
ros_setup = Path("/opt/ros/humble/setup.bash")
install_setup = workspace_root / "install" / "setup.bash"
lines = [
@ -100,11 +129,13 @@ def _source_lines(workspace_root: Path) -> list[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([
f"ros2 launch xr_rm_bringup arm_debug.launch.py arm:={arm} use_mock:=true &",
"launch_pid=$!",
"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 '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.'",
@ -133,22 +164,23 @@ def _finalize_items(
def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
# UI 列表只维护命令模板;真正执行时统一套上工作空间 source 和终端包装。
if mode == "Simulation":
items = [
("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"),
("Dual Arm Mock Launch", "ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=true"),
(
"Sample UDP Sender (Left, 10s)",
f"ros2 run xr_rm_input sample_udp_sender --hand left --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
"Sample UDP Sender (Left, 30s)",
_sample_udp_sender_command("left"),
),
(
"Sample UDP Sender (Right, 10s)",
f"ros2 run xr_rm_input sample_udp_sender --hand right --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
"Sample UDP Sender (Right, 30s)",
_sample_udp_sender_command("right"),
),
(
"Sample UDP Sender (Both, 10s)",
f"ros2 run xr_rm_input sample_udp_sender --hand both --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
"Sample UDP Sender (Both Staggered, 60s)",
_sample_udp_sender_command("both", SAMPLE_SENDER_STAGGERED_SECONDS, "staggered"),
),
("One-Click Left Mock Demo", _one_click_mock("left", "left")),
("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",
),
(
"Sample UDP Sender (Left, 10s)",
f"ros2 run xr_rm_input sample_udp_sender --hand left --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
"Sample UDP Sender (Left, 30s)",
_sample_udp_sender_command("left"),
),
]
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",
),
(
"Sample UDP Sender (Right, 10s)",
f"ros2 run xr_rm_input sample_udp_sender --hand right --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
"Sample UDP Sender (Right, 30s)",
_sample_udp_sender_command("right"),
),
]
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",
),
(
"Sample UDP Sender (Both, 10s)",
f"ros2 run xr_rm_input sample_udp_sender --hand both --host 127.0.0.1 --port {DEFAULT_PORT} --seconds 10",
"Sample UDP Sender (Both Staggered, 60s)",
_sample_udp_sender_command("both", SAMPLE_SENDER_STAGGERED_SECONDS, "staggered"),
),
]
one_click = None
@ -213,6 +245,8 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
class LauncherApp:
"""XR-RM 调试启动器主窗口,负责命令列表、终端启动和监控窗口管理。"""
def __init__(self, root: tk.Tk) -> None:
self.root = root
self.workspace_root = _find_workspace_root()
@ -421,6 +455,7 @@ class LauncherApp:
return result.returncode == 0
def check_prerequisites(self) -> None:
# 环境检查只做轻量探测,避免在 UI 线程里执行耗时的网络或硬件连接动作。
ok: list[str] = []
warnings: list[str] = []
errors: list[str] = []
@ -604,16 +639,72 @@ class LauncherApp:
return Path(script_file.name)
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)
width = min(screen_width - 160, max(960, int(screen_width * 0.75)))
height = min(screen_height - 160, max(560, int(screen_height * 0.66)))
width = max(width, min(screen_width, 900))
height = max(height, min(screen_height, 520))
left = max((screen_width - width) // 2, 0)
top = max((screen_height - height) // 2, 0)
# 监控窗口尽量放在启动器所在显示器,双屏现场调试时更容易找回窗口。
monitor_left, monitor_top, monitor_width, monitor_height = self.active_monitor_rect()
width = min(monitor_width - 120, max(960, int(monitor_width * 0.75)))
height = min(monitor_height - 120, max(560, int(monitor_height * 0.66)))
width = max(width, min(monitor_width, 900))
height = max(height, min(monitor_height, 520))
left = monitor_left + max((monitor_width - width) // 2, 0)
top = monitor_top + max((monitor_height - height) // 2, 0)
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]:
if not shutil.which("xprop"):
return []
@ -641,16 +732,87 @@ class LauncherApp:
window_ids.append(int(xid_text, 16))
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():
return
window_ids = self.window_ids_matching_title(title)
if window_ids:
width, height, left, top = self.topic_monitor_rect()
if not window_ids:
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.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:
lib_path = ctypes.util.find_library("X11")
@ -769,8 +931,8 @@ class LauncherApp:
event_mask = (1 << 19) | (1 << 20) # SubstructureNotifyMask | SubstructureRedirectMask
xlib.XSendEvent(display, root, False, event_mask, ctypes.byref(event))
def write_topic_monitor_config(self) -> Path:
width, height, left, top = self.topic_monitor_rect()
def write_topic_monitor_config(self, target_rect: tuple[int, int, int, int] | None = None) -> Path:
width, height, left, top = target_rect or self.topic_monitor_rect()
left_script, right_script = [
self.write_topic_monitor_script(title, topic)
for title, topic in TOPIC_MONITORS
@ -778,6 +940,8 @@ class LauncherApp:
layout_name = "xr_rm_topic_monitor"
config_text = "\n".join([
"[global_config]",
" geometry_hinting = False",
" window_state = normal",
" suppress_multiple_term_dialog = True",
"[keybindings]",
"[profiles]",
@ -822,8 +986,8 @@ class LauncherApp:
config_file.write(config_text)
return Path(config_file.name)
def write_ros_graph_monitor_config(self) -> Path:
width, height, left, top = self.topic_monitor_rect()
def write_ros_graph_monitor_config(self, target_rect: tuple[int, int, int, int] | None = None) -> Path:
width, height, left, top = target_rect or self.topic_monitor_rect()
topic_script, node_script = [
self.write_ros_graph_monitor_script(title, command)
for title, command in ROS_GRAPH_MONITORS
@ -831,6 +995,8 @@ class LauncherApp:
layout_name = "xr_rm_ros_graph_monitor"
config_text = "\n".join([
"[global_config]",
" geometry_hinting = False",
" window_state = normal",
" suppress_multiple_term_dialog = True",
"[keybindings]",
"[profiles]",
@ -892,7 +1058,8 @@ class LauncherApp:
)
return
config_path = self.write_topic_monitor_config()
target_rect = self.topic_monitor_rect()
config_path = self.write_topic_monitor_config(target_rect)
command = [
terminal,
"--no-dbus",
@ -910,7 +1077,7 @@ class LauncherApp:
stdout=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.")
except Exception as exc:
messagebox.showerror("Topic Monitor Failed", f"Failed to launch topic monitor:\n{exc}")
@ -933,7 +1100,8 @@ class LauncherApp:
)
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 = [
terminal,
"--no-dbus",
@ -951,7 +1119,7 @@ class LauncherApp:
stdout=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.")
except Exception as 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:
return
# 只清理由启动器常用命令创建的 ROS/监控进程,并保护当前 UI 进程。
patterns = [
"ros2 launch xr_rm_bringup",
"ros2 run xr_rm_input",

View 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()