Files
acRealman_xr/docs/superpowers/plans/2026-07-27-rm75-placo-qp-ik.md
T

1645 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: 全部 PASSmock 测试过程中不需要 `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、配置、模型、测试和文档变更。
在用户明确授权之前不创建提交。