# RM75 Placo 单步 QP 逆解 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 用 Placo 0.9.4 每控制周期执行一次 QP,把左右 RM75 的工具 TCP 目标转换成 7 个关节目标并通过 `rm_movej_canfd` 下发。 **Architecture:** 继续使用现有 `single_arm_velocity_teleop` 节点、`RealManAdapter` 和 `arm_debug.launch.py`。新增一个进程内 `PlacoIkSolver`,从首帧有效关节反馈初始化 `robot.state.q[7:14]`,固定虚拟基座,并在现有 TCP 安全限幅之后执行一步 QP;Adapter 的同一个 SDK 连接同时承担关节反馈、关节指令、安全停止和工具控制。 **Tech Stack:** Ubuntu 22.04、ROS2 Humble、Python 3.10、Placo 0.9.4、Pin 3.7.0、NumPy、睿尔曼 Python API2、pytest。 **Repository rule:** 未获得用户明确授权前不执行 `git commit`、`git push` 或真机命令。所有 Codex 运行验证均使用 `use_mock:=true`;真机步骤只写入检查清单,由用户执行。 --- ## 文件结构 本次只触及以下职责边界: - Create: `xr_rm_teleop/models/rm75/RM75-B.urdf` - Create: `xr_rm_teleop/models/rm75/meshes/base_link.STL` - Create: `xr_rm_teleop/models/rm75/meshes/link_1.STL` - Create: `xr_rm_teleop/models/rm75/meshes/link_2.STL` - Create: `xr_rm_teleop/models/rm75/meshes/link_3.STL` - Create: `xr_rm_teleop/models/rm75/meshes/link_4.STL` - Create: `xr_rm_teleop/models/rm75/meshes/link_5.STL` - Create: `xr_rm_teleop/models/rm75/meshes/link_6.STL` - Create: `xr_rm_teleop/models/rm75/meshes/link_7.STL` - Create: `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py` — Placo 模型、工具变换和一步 QP。 - Create: `xr_rm_teleop/test/test_placo_transforms.py` — 不依赖 Placo 的 TCP/法兰变换测试。 - Create: `xr_rm_teleop/test/test_joint_control.py` — 首帧反馈门控和 last-known-good 测试。 - Create: `xr_rm_teleop/test/placo_ik_smoke.py` — 使用 XR Python 的左右臂 45 周期数值验收。 - Modify: `xr_rm_teleop/xr_rm_teleop/fun_peripheral.py` — 让已加载的配置直接提供所选工具名和位姿。 - Modify: `xr_rm_teleop/xr_rm_teleop/realman_adapter.py` — 用关节反馈缓存和 `rm_movej_canfd` 替换笛卡尔接口。 - Modify: `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py` — 保留现有 TCP 目标生成与安全限幅,接入反馈门控和 QP。 - Modify: `xr_rm_teleop/test/test_initial_joint_pose.py` — 覆盖 SDK 关节反馈与关节透传参数。 - Modify: `xr_rm_teleop/test/test_orientation_control.py` — 删除已移除的 mock 笛卡尔接口测试,其余姿态控制回归测试保持不变。 - Modify: `xr_rm_teleop/setup.py` — 安装 URDF 和网格,不声明或下载 Placo。 - Modify: `xr_rm_bringup/launch/arm_debug.launch.py` — 显式用 XR Python 启动遥操作节点并传入 URDF。 - Modify: `xr_rm_bringup/config/left_arm_rm75.yaml` - Modify: `xr_rm_bringup/config/right_arm_rm75.yaml` - Modify: `xr_rm_bringup/config/dual_arm_rm75.yaml` - Modify: `README.md` — 更新控制链路、环境约束和左右臂独立验收步骤。 不修改 `xr_rm_interfaces`,不新增 ROS 消息,不新增 QP ROS 节点,不修改 `launcher_ui.py` 的进程拓扑。 ### Task 1: 导入 RM75 模型并实现工具变换 **Files:** - Create: `xr_rm_teleop/models/rm75/RM75-B.urdf` - Create: `xr_rm_teleop/models/rm75/meshes/*.STL` - Create: `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py` - Create: `xr_rm_teleop/test/test_placo_transforms.py` - Modify: `xr_rm_teleop/xr_rm_teleop/fun_peripheral.py` - [ ] **Step 1: 从用户上传包复制且只复制模型资源** 从工作空间根目录执行: ```bash cd /home/robot/WS_xr model_tmp_dir=$(mktemp -d /tmp/rm75-model.XXXXXX) unzip -q /home/robot/XRoboToolkit/Resource/IK_qp-class_version.zip \ 'ik_qp/kine_ctrl/urdf_rm75/RM75-B.urdf' \ 'ik_qp/kine_ctrl/urdf_rm75/meshes/*' \ -d "$model_tmp_dir" mkdir -p src/xr_rm_teleop/models/rm75/meshes cp "$model_tmp_dir/ik_qp/kine_ctrl/urdf_rm75/RM75-B.urdf" \ src/xr_rm_teleop/models/rm75/RM75-B.urdf cp "$model_tmp_dir/ik_qp/kine_ctrl/urdf_rm75/meshes/"*.STL \ src/xr_rm_teleop/models/rm75/meshes/ ``` 检查只导入一个 URDF 和八个网格,没有导入上传包的 Pinocchio/OSQP 代码: ```bash find src/xr_rm_teleop/models/rm75 -type f | sort ``` Expected: `RM75-B.urdf`、`base_link.STL`、`link_1.STL` 至 `link_7.STL`,共 9 个文件。 - [ ] **Step 2: 先写 TCP/法兰变换失败测试** 创建 `src/xr_rm_teleop/test/test_placo_transforms.py`: ```python import math import numpy as np import pytest from xr_rm_teleop.placo_ik_solver import ( _arm_pose_to_transform, _tool_pose_to_transform, _transform_to_arm_pose, ) from xr_rm_teleop.realman_adapter import ArmPose def test_tool_offset_rotates_with_flange_and_roundtrips() -> None: flange_pose = ArmPose(0.30, -0.10, 0.20, 0.0, math.pi / 2.0, 0.0) tool_pose = [0.0, 0.0, 0.19, 0.0, 0.0, 0.0, 1.0] base_to_flange = _arm_pose_to_transform(flange_pose) flange_to_tool = _tool_pose_to_transform(tool_pose) base_to_tool = base_to_flange @ flange_to_tool recovered_flange = base_to_tool @ np.linalg.inv(flange_to_tool) assert base_to_tool[:3, 3] == pytest.approx([0.49, -0.10, 0.20]) assert recovered_flange == pytest.approx(base_to_flange) def test_transform_to_arm_pose_roundtrip() -> None: expected = ArmPose(0.25, -0.30, 0.40, 0.20, -0.30, 0.40) actual = _transform_to_arm_pose(_arm_pose_to_transform(expected)) assert actual.xyz() == pytest.approx(expected.xyz()) assert actual.rpy() == pytest.approx(expected.rpy()) ``` - [ ] **Step 3: 运行测试并确认它因模块尚不存在而失败** ```bash cd /home/robot/WS_xr source /opt/ros/humble/setup.bash PYTHONPATH=src/xr_rm_teleop pytest -q \ src/xr_rm_teleop/test/test_placo_transforms.py ``` Expected: FAIL,错误包含 `No module named 'xr_rm_teleop.placo_ik_solver'`。 - [ ] **Step 4: 给外设配置增加所选工具的单一来源** 在 `PeripheralConfig` 中加入: ```python @property def tool_name(self) -> str: return list(self.tools_in_ee)[self.scissorgripper] @property def tool_pose(self) -> list[float]: return list(self.tools_in_ee[self.tool_name][0]) ``` 把 `RealManAdapter.configure_peripheral()` 中原来的工具名选择替换为: ```python tool_name = config.tool_name ``` 工具位姿和控制器工具帧由同一个 `PeripheralConfig` 提供,避免左右臂工具索引在两个位置各自解析。 - [ ] **Step 5: 实现不在系统 Python 导入 Placo 的变换和求解器骨架** 创建 `src/xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`,模块顶层只导入标准库和 NumPy;`placo` 必须在构造函数内导入: ```python """RM75 的 Placo 0.9.4 单步 QP 逆解。""" from __future__ import annotations import math from importlib.metadata import PackageNotFoundError, version from pathlib import Path import numpy as np from .realman_adapter import ArmPose EXPECTED_PLACO_VERSION = "0.9.4" RM75_JOINT_NAMES = [f"joint_{index}" for index in range(1, 8)] RM75_Q_SLICE = slice(7, 14) def _rpy_to_rotation(roll: float, pitch: float, yaw: float) -> np.ndarray: cr, sr = math.cos(roll), math.sin(roll) cp, sp = math.cos(pitch), math.sin(pitch) cy, sy = math.cos(yaw), math.sin(yaw) return np.array( [ [cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr], [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr], [-sp, cp * sr, cp * cr], ], dtype=float, ) def _rotation_to_rpy(rotation: np.ndarray) -> tuple[float, float, float]: pitch = math.asin(-float(np.clip(rotation[2, 0], -1.0, 1.0))) if abs(math.cos(pitch)) > 1e-9: roll = math.atan2(float(rotation[2, 1]), float(rotation[2, 2])) yaw = math.atan2(float(rotation[1, 0]), float(rotation[0, 0])) else: roll = math.atan2(-float(rotation[1, 2]), float(rotation[1, 1])) yaw = 0.0 return roll, pitch, yaw def _arm_pose_to_transform(pose: ArmPose) -> np.ndarray: transform = np.eye(4) transform[:3, :3] = _rpy_to_rotation(pose.rx, pose.ry, pose.rz) transform[:3, 3] = pose.xyz() return transform def _tool_pose_to_transform(tool_pose: list[float]) -> np.ndarray: values = np.asarray(tool_pose, dtype=float) if values.shape != (7,) or not np.isfinite(values).all(): raise ValueError("tool pose must contain 7 finite values") x, y, z, qx, qy, qz, qw = values norm = math.sqrt(qx * qx + qy * qy + qz * qz + qw * qw) if norm <= 1e-9: raise ValueError("tool quaternion norm must be positive") qx, qy, qz, qw = qx / norm, qy / norm, qz / norm, qw / norm transform = np.eye(4) transform[:3, :3] = np.array( [ [1 - 2 * (qy * qy + qz * qz), 2 * (qx * qy - qz * qw), 2 * (qx * qz + qy * qw)], [2 * (qx * qy + qz * qw), 1 - 2 * (qx * qx + qz * qz), 2 * (qy * qz - qx * qw)], [2 * (qx * qz - qy * qw), 2 * (qy * qz + qx * qw), 1 - 2 * (qx * qx + qy * qy)], ] ) transform[:3, 3] = [x, y, z] return transform def _transform_to_arm_pose(transform: np.ndarray) -> ArmPose: roll, pitch, yaw = _rotation_to_rpy(transform[:3, :3]) return ArmPose( float(transform[0, 3]), float(transform[1, 3]), float(transform[2, 3]), roll, pitch, yaw, ) ``` - [ ] **Step 6: 运行纯变换测试** ```bash cd /home/robot/WS_xr source /opt/ros/humble/setup.bash PYTHONPATH=src/xr_rm_teleop pytest -q \ src/xr_rm_teleop/test/test_placo_transforms.py ``` Expected: `2 passed`。系统 Python 不需要也不应能导入 Placo。 - [ ] **Step 7: 检查 Task 1 变更范围** ```bash git diff -- \ src/xr_rm_teleop/xr_rm_teleop/fun_peripheral.py \ src/xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py \ src/xr_rm_teleop/test/test_placo_transforms.py git status --short src/xr_rm_teleop/models/rm75 ``` Expected: 只包含工具选择属性、变换模块、测试和模型资源。不要提交;等待用户明确授权。 ### Task 2: 实现 Placo 0.9.4 单步求解 **Files:** - Modify: `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py` - Create: `xr_rm_teleop/test/placo_ik_smoke.py` - [ ] **Step 1: 写 XR Python 左右臂数值验收脚本** 创建 `src/xr_rm_teleop/test/placo_ik_smoke.py`: ```python from __future__ import annotations import math import sys import time from pathlib import Path import numpy as np from xr_rm_teleop.placo_ik_solver import PlacoIkSolver from xr_rm_teleop.realman_adapter import ArmPose CASES = { "left": ( [-79.55, -9.99, 71.01, 101.45, 95.07, -84.47, -74.52], [0.0, 0.0, 0.19, 0.0, 0.0, 0.0, 1.0], ), "right": ( [-90.14, 3.76, -86.89, 87.89, -96.53, -79.62, -90.04], [0.0, 0.0, 0.16, 0.0, 0.0, 0.0, 1.0], ), } def angle_error(actual: list[float], target: list[float]) -> float: deltas = [ math.atan2(math.sin(a - b), math.cos(a - b)) for a, b in zip(actual, target) ] return math.sqrt(sum(value * value for value in deltas)) def main() -> None: urdf_path = Path(sys.argv[1]).resolve() for arm, (joint_degrees, tool_pose) in CASES.items(): solver = PlacoIkSolver(str(urdf_path), tool_pose, 1.0 / 90.0) joints = np.deg2rad(joint_degrees).tolist() current = solver.update_joint_state(joints) target = ArmPose( current.x + 0.01, current.y, current.z, current.rx, current.ry, current.rz + 0.05, ) solve_durations = [] for _ in range(45): solver.update_joint_state(joints) started_at = time.perf_counter() joints = solver.solve(target) solve_durations.append(time.perf_counter() - started_at) actual = solver.update_joint_state(joints) position_error = np.linalg.norm( np.asarray(actual.xyz()) - np.asarray(target.xyz()) ) orientation_error = angle_error(actual.rpy(), target.rpy()) assert len(joints) == 7 assert np.isfinite(joints).all() assert np.allclose( solver.base_configuration, [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0], ) assert position_error <= 0.005 assert orientation_error <= math.radians(2.0) print( f"{arm}: position_error={position_error:.6f}m, " f"orientation_error={math.degrees(orientation_error):.3f}deg, " f"solve_avg={1000.0 * np.mean(solve_durations):.3f}ms, " f"solve_max={1000.0 * max(solve_durations):.3f}ms, " f"solve_overruns={sum(value > 1.0 / 90.0 for value in solve_durations)}" ) if __name__ == "__main__": main() ``` 该脚本不是 pytest 收集文件;它由已有 XR Python 直接运行,全部断言只使用 Python 和 NumPy,不向该 Conda 环境安装 pytest。 - [ ] **Step 2: 运行脚本并确认求解器类尚不存在** ```bash cd /home/robot/WS_xr PYTHONPATH=src/xr_rm_teleop \ /home/robot/miniconda3/envs/xr/bin/python \ src/xr_rm_teleop/test/placo_ik_smoke.py \ src/xr_rm_teleop/models/rm75/RM75-B.urdf ``` Expected: FAIL,错误包含 `cannot import name 'PlacoIkSolver'`。 - [ ] **Step 3: 实现固定版本、固定基座和 q[7:14] 映射** 在 `placo_ik_solver.py` 追加: ```python class PlacoIkSolver: def __init__( self, urdf_path: str, tool_pose: list[float], dt: float, ) -> None: if dt <= 0.0: raise ValueError("dt must be positive") try: installed_version = version("placo") import placo except (ImportError, PackageNotFoundError) as exc: raise RuntimeError( "Placo 0.9.4 must come from " "/home/robot/miniconda3/envs/xr" ) from exc if installed_version != EXPECTED_PLACO_VERSION: raise RuntimeError( f"Placo {EXPECTED_PLACO_VERSION} is required, got {installed_version}" ) model_path = Path(urdf_path).expanduser().resolve() if not model_path.is_file(): raise FileNotFoundError(f"RM75 URDF not found: {model_path}") self._dt = dt self._robot = placo.RobotWrapper(str(model_path)) if self._robot.state.q.shape != (14,): raise RuntimeError( f"expected Placo q shape (14,), got {self._robot.state.q.shape}" ) if list(self._robot.joint_names()) != RM75_JOINT_NAMES: raise RuntimeError( f"unexpected RM75 joint order: {list(self._robot.joint_names())}" ) offsets = [ self._robot.get_joint_offset(name) for name in RM75_JOINT_NAMES ] if offsets != list(range(7, 14)): raise RuntimeError(f"unexpected RM75 q offsets: {offsets}") self._joint_limits = np.asarray( [self._robot.get_joint_limits(name) for name in RM75_JOINT_NAMES] ) velocity_offsets = [ self._robot.get_joint_v_offset(name) for name in RM75_JOINT_NAMES ] self._velocity_limits = np.asarray( [self._robot.model.velocityLimit[index] for index in velocity_offsets] ) self._tool_transform = _tool_pose_to_transform(tool_pose) self._tool_inverse = np.linalg.inv(self._tool_transform) self._actual_joints: np.ndarray | None = None self._solver = placo.KinematicsSolver(self._robot) self._solver.dt = dt self._solver.mask_fbase(True) self._solver.enable_velocity_limits(True) self._frame_task = self._solver.add_frame_task("link_7", np.eye(4)) self._frame_task.configure("rm75_frame", "soft", 1.0) manipulability = self._solver.add_manipulability_task( "link_7", "both", 1.0 ) manipulability.configure("rm75_manipulability", "soft", 5e-2) self._solver.add_kinetic_energy_regularization_task(1e-6) @property def base_configuration(self) -> list[float]: return self._robot.state.q[:7].tolist() def update_joint_state(self, joints: list[float]) -> ArmPose: values = np.asarray(joints, dtype=float) if values.shape != (7,) or not np.isfinite(values).all(): raise ValueError("joint state must contain 7 finite values") is_first_feedback = self._actual_joints is None self._actual_joints = values.copy() self._robot.state.q[RM75_Q_SLICE] = values self._robot.update_kinematics() base_to_flange = self._robot.get_T_world_frame("link_7") if is_first_feedback: self._frame_task.T_world_frame = base_to_flange.copy() base_to_tool = base_to_flange @ self._tool_transform return _transform_to_arm_pose(base_to_tool) def solve(self, target_tool_pose: ArmPose) -> list[float]: if self._actual_joints is None: raise RuntimeError("joint state must be initialized before QP solve") self._frame_task.T_world_frame = ( _arm_pose_to_transform(target_tool_pose) @ self._tool_inverse ) self._solver.solve(True) result = np.asarray( self._robot.state.q[RM75_Q_SLICE], dtype=float, ).copy() self._validate_result(result) return result.tolist() def _validate_result(self, result: np.ndarray) -> None: if result.shape != (7,) or not np.isfinite(result).all(): raise ValueError("QP result must contain 7 finite values") lower = self._joint_limits[:, 0] upper = self._joint_limits[:, 1] if np.any(result < lower - 1e-9) or np.any(result > upper + 1e-9): raise ValueError("QP result violates RM75 joint position limits") max_step = self._velocity_limits * self._dt + 1e-9 if np.any(np.abs(result - self._actual_joints) > max_step): raise ValueError("QP result violates RM75 one-cycle velocity limits") ``` 不要调用 neutral 全零求解。构造函数只建立任务;第一次 `solve()` 必须发生在 `update_joint_state()` 之后。 - [ ] **Step 4: 增加求解结果校验测试** 在 `test_placo_transforms.py` 的 solver import 中加入 `PlacoIkSolver`,并追加: ```python def test_qp_result_rejects_nan_position_and_velocity_violations() -> None: solver = object.__new__(PlacoIkSolver) solver._joint_limits = np.asarray([[-1.0, 1.0]] * 7) solver._velocity_limits = np.ones(7) solver._dt = 0.1 solver._actual_joints = np.zeros(7) with pytest.raises(ValueError, match="finite"): solver._validate_result(np.full(7, np.nan)) with pytest.raises(ValueError, match="position"): solver._validate_result(np.full(7, 2.0)) with pytest.raises(ValueError, match="velocity"): solver._validate_result(np.full(7, 0.2)) ``` 运行: ```bash cd /home/robot/WS_xr source /opt/ros/humble/setup.bash PYTHONPATH=src/xr_rm_teleop pytest -q \ src/xr_rm_teleop/test/test_placo_transforms.py ``` Expected: `3 passed`,且系统 Python 没有导入 Placo。 - [ ] **Step 5: 运行左右臂 45 周期 QP 验收** ```bash cd /home/robot/WS_xr PYTHONPATH=src/xr_rm_teleop \ /home/robot/miniconda3/envs/xr/bin/python \ src/xr_rm_teleop/test/placo_ik_smoke.py \ src/xr_rm_teleop/models/rm75/RM75-B.urdf ``` Expected: ```text left: position_error=<不大于0.005>m, orientation_error=<不大于2.0>deg right: position_error=<不大于0.005>m, orientation_error=<不大于2.0>deg ``` 允许 Placo 打印 neutral 自碰撞警告;不得出现 QP `NaN`,基座必须保持 `[0, 0, 0, 0, 0, 0, 1]`。 - [ ] **Step 6: 检查 Task 2 变更,不提交** ```bash git diff -- \ src/xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py \ src/xr_rm_teleop/test/placo_ik_smoke.py ``` Expected: 一个求解器类和一个左右臂数值验收脚本,不包含 Placo 安装命令、可视化、OSQP 或 XRoboToolkit import。 ### Task 3: 把 Adapter 改成单连接关节反馈与关节透传 **Files:** - Modify: `xr_rm_teleop/xr_rm_teleop/realman_adapter.py` - Modify: `xr_rm_teleop/test/test_initial_joint_pose.py` - Modify: `xr_rm_teleop/test/test_orientation_control.py` - [ ] **Step 1: 先写 SDK 关节边界失败测试** 在 `test_initial_joint_pose.py` 保留现有初始化 `rm_movej` 测试,并追加: ```python import math import pytest from xr_rm_teleop.realman_adapter import MockRealManAdapter def test_joint_feedback_is_cached_in_radians() -> None: class FakeArm: def rm_get_joint_degree(self): return 0, [0.0, 10.0, -20.0, 30.0, -40.0, 50.0, -60.0] adapter = RealManAdapter("127.0.0.1", 8080, 0, 0.01) adapter._arm = FakeArm() adapter._read_joint_state_once() snapshot = adapter.get_latest_joint_state() assert snapshot is not None assert snapshot.positions == pytest.approx( [math.radians(value) for value in [0, 10, -20, 30, -40, 50, -60]] ) def test_joint_target_uses_movej_canfd_in_degrees() -> None: class FakeArm: def __init__(self) -> None: self.calls = [] def rm_movej_canfd(self, *args): self.calls.append(args) return 0 adapter = RealManAdapter("127.0.0.1", 8080, 0, 0.01) adapter._arm = FakeArm() target = [math.radians(value) for value in [1, 2, 3, 4, 5, 6, 7]] adapter.send_joint_target(target, follow=False) assert adapter._arm.calls == [ ([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0], False, 0, 2, 0) ] def test_mock_joint_feedback_is_available_without_vendor_sdk() -> None: adapter = MockRealManAdapter([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]) adapter.connect() snapshot = adapter.get_latest_joint_state() assert snapshot is not None assert snapshot.positions == pytest.approx( [math.radians(value) for value in [1, 2, 3, 4, 5, 6, 7]] ) ``` 从 `test_orientation_control.py` 删除 `test_mock_adapter_uses_shortest_angular_velocity()`,并删除不再使用的 `MockRealManAdapter` import。其余姿态测试不改。 - [ ] **Step 2: 运行测试并确认旧 Adapter API 不满足测试** ```bash cd /home/robot/WS_xr source /opt/ros/humble/setup.bash PYTHONPATH=src/xr_rm_teleop pytest -q \ src/xr_rm_teleop/test/test_initial_joint_pose.py ``` Expected: FAIL,至少包含 `get_latest_joint_state`、`send_joint_target` 或构造参数不匹配。 - [ ] **Step 3: 用线程安全反馈快照替换笛卡尔 mock** 在 `realman_adapter.py`: ```python import threading import time ``` 加入: ```python @dataclass(frozen=True) class JointStateSnapshot: positions: list[float] received_at: float ``` 将 `MockRealManAdapter` 替换成: ```python class MockRealManAdapter: def __init__(self, initial_joint_degrees: list[float]) -> None: if len(initial_joint_degrees) != 7: raise ValueError("initial joint pose must contain 7 values") self._joint_positions = [ math.radians(value) for value in initial_joint_degrees ] self.last_joint_target: list[float] | None = None self.last_tool_open: bool | None = None def connect(self) -> None: return def get_latest_joint_state(self) -> JointStateSnapshot: return JointStateSnapshot( list(self._joint_positions), time.monotonic(), ) def send_joint_target(self, joints: list[float], follow: bool) -> None: del follow if len(joints) != 7 or not all(math.isfinite(value) for value in joints): raise ValueError("joint target must contain 7 finite values") self._joint_positions = list(joints) self.last_joint_target = list(joints) def stop(self) -> None: return def close(self) -> None: self.stop() def configure_peripheral(self, config, peripheral_arm: str) -> None: del config, peripheral_arm def set_tool_enabled(self, open_tool: bool) -> None: self.last_tool_open = open_tool ``` 删除 Adapter 内不再使用的 `_angle_delta()` 和笛卡尔发送/读取方法;保留 `ArmPose`,因为遥操作和 Placo 仍用它表示工具 TCP。`Number` 继续用于 SDK 反馈数值校验。 - [ ] **Step 4: 在 RealManAdapter 的同一连接上加入反馈缓存** 把构造参数中的 `frame_type` 替换为: ```python feedback_period: float, ``` 在构造函数末尾加入: ```python if feedback_period <= 0.0: raise ValueError("feedback_period must be positive") self._feedback_period = feedback_period self._joint_state_lock = threading.Lock() self._latest_joint_state: JointStateSnapshot | None = None self._feedback_stop = threading.Event() self._feedback_thread: threading.Thread | None = None self._feedback_fault_logged = False ``` 连接成功日志中的命令改成 `rm_movej_canfd`,在安全配置和可选初始移动之后启动反馈线程: ```python self._feedback_stop.clear() self._feedback_thread = threading.Thread( target=self._feedback_loop, name=f"rm75_feedback_{self._robot_ip}", daemon=True, ) self._feedback_thread.start() ``` 加入以下方法: ```python def _feedback_loop(self) -> None: while not self._feedback_stop.is_set(): try: self._read_joint_state_once() self._feedback_fault_logged = False except Exception as exc: if not self._feedback_fault_logged: self._log_warn(f"RealMan 关节反馈读取失败:{exc}") self._feedback_fault_logged = True self._feedback_stop.wait(self._feedback_period) def _read_joint_state_once(self) -> None: self._require_arm() result = self._arm.rm_get_joint_degree() self._check_return(result, "rm_get_joint_degree") if not isinstance(result, tuple) or len(result) < 2: raise RuntimeError(f"rm_get_joint_degree 返回格式错误:{result!r}") degrees = result[1] if ( not isinstance(degrees, (list, tuple)) or len(degrees) != 7 or not all(isinstance(value, Number) for value in degrees) ): raise RuntimeError(f"RM75 关节反馈必须包含 7 个数值:{degrees!r}") positions = [math.radians(float(value)) for value in degrees] if not all(math.isfinite(value) for value in positions): raise RuntimeError("RM75 关节反馈包含 NaN/Inf") snapshot = JointStateSnapshot(positions, time.monotonic()) with self._joint_state_lock: self._latest_joint_state = snapshot def get_latest_joint_state(self) -> JointStateSnapshot | None: with self._joint_state_lock: if self._latest_joint_state is None: return None return JointStateSnapshot( list(self._latest_joint_state.positions), self._latest_joint_state.received_at, ) def send_joint_target(self, joints: list[float], follow: bool) -> None: self._require_arm() if len(joints) != 7 or not all(math.isfinite(value) for value in joints): raise ValueError("joint target must contain 7 finite values") degrees = [math.degrees(value) for value in joints] ret = self._arm.rm_movej_canfd( degrees, follow, 0, self._canfd_trajectory_mode, self._canfd_radio, ) self._check_return(ret, "rm_movej_canfd") ``` `Number` 仍用于 SDK 反馈校验,因此保留 `from numbers import Number`。 - [ ] **Step 5: 让工具配置复用已加载的 PeripheralConfig** 把真机和 mock 的签名统一为: ```python def configure_peripheral(self, config, peripheral_arm: str) -> None: ``` 真机实现不再读取 YAML,直接使用: ```python self._scissorgripper = config.scissorgripper tool_name = config.tool_name peripheral_cfg( self._arm, config.scissorgripper, config.tools_in_ee, set_initial_tool_state=config.set_initial_tool_state, ) ``` 这样 Placo 与控制器工具帧使用同一次 YAML 解析结果。 - [ ] **Step 6: 安全停止后结束反馈线程,再删除唯一连接** 将 `close()` 改为: ```python def close(self) -> None: if self._arm is None: return self.stop() self._feedback_stop.set() if self._feedback_thread is not None: self._feedback_thread.join(timeout=3.0) if self._feedback_thread.is_alive(): self._log_warn("RealMan 关节反馈线程未在 3 秒内退出。") self._feedback_thread = None try: self._arm.rm_delete_robot_arm() finally: self._arm = None ``` 不得创建第二个 `RoboticArm` 或再次调用 `rm_create_robot_arm`。 - [ ] **Step 7: 运行 Adapter 与姿态回归测试** ```bash cd /home/robot/WS_xr source /opt/ros/humble/setup.bash PYTHONPATH=src/xr_rm_teleop pytest -q \ src/xr_rm_teleop/test/test_initial_joint_pose.py \ src/xr_rm_teleop/test/test_orientation_control.py ``` Expected: 全部 PASS;mock 测试过程中不需要 `Robotic_Arm`。 - [ ] **Step 8: 静态确认只有一个连接创建点** ```bash rg -n "rm_create_robot_arm" src/xr_rm_teleop ``` Expected: 只有 `realman_adapter.py` 的 `connect()` 中一处调用。 ### Task 4: 在单臂节点中加入首帧反馈门控和 XR 失败策略 **Files:** - Create: `xr_rm_teleop/test/test_joint_control.py` - Modify: `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py` - [ ] **Step 1: 写首帧门控和 last-known-good 失败测试** 创建 `src/xr_rm_teleop/test/test_joint_control.py`: ```python import time from types import SimpleNamespace import pytest from xr_rm_teleop.realman_adapter import ArmPose, JointStateSnapshot from xr_rm_teleop.single_arm_velocity_teleop import SingleArmVelocityTeleop class FakeLogger: def warn(self, *args, **kwargs): del args, kwargs def test_missing_or_stale_feedback_does_not_enable_qp() -> None: teleop = object.__new__(SingleArmVelocityTeleop) teleop._command_timeout_sec = 0.12 teleop._adapter = SimpleNamespace(get_latest_joint_state=lambda: None) assert teleop._fresh_joint_state() is None teleop._adapter = SimpleNamespace( get_latest_joint_state=lambda: JointStateSnapshot( [0.0] * 7, time.monotonic() - 1.0, ) ) assert teleop._fresh_joint_state() is None def test_stale_feedback_stops_before_qp_solve() -> None: class SolverThatMustNotRun: def solve(self, target): del target raise AssertionError("QP must not run with stale feedback") stopped = [] teleop = object.__new__(SingleArmVelocityTeleop) teleop._adapter = SimpleNamespace( get_latest_joint_state=lambda: JointStateSnapshot( [0.0] * 7, time.monotonic() - 1.0, ) ) teleop._ik_solver = SolverThatMustNotRun() teleop._command_timeout_sec = 0.12 teleop._joint_feedback_ready = True teleop._arm_name = "right_rm75" teleop.get_clock = lambda: SimpleNamespace(now=lambda: object()) teleop.get_logger = lambda: FakeLogger() teleop._safe_stop = lambda reset_active: stopped.append(reset_active) teleop._control_tick() assert stopped == [True] def test_first_feedback_initializes_last_valid_target_without_solving() -> None: class FakeSolver: def __init__(self) -> None: self.solve_calls = 0 def update_joint_state(self, joints): assert joints == [0.1] * 7 return ArmPose(0.3, 0.0, 0.2) def solve(self, target): del target self.solve_calls += 1 return [0.2] * 7 teleop = object.__new__(SingleArmVelocityTeleop) teleop._ik_solver = FakeSolver() teleop._active = False teleop._last_valid_joint_target = None teleop._last_current_pose = None pose = teleop._sync_joint_feedback( JointStateSnapshot([0.1] * 7, time.monotonic()) ) assert pose == ArmPose(0.3, 0.0, 0.2) assert teleop._last_valid_joint_target == [0.1] * 7 assert teleop._ik_solver.solve_calls == 0 def test_qp_failure_returns_last_known_good_target() -> None: class FailingSolver: def solve(self, target): del target raise RuntimeError("NaN in QP solution") teleop = object.__new__(SingleArmVelocityTeleop) teleop._ik_solver = FailingSolver() teleop._last_valid_joint_target = [0.1] * 7 teleop._arm_name = "right_rm75" teleop.get_logger = lambda: FakeLogger() target = teleop._solve_joint_target(ArmPose(0.3, 0.0, 0.2)) assert target == pytest.approx([0.1] * 7) assert teleop._last_valid_joint_target == pytest.approx([0.1] * 7) def test_qp_success_updates_last_known_good_target() -> None: class SuccessfulSolver: def solve(self, target): del target return [0.2] * 7 teleop = object.__new__(SingleArmVelocityTeleop) teleop._ik_solver = SuccessfulSolver() teleop._last_valid_joint_target = [0.1] * 7 teleop._arm_name = "left_rm75" teleop.get_logger = lambda: FakeLogger() target = teleop._solve_joint_target(ArmPose(0.3, 0.0, 0.2)) assert target == pytest.approx([0.2] * 7) assert teleop._last_valid_joint_target == pytest.approx([0.2] * 7) ``` - [ ] **Step 2: 运行测试并确认节点尚无这些方法** ```bash cd /home/robot/WS_xr source /opt/ros/humble/setup.bash PYTHONPATH=src/xr_rm_teleop pytest -q \ src/xr_rm_teleop/test/test_joint_control.py ``` Expected: FAIL,错误包含 `_fresh_joint_state`、`_sync_joint_feedback` 或 `_solve_joint_target` 不存在。 - [ ] **Step 3: 让节点启动时先解析工具并创建 Placo,再连接 Adapter** 在 import 中加入: ```python import time from .fun_peripheral import load_peripheral_config from .placo_ik_solver import PlacoIkSolver from .realman_adapter import ( ArmPose, JointStateSnapshot, MockRealManAdapter, RealManAdapter, ) ``` 声明新参数: ```python self.declare_parameter("robot_urdf_path", "") ``` 删除已失效的参数声明和成员读取: ```text current_pose_poll_hz mock_initial_pose frame_type ``` 初始化字段加入: ```python self._last_valid_joint_target: list[float] | None = None self._joint_feedback_ready = False ``` 把 Adapter 创建顺序替换为: ```python peripheral_arm = self._peripheral_arm_name() config_file = str(self.get_parameter("peripheral_config_file").value) self._peripheral_config = load_peripheral_config( config_file, peripheral_arm, ) self._ik_solver = PlacoIkSolver( str(self.get_parameter("robot_urdf_path").value), self._peripheral_config.tool_pose, self._dt, ) self._adapter = self._make_adapter() self._adapter.connect() self._setup_tool_control() ``` 这样 Placo 版本、URDF、关节映射和工具位姿在建立真机连接前完成校验。 - [ ] **Step 4: 让 mock 和真机 Adapter 都使用初始关节配置** 将 `_make_adapter()` 改为: ```python def _make_adapter(self): initial_joint_pose = self._float_list_parameter( "initial_joint_pose", 7, ) if self._bool_parameter("use_mock"): return MockRealManAdapter(initial_joint_pose) return RealManAdapter( robot_ip=self.get_parameter("robot_ip").value, robot_port=int(self.get_parameter("robot_port").value), avoid_singularity=int( self.get_parameter("avoid_singularity").value ), feedback_period=self._dt, logger=self.get_logger(), configure_safety_limits=self._bool_parameter( "configure_safety_limits" ), max_line_speed=float(self.get_parameter("max_line_speed").value), max_angular_speed=float( self.get_parameter("max_angular_speed").value ), max_line_acc=float(self.get_parameter("max_line_acc").value), max_angular_acc=float( self.get_parameter("max_angular_acc").value ), joint_max_speed=float( self.get_parameter("joint_max_speed").value ), joint_max_acc=float(self.get_parameter("joint_max_acc").value), move_to_initial_pose_on_connect=self._bool_parameter( "move_to_initial_pose_on_connect" ), initial_joint_pose=initial_joint_pose, init_move_speed=int(self.get_parameter("init_move_speed").value), canfd_trajectory_mode=int( self.get_parameter("canfd_trajectory_mode").value ), canfd_radio=int(self.get_parameter("canfd_radio").value), ) ``` - [ ] **Step 5: 工具坐标配置与工具开合解耦** 在 `_setup_tool_control()` 开头先执行控制器工具配置: ```python peripheral_arm = self._peripheral_arm_name() if self._bool_parameter("configure_peripheral_on_connect"): self._adapter.configure_peripheral( self._peripheral_config, peripheral_arm, ) ``` 然后再判断: ```python if not self._enable_tool_control: ``` 删除该函数中重复读取 `peripheral_config_file` 和重复调用旧 `configure_peripheral(config_file, peripheral_arm)` 的代码。 - [ ] **Step 6: 实现反馈时效、首帧初始化和 QP 失败回退** 在节点类中加入: ```python def _fresh_joint_state(self) -> JointStateSnapshot | None: snapshot = self._adapter.get_latest_joint_state() if snapshot is None: return None age = time.monotonic() - snapshot.received_at if age < 0.0 or age > self._command_timeout_sec: return None if ( len(snapshot.positions) != 7 or not all(math.isfinite(value) for value in snapshot.positions) ): return None return snapshot def _sync_joint_feedback( self, snapshot: JointStateSnapshot, ) -> ArmPose: current_pose = self._ik_solver.update_joint_state( snapshot.positions ) self._last_current_pose = current_pose if not self._active or self._last_valid_joint_target is None: self._last_valid_joint_target = list(snapshot.positions) return current_pose def _solve_joint_target(self, target_pose: ArmPose) -> list[float]: if self._last_valid_joint_target is None: raise RuntimeError("valid joint feedback has not been initialized") try: result = self._ik_solver.solve(target_pose) except Exception as exc: self.get_logger().warn( f"{self._arm_name} QP 求解失败,保持上一组关节目标:{exc}", throttle_duration_sec=1.0, ) return list(self._last_valid_joint_target) self._last_valid_joint_target = list(result) return list(result) def _send_joint_target(self, joints: list[float]) -> bool: try: self._adapter.send_joint_target(joints, self._follow) except Exception as exc: self.get_logger().error( f"{self._arm_name} 发送关节透传命令失败:{exc}", throttle_duration_sec=1.0, ) self._safe_stop(reset_active=True) return False return True ``` - [ ] **Step 7: 在每个控制周期最前面执行反馈门控** `_control_tick()` 在读取 XR 消息前加入: ```python snapshot = self._fresh_joint_state() if snapshot is None: if self._joint_feedback_ready: self.get_logger().warn( f"{self._arm_name} 关节反馈缺失或过期,机械臂停止。", throttle_duration_sec=1.0, ) self._joint_feedback_ready = False self._safe_stop(reset_active=True) return try: current_pose = self._sync_joint_feedback(snapshot) except Exception as exc: self.get_logger().error( f"{self._arm_name} 关节反馈同步到 Placo 失败:{exc}", throttle_duration_sec=1.0, ) self._joint_feedback_ready = False self._safe_stop(reset_active=True) return if not self._joint_feedback_ready: self.get_logger().info( f"{self._arm_name} 已收到首帧有效关节反馈,QP 可以启用。" ) self._joint_feedback_ready = True ``` 当 grip 首次激活时,把当前 `current_pose` 传给 `_enter_active_control()`;该函数不再调用 Adapter 读取 TCP: ```python self._enter_active_control( controller_now, controller_quat, current_pose, now, ) ``` 函数签名改为: ```python def _enter_active_control( self, controller_now: list[float], controller_quat: tuple[float, float, float, float], robot_pose: ArmPose, now: Time, ) -> None: ``` 删除该函数原来的 `try: robot_pose = self._read_current_pose_for_control(now)` 块。 - [ ] **Step 8: 用一步 QP 和关节透传替换笛卡尔下发** 删除 `_maybe_refresh_current_pose(now)` 调用。保留从 `raw_target_xyz` 到 `target_pose` 的全部死区、滤波、工作空间/圆柱、线速度和角速度限制。 把: ```python if self._send_cartesian_target(target_pose): ``` 替换为: ```python joint_target = self._solve_joint_target(target_pose) if self._send_joint_target(joint_target): ``` 删除以下旧方法: ```text _read_current_pose_for_control _maybe_refresh_current_pose _send_cartesian_target ``` 每个有效且激活的控制周期只能有一次 `self._ik_solver.solve(target_pose)`。 - [ ] **Step 9: 运行节点行为和姿态回归测试** ```bash cd /home/robot/WS_xr source /opt/ros/humble/setup.bash PYTHONPATH=src/xr_rm_teleop pytest -q \ src/xr_rm_teleop/test/test_joint_control.py \ src/xr_rm_teleop/test/test_orientation_control.py ``` Expected: 全部 PASS。 - [ ] **Step 10: 静态确认旧笛卡尔运动链路已移除** ```bash rg -n "rm_movep_canfd|send_cartesian_target|get_current_pose" \ src/xr_rm_teleop/xr_rm_teleop ``` Expected: 遥操作包的运行代码中无匹配。 ### Task 5: 固定 XR Python 启动并安装模型资源 **Files:** - Modify: `xr_rm_teleop/setup.py` - Modify: `xr_rm_bringup/launch/arm_debug.launch.py` - Modify: `xr_rm_bringup/config/left_arm_rm75.yaml` - Modify: `xr_rm_bringup/config/right_arm_rm75.yaml` - Modify: `xr_rm_bringup/config/dual_arm_rm75.yaml` - [ ] **Step 1: 在 setup.py 中只安装模型,不声明 pip Placo 依赖** 加入: ```python from glob import glob ``` 把 `data_files` 扩展为: ```python data_files=[ ( "share/ament_index/resource_index/packages", [f"resource/{package_name}"], ), (f"share/{package_name}", ["package.xml"]), ( f"share/{package_name}/models/rm75", ["models/rm75/RM75-B.urdf"], ), ( f"share/{package_name}/models/rm75/meshes", glob("models/rm75/meshes/*.STL"), ), ], ``` 保持: ```python install_requires=["setuptools"], ``` 不得加入 `placo`、`pin`、`eigenpy` 或 `numpy`,以免 colcon 使用系统 Python 时触发安装或升级。 - [ ] **Step 2: launch 固定并验证 XR Python** 在 `arm_debug.launch.py` 加入: ```python from pathlib import Path ``` 定义: ```python XR_PYTHON = "/home/robot/miniconda3/envs/xr/bin/python" ``` 在 `_launch_setup()` 最前面校验: ```python if not Path(XR_PYTHON).is_file(): raise RuntimeError( f"XR Python not found: {XR_PYTHON}; " "Placo 0.9.4 must not be installed globally" ) ``` 给三个 `single_arm_velocity_teleop` Node 对象都加入: ```python prefix=[XR_PYTHON], ``` 不要给 `udp_controller_receiver` 加 prefix;它继续使用系统 ROS2 Python。 - [ ] **Step 3: launch 向每个遥操作节点传安装后的 URDF** 给三个遥操作 `Node` 的参数字典都加入: ```python "robot_urdf_path": PathJoinSubstitution([ FindPackageShare("xr_rm_teleop"), "models", "rm75", "RM75-B.urdf", ]), ``` 删除 launch 中已经失效的 `frame_type` 参数传递、函数参数和对应的 `DeclareLaunchArgument`。 将控制周期注释更新为: ```python # 每周期同步一次实际关节反馈、执行一次 Placo QP,再发送 rm_movej_canfd。 ``` - [ ] **Step 4: 同步左、右、双臂 YAML** 三个 YAML 都删除: ```text current_pose_poll_hz mock_initial_pose frame_type ``` 保留每侧原有: ```text initial_joint_pose workspace_min / workspace_max cyl_radius_limit max_linear_speed / max_orientation_speed configure_safety_limits: true ``` 把左右单臂 YAML 都改为: ```yaml move_to_initial_pose_on_connect: false ``` 双臂文件原有左右两个 `false` 保持不变。把 `rm_movep_canfd` 注释改为 “Placo 单步 QP + `rm_movej_canfd` 关节透传”。 - [ ] **Step 5: 从工作空间根目录构建** ```bash cd /home/robot/WS_xr source /opt/ros/humble/setup.bash colcon build --symlink-install ``` Expected: build summary 无失败包。 - [ ] **Step 6: 检查模型和 launch 安装结果** ```bash test -f install/xr_rm_teleop/share/xr_rm_teleop/models/rm75/RM75-B.urdf test -f install/xr_rm_teleop/share/xr_rm_teleop/models/rm75/meshes/link_7.STL source install/setup.bash ros2 launch xr_rm_bringup arm_debug.launch.py --show-args ``` Expected: 两个 `test` 返回 0;launch 参数列表正常显示,不包含 `frame_type`。 - [ ] **Step 7: 使用安装模型再次运行左右臂数值验收** ```bash cd /home/robot/WS_xr PYTHONPATH=src/xr_rm_teleop \ /home/robot/miniconda3/envs/xr/bin/python \ src/xr_rm_teleop/test/placo_ik_smoke.py \ install/xr_rm_teleop/share/xr_rm_teleop/models/rm75/RM75-B.urdf ``` Expected: 左、右均满足 `<=5 mm` 和 `<=2°`。 ### Task 6: 更新文档并完成 mock 验证 **Files:** - Modify: `README.md` - [ ] **Step 1: 更新 README 控制链路** 把项目首页链路改为: ```text PICO/XR 双手柄 UDP JSON -> xr_rm_input/udp_controller_receiver -> /xr/left_controller 与 /xr/right_controller -> xr_rm_teleop/single_arm_velocity_teleop -> TCP 安全限幅与工具/法兰目标换算 -> Placo 0.9.4 每周期一步 QP -> rm_movej_canfd 发送 7 个 RM75 关节目标 ``` 明确说明: ```text 遥操作节点收到首帧有效实际关节反馈前不调用 QP、不发送运动指令。 QP 失败时沿用上一组有效关节目标;XR 超时、反馈过期、Grip 松开或 SDK 发送失败时执行慢停止。 ``` - [ ] **Step 2: 更新环境说明** 在“环境准备”加入: ```text Placo 固定复用 /home/robot/miniconda3/envs/xr: - Python 3.10 - Placo 0.9.4 - Pin 3.7.0 - NumPy 2.2.6 arm_debug.launch.py 只对 single_arm_velocity_teleop 使用该 Python; ros2、colcon 和 udp_controller_receiver 仍使用系统 Python。 禁止 pip --user、sudo pip 或系统级安装/升级 Placo、Pin、EigenPy 和 NumPy。 ``` 加入只读检查命令: ```bash /home/robot/miniconda3/envs/xr/bin/python -c \ "import importlib.metadata; print(importlib.metadata.version('placo'))" ``` Expected: `0.9.4`。 - [ ] **Step 3: 更新参数和真机安全说明** 删除 README 对以下旧参数的描述: ```text frame_type current_pose_poll_hz mock_initial_pose ``` 把 `follow` 描述改成传给 `rm_movej_canfd`。把单臂自动回初始位姿说明改成: ```text 左臂、右臂和双臂默认都不会自动执行 movej(initial_joint_pose)。 只有用户在清空工作区并确认急停后显式传 move_to_initial_pose_on_connect:=true 才允许自动回初始位姿。 ``` 说明 `peripherals_rm75.yaml` 中左臂 `minisci +0.19 m`、右臂 `omnipic +0.16 m` 同时用于 RealMan 工具配置和 Placo TCP/法兰换算。 - [ ] **Step 4: 运行完整自动测试** ```bash cd /home/robot/WS_xr source /opt/ros/humble/setup.bash source install/setup.bash pytest -q \ src/xr_rm_teleop/test/test_orientation_control.py \ src/xr_rm_teleop/test/test_initial_joint_pose.py \ src/xr_rm_teleop/test/test_placo_transforms.py \ src/xr_rm_teleop/test/test_joint_control.py ``` Expected: 全部 PASS。 - [ ] **Step 5: 分别启动左臂和右臂 mock** 左臂: ```bash cd /home/robot/WS_xr source /opt/ros/humble/setup.bash source install/setup.bash timeout --signal=INT 8s \ ros2 launch xr_rm_bringup arm_debug.launch.py \ arm:=left use_mock:=true ``` 右臂: ```bash cd /home/robot/WS_xr source /opt/ros/humble/setup.bash source install/setup.bash timeout --signal=INT 8s \ ros2 launch xr_rm_bringup arm_debug.launch.py \ arm:=right use_mock:=true ``` Expected: 两侧分别打印 Placo 0.9.4 启动、首帧有效关节反馈和节点启动日志; 没有 `ModuleNotFoundError`、QP `NaN` 或厂商 SDK import 错误。`timeout` 主动结束 launch 时退出码可以是 124。 - [ ] **Step 6: 最终静态安全检查** ```bash cd /home/robot/WS_xr rg -n "rm_movep_canfd|send_cartesian_target" \ src/xr_rm_teleop src/xr_rm_bringup rg -n "rm_create_robot_arm" src/xr_rm_teleop rg -n "_ik_solver\\.solve" \ src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py rg -n "configure_safety_limits: false|move_to_initial_pose_on_connect: true" \ src/xr_rm_bringup/config git diff --check git status --short ``` Expected: - 旧笛卡尔运动链路无匹配。 - `rm_create_robot_arm` 只有一个实现调用点。 - `_ik_solver.solve` 在控制节点中只有一个调用点。 - 安全配置中没有默认关闭安全限位或默认自动回初始位姿。 - `git diff --check` 无空白错误。 - 原有用户改动 `D openspec/config.yaml` 仍保持原样,未被本任务触碰。 - [ ] **Step 7: 给用户提供左右臂各自的真机人工验收清单** 只提供命令,不由 Codex 执行: ```bash ros2 launch xr_rm_bringup arm_debug.launch.py \ arm:=left use_mock:=false \ move_to_initial_pose_on_connect:=false ``` 停止左臂节点后再执行: ```bash ros2 launch xr_rm_bringup arm_debug.launch.py \ arm:=right use_mock:=false \ move_to_initial_pose_on_connect:=false ``` 每侧检查: 1. 急停可用、工作区清空、速度保持低值。 2. 日志先出现首帧有效关节反馈,之后才允许按 Grip。 3. 静止握持时无启动跳变。 4. 只做小幅单轴位置和小角度姿态动作。 5. 松开 Grip、停止 XR 数据时均触发慢停止。 6. 人为制造不可达目标时不出现关节突跳;QP 失败保持上一组有效目标。 7. 左臂工具 TCP 使用 `minisci +0.19 m`,右臂使用 `omnipic +0.16 m`。 8. 停止一侧并确认连接释放后,才验证另一侧。 本轮不执行 `arm:=both use_mock:=false`。 - [ ] **Step 8: 最终检查变更范围,不提交** ```bash git diff --stat git diff -- \ src/xr_rm_teleop \ src/xr_rm_bringup \ src/README.md ``` Expected: 只包含本计划列出的 QP、Adapter、launch、配置、模型、测试和文档变更。 在用户明确授权之前不创建提交。