51 KiB
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: 从用户上传包复制且只复制模型资源
从工作空间根目录执行:
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 代码:
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:
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: 运行测试并确认它因模块尚不存在而失败
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 中加入:
@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() 中原来的工具名选择替换为:
tool_name = config.tool_name
工具位姿和控制器工具帧由同一个 PeripheralConfig 提供,避免左右臂工具索引在两个位置各自解析。
- Step 5: 实现不在系统 Python 导入 Placo 的变换和求解器骨架
创建 src/xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py,模块顶层只导入标准库和 NumPy;placo 必须在构造函数内导入:
"""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: 运行纯变换测试
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 变更范围
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:
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: 运行脚本并确认求解器类尚不存在
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 追加:
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,并追加:
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))
运行:
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 验收
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:
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 变更,不提交
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 测试,并追加:
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 不满足测试
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:
import threading
import time
加入:
@dataclass(frozen=True)
class JointStateSnapshot:
positions: list[float]
received_at: float
将 MockRealManAdapter 替换成:
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 替换为:
feedback_period: float,
在构造函数末尾加入:
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,在安全配置和可选初始移动之后启动反馈线程:
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()
加入以下方法:
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 的签名统一为:
def configure_peripheral(self, config, peripheral_arm: str) -> None:
真机实现不再读取 YAML,直接使用:
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() 改为:
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 与姿态回归测试
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: 静态确认只有一个连接创建点
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:
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: 运行测试并确认节点尚无这些方法
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 中加入:
import time
from .fun_peripheral import load_peripheral_config
from .placo_ik_solver import PlacoIkSolver
from .realman_adapter import (
ArmPose,
JointStateSnapshot,
MockRealManAdapter,
RealManAdapter,
)
声明新参数:
self.declare_parameter("robot_urdf_path", "")
删除已失效的参数声明和成员读取:
current_pose_poll_hz
mock_initial_pose
frame_type
初始化字段加入:
self._last_valid_joint_target: list[float] | None = None
self._joint_feedback_ready = False
把 Adapter 创建顺序替换为:
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() 改为:
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() 开头先执行控制器工具配置:
peripheral_arm = self._peripheral_arm_name()
if self._bool_parameter("configure_peripheral_on_connect"):
self._adapter.configure_peripheral(
self._peripheral_config,
peripheral_arm,
)
然后再判断:
if not self._enable_tool_control:
删除该函数中重复读取 peripheral_config_file 和重复调用旧
configure_peripheral(config_file, peripheral_arm) 的代码。
- Step 6: 实现反馈时效、首帧初始化和 QP 失败回退
在节点类中加入:
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 消息前加入:
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:
self._enter_active_control(
controller_now,
controller_quat,
current_pose,
now,
)
函数签名改为:
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 的全部死区、滤波、工作空间/圆柱、线速度和角速度限制。
把:
if self._send_cartesian_target(target_pose):
替换为:
joint_target = self._solve_joint_target(target_pose)
if self._send_joint_target(joint_target):
删除以下旧方法:
_read_current_pose_for_control
_maybe_refresh_current_pose
_send_cartesian_target
每个有效且激活的控制周期只能有一次
self._ik_solver.solve(target_pose)。
- Step 9: 运行节点行为和姿态回归测试
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: 静态确认旧笛卡尔运动链路已移除
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 依赖
加入:
from glob import glob
把 data_files 扩展为:
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"),
),
],
保持:
install_requires=["setuptools"],
不得加入 placo、pin、eigenpy 或 numpy,以免 colcon 使用系统 Python 时触发安装或升级。
- Step 2: launch 固定并验证 XR Python
在 arm_debug.launch.py 加入:
from pathlib import Path
定义:
XR_PYTHON = "/home/robot/miniconda3/envs/xr/bin/python"
在 _launch_setup() 最前面校验:
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 对象都加入:
prefix=[XR_PYTHON],
不要给 udp_controller_receiver 加 prefix;它继续使用系统 ROS2 Python。
- Step 3: launch 向每个遥操作节点传安装后的 URDF
给三个遥操作 Node 的参数字典都加入:
"robot_urdf_path": PathJoinSubstitution([
FindPackageShare("xr_rm_teleop"),
"models",
"rm75",
"RM75-B.urdf",
]),
删除 launch 中已经失效的 frame_type 参数传递、函数参数和对应的
DeclareLaunchArgument。
将控制周期注释更新为:
# 每周期同步一次实际关节反馈、执行一次 Placo QP,再发送 rm_movej_canfd。
- Step 4: 同步左、右、双臂 YAML
三个 YAML 都删除:
current_pose_poll_hz
mock_initial_pose
frame_type
保留每侧原有:
initial_joint_pose
workspace_min / workspace_max
cyl_radius_limit
max_linear_speed / max_orientation_speed
configure_safety_limits: true
把左右单臂 YAML 都改为:
move_to_initial_pose_on_connect: false
双臂文件原有左右两个 false 保持不变。把 rm_movep_canfd 注释改为
“Placo 单步 QP + rm_movej_canfd 关节透传”。
- Step 5: 从工作空间根目录构建
cd /home/robot/WS_xr
source /opt/ros/humble/setup.bash
colcon build --symlink-install
Expected: build summary 无失败包。
- Step 6: 检查模型和 launch 安装结果
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: 使用安装模型再次运行左右臂数值验收
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 控制链路
把项目首页链路改为:
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 关节目标
明确说明:
遥操作节点收到首帧有效实际关节反馈前不调用 QP、不发送运动指令。
QP 失败时沿用上一组有效关节目标;XR 超时、反馈过期、Grip 松开或
SDK 发送失败时执行慢停止。
- Step 2: 更新环境说明
在“环境准备”加入:
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。
加入只读检查命令:
/home/robot/miniconda3/envs/xr/bin/python -c \
"import importlib.metadata; print(importlib.metadata.version('placo'))"
Expected: 0.9.4。
- Step 3: 更新参数和真机安全说明
删除 README 对以下旧参数的描述:
frame_type
current_pose_poll_hz
mock_initial_pose
把 follow 描述改成传给 rm_movej_canfd。把单臂自动回初始位姿说明改成:
左臂、右臂和双臂默认都不会自动执行 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: 运行完整自动测试
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
左臂:
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
右臂:
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: 最终静态安全检查
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 执行:
ros2 launch xr_rm_bringup arm_debug.launch.py \
arm:=left use_mock:=false \
move_to_initial_pose_on_connect:=false
停止左臂节点后再执行:
ros2 launch xr_rm_bringup arm_debug.launch.py \
arm:=right use_mock:=false \
move_to_initial_pose_on_connect:=false
每侧检查:
- 急停可用、工作区清空、速度保持低值。
- 日志先出现首帧有效关节反馈,之后才允许按 Grip。
- 静止握持时无启动跳变。
- 只做小幅单轴位置和小角度姿态动作。
- 松开 Grip、停止 XR 数据时均触发慢停止。
- 人为制造不可达目标时不出现关节突跳;QP 失败保持上一组有效目标。
- 左臂工具 TCP 使用
minisci +0.19 m,右臂使用omnipic +0.16 m。 - 停止一侧并确认连接释放后,才验证另一侧。
本轮不执行 arm:=both use_mock:=false。
- Step 8: 最终检查变更范围,不提交
git diff --stat
git diff -- \
src/xr_rm_teleop \
src/xr_rm_bringup \
src/README.md
Expected: 只包含本计划列出的 QP、Adapter、launch、配置、模型、测试和文档变更。 在用户明确授权之前不创建提交。