diff --git a/docs/superpowers/plans/2026-08-04-dual-arm-mujoco-teleoperation.md b/docs/superpowers/plans/2026-08-04-dual-arm-mujoco-teleoperation.md
new file mode 100644
index 0000000..65bff0b
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-04-dual-arm-mujoco-teleoperation.md
@@ -0,0 +1,1231 @@
+# 双臂 MuJoCo 运动学遥操作实施计划
+
+> **面向执行代理:** 必须逐任务执行本计划,并使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`。所有步骤使用复选框跟踪。
+
+**目标:** 新增独立的 `xr_rm_mujoco` ROS2 包,用现有双臂 URDF 实时显示 Mock 或真机反馈,并保持现有 PICO、Placo QP、真机控制和安全行为不变。
+
+**架构:** 左右 `single_arm_velocity_teleop` 节点继续负责所有控制逻辑,并用标准 `sensor_msgs/JointState` 发布当前适配器反馈和限速后的关节目标。单个 `dual_arm_simulator` 订阅左右反馈,按关节名写入 MuJoCo `qpos` 并调用 `mj_forward()`,只做运动学显示。现有 `arm_debug.launch.py` 用新增的 `use_mujoco` 参数选择是否启动显示进程。
+
+**技术栈:** Ubuntu 22.04、ROS2 Humble、Python 3.10、ament_python、MuJoCo 3.10.0、Placo 0.9.4、NumPy、sensor_msgs、pytest、colcon。
+
+---
+
+## 执行约束
+
+- 所有构建、测试和启动命令均在 `/home/robot/WS_xr` 执行,并先运行:
+
+ ```bash
+ source /opt/ros/humble/setup.bash
+ ```
+
+- MuJoCo 和 Placo 测试使用 `/home/robot/miniconda3/envs/xr/bin/python`;不得用系统
+ pip、`pip --user` 或 `sudo pip` 改动现有环境。
+- 自动化和启动验收只允许 `use_mock:=true`,不得连接真机、移动机械臂或操作夹爪。
+- `move_to_initial_pose_on_connect` 保持 `false`,不得关闭现有安全限位、超时和停止
+ 逻辑。
+- 不修改左右节点名 `left_arm_teleop`、`right_arm_teleop`,不复制 URDF/mesh,不
+ 生成持久化 MJCF,不实现动力学、碰撞或执行器。
+- 每个任务只提交列出的文件,不提交无关工作树内容,不推送远程。
+
+## 文件结构
+
+**新建:**
+
+- `xr_rm_mujoco/package.xml`:ROS2 包依赖。
+- `xr_rm_mujoco/setup.py`:包安装和 `dual_arm_simulator` 入口。
+- `xr_rm_mujoco/setup.cfg`:ament_python 脚本安装位置。
+- `xr_rm_mujoco/resource/xr_rm_mujoco`:ament 索引标记。
+- `xr_rm_mujoco/xr_rm_mujoco/__init__.py`:Python 包标记。
+- `xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py`:MuJoCo 模型映射、ROS 订阅和 viewer。
+- `xr_rm_mujoco/test/test_dual_arm_simulator.py`:模型加载、映射、校验与配置测试。
+- `xr_rm_bringup/config/dual_arm_mujoco.yaml`:MuJoCo 专属刷新参数。
+- `xr_rm_bringup/test/test_arm_debug_launch.py`:launch 参数和模式约束测试。
+
+**修改:**
+
+- `xr_rm_teleop/package.xml`:增加 `sensor_msgs` 运行依赖。
+- `xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`:只读暴露当前侧关节名称。
+- `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`:发布关节反馈/目标并调整 Mock Reset 后的重新锚定。
+- `xr_rm_teleop/test/test_joint_control.py`:关节话题和 Reset 回归测试。
+- `xr_rm_bringup/package.xml`:增加 `xr_rm_mujoco` 运行依赖。
+- `xr_rm_bringup/launch/arm_debug.launch.py`:增加 `use_mujoco` 和仿真节点。
+- `README.md`:记录目录、启动方式、频率、话题和 Reset 行为。
+
+`xr_rm_bringup/CMakeLists.txt` 已安装整个 `config` 目录,无需修改。
+
+### 任务一:建立 MuJoCo 双臂运动学模型
+
+**文件:**
+
+- 新建:`xr_rm_mujoco/package.xml`
+- 新建:`xr_rm_mujoco/setup.py`
+- 新建:`xr_rm_mujoco/setup.cfg`
+- 新建:`xr_rm_mujoco/resource/xr_rm_mujoco`
+- 新建:`xr_rm_mujoco/xr_rm_mujoco/__init__.py`
+- 新建:`xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py`
+- 新建:`xr_rm_mujoco/test/test_dual_arm_simulator.py`
+
+- [ ] **步骤 1:先写失败的 MuJoCo 模型测试**
+
+创建 `xr_rm_mujoco/test/test_dual_arm_simulator.py`:
+
+```python
+import math
+from pathlib import Path
+
+import pytest
+import yaml
+
+from xr_rm_mujoco.dual_arm_simulator import (
+ ARM_JOINT_NAMES,
+ DualArmKinematicModel,
+)
+
+
+SRC_DIR = Path(__file__).resolve().parents[2]
+URDF_PATH = (
+ SRC_DIR / "xr_rm_teleop" / "models" / "dual_rm75" / "Dual_arm.urdf"
+)
+DUAL_CONFIG_PATH = SRC_DIR / "xr_rm_bringup" / "config" / "dual_arm_rm75.yaml"
+
+
+def test_dual_urdf_loads_with_expected_joint_mapping() -> None:
+ simulation = DualArmKinematicModel(str(URDF_PATH))
+
+ assert simulation.model.nq == 14
+ assert simulation.model.nv == 14
+ assert ARM_JOINT_NAMES == {
+ "left": tuple(f"scissor_joint_{index}" for index in range(1, 8)),
+ "right": tuple(f"omnipic_joint_{index}" for index in range(1, 8)),
+ }
+ assert not simulation.ready
+
+
+def test_joint_messages_are_mapped_by_name_not_array_order() -> None:
+ simulation = DualArmKinematicModel(str(URDF_PATH))
+ names = list(reversed(ARM_JOINT_NAMES["left"]))
+ values = [float(index) / 10.0 for index in range(7)]
+
+ simulation.apply_arm_state("left", names, values)
+
+ by_name = dict(zip(names, values))
+ assert simulation.joint_positions("left") == pytest.approx(
+ [by_name[name] for name in ARM_JOINT_NAMES["left"]]
+ )
+ assert not simulation.ready
+
+
+def test_yaml_initial_poses_populate_both_arms() -> None:
+ simulation = DualArmKinematicModel(str(URDF_PATH))
+ with DUAL_CONFIG_PATH.open(encoding="utf-8") as stream:
+ config = yaml.safe_load(stream)
+
+ for arm, node_name in (
+ ("left", "left_arm_teleop"),
+ ("right", "right_arm_teleop"),
+ ):
+ degrees = config[node_name]["ros__parameters"]["initial_joint_pose"]
+ radians = [math.radians(value) for value in degrees]
+ simulation.apply_arm_state(arm, ARM_JOINT_NAMES[arm], radians)
+ assert simulation.joint_positions(arm) == pytest.approx(radians)
+
+ assert simulation.ready
+
+
+@pytest.mark.parametrize(
+ ("names", "positions", "match"),
+ [
+ (list(ARM_JOINT_NAMES["left"][:-1]), [0.0] * 6, "expected"),
+ (list(ARM_JOINT_NAMES["left"]), [0.0] * 6, "same length"),
+ (
+ [ARM_JOINT_NAMES["left"][0]] * 7,
+ [0.0] * 7,
+ "unique",
+ ),
+ (
+ list(ARM_JOINT_NAMES["left"]),
+ [0.0] * 6 + [math.nan],
+ "finite",
+ ),
+ ],
+)
+def test_invalid_joint_state_is_rejected_without_partial_update(
+ names: list[str],
+ positions: list[float],
+ match: str,
+) -> None:
+ simulation = DualArmKinematicModel(str(URDF_PATH))
+ valid = [0.1] * 7
+ simulation.apply_arm_state("left", ARM_JOINT_NAMES["left"], valid)
+
+ with pytest.raises(ValueError, match=match):
+ simulation.apply_arm_state("left", names, positions)
+
+ assert simulation.joint_positions("left") == pytest.approx(valid)
+```
+
+- [ ] **步骤 2:运行测试并确认因新包不存在而失败**
+
+运行:
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+PYTHONPATH=src/xr_rm_mujoco \
+ /home/robot/miniconda3/envs/xr/bin/python -m pytest \
+ src/xr_rm_mujoco/test/test_dual_arm_simulator.py -v
+```
+
+预期:收集失败,提示 `ModuleNotFoundError: No module named 'xr_rm_mujoco'` 或缺少
+`dual_arm_simulator`。
+
+- [ ] **步骤 3:创建最小 ROS2 Python 包元数据**
+
+创建空文件 `xr_rm_mujoco/resource/xr_rm_mujoco` 和
+`xr_rm_mujoco/xr_rm_mujoco/__init__.py`。
+
+创建 `xr_rm_mujoco/setup.cfg`:
+
+```ini
+[develop]
+script_dir=$base/lib/xr_rm_mujoco
+[install]
+install_scripts=$base/lib/xr_rm_mujoco
+```
+
+创建 `xr_rm_mujoco/setup.py`:
+
+```python
+"""MuJoCo 双 RM75 运动学显示包安装配置。"""
+
+from setuptools import setup
+
+
+package_name = "xr_rm_mujoco"
+
+setup(
+ name=package_name,
+ version="0.1.0",
+ packages=[package_name],
+ data_files=[
+ ("share/ament_index/resource_index/packages", [f"resource/{package_name}"]),
+ (f"share/{package_name}", ["package.xml"]),
+ ],
+ install_requires=["setuptools"],
+ zip_safe=True,
+ maintainer="Yikai Fu",
+ maintainer_email="user@example.com",
+ description="MuJoCo kinematic visualization for the dual RM75 platform.",
+ license="Apache-2.0",
+ tests_require=["pytest"],
+)
+```
+
+创建 `xr_rm_mujoco/package.xml`:
+
+```xml
+
+
+
+ xr_rm_mujoco
+ 0.1.0
+ MuJoCo kinematic visualization for the dual RM75 platform.
+ Yikai Fu
+ Apache-2.0
+
+ ament_python
+
+ rclpy
+ sensor_msgs
+ xr_rm_teleop
+
+ ament_lint_auto
+ ament_lint_common
+ python3-pytest
+ python3-yaml
+
+
+ ament_python
+
+
+```
+
+不要把 `mujoco` 加入 `install_requires`,避免 colcon 构建时通过 pip 改动已经固定的
+Conda 环境;运行入口后续继续使用项目现有 `XR_PYTHON`。
+
+- [ ] **步骤 4:实现最小的名称映射和运动学状态类**
+
+创建 `xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py`:
+
+```python
+"""使用现有双 RM75 URDF 的 MuJoCo 运动学状态映射。"""
+
+from __future__ import annotations
+
+import math
+from pathlib import Path
+
+import mujoco
+
+
+ARM_JOINT_NAMES = {
+ "left": tuple(f"scissor_joint_{index}" for index in range(1, 8)),
+ "right": tuple(f"omnipic_joint_{index}" for index in range(1, 8)),
+}
+
+
+class DualArmKinematicModel:
+ """加载双臂 URDF,并按关节名更新 MuJoCo qpos。"""
+
+ def __init__(self, urdf_path: str) -> None:
+ path = Path(urdf_path).expanduser().resolve()
+ if not path.is_file():
+ raise FileNotFoundError(f"dual RM75 URDF not found: {path}")
+
+ self.model = mujoco.MjModel.from_xml_path(str(path))
+ self.data = mujoco.MjData(self.model)
+ self._qpos_addresses: dict[str, dict[str, int]] = {}
+ self._received_arms: set[str] = set()
+
+ for arm, names in ARM_JOINT_NAMES.items():
+ addresses = {}
+ for name in names:
+ joint_id = mujoco.mj_name2id(
+ self.model,
+ mujoco.mjtObj.mjOBJ_JOINT,
+ name,
+ )
+ if joint_id < 0:
+ raise RuntimeError(f"MuJoCo joint not found: {name}")
+ if self.model.jnt_type[joint_id] != mujoco.mjtJoint.mjJNT_HINGE:
+ raise RuntimeError(f"MuJoCo joint must be hinge: {name}")
+ addresses[name] = int(self.model.jnt_qposadr[joint_id])
+ self._qpos_addresses[arm] = addresses
+
+ @property
+ def ready(self) -> bool:
+ return self._received_arms == set(ARM_JOINT_NAMES)
+
+ def apply_arm_state(
+ self,
+ arm: str,
+ names: list[str] | tuple[str, ...],
+ positions: list[float] | tuple[float, ...],
+ ) -> None:
+ if arm not in ARM_JOINT_NAMES:
+ raise ValueError("arm must be left or right")
+ if len(names) != len(positions):
+ raise ValueError("joint names and positions must have the same length")
+ if len(set(names)) != len(names):
+ raise ValueError("joint names must be unique")
+
+ expected = set(ARM_JOINT_NAMES[arm])
+ if set(names) != expected:
+ raise ValueError(f"joint names must match expected {arm} joints")
+
+ values = [float(value) for value in positions]
+ if not all(math.isfinite(value) for value in values):
+ raise ValueError("joint positions must be finite")
+
+ by_name = dict(zip(names, values))
+ updates = [
+ (self._qpos_addresses[arm][name], by_name[name])
+ for name in ARM_JOINT_NAMES[arm]
+ ]
+ for address, value in updates:
+ self.data.qpos[address] = value
+ mujoco.mj_forward(self.model, self.data)
+ self._received_arms.add(arm)
+
+ def joint_positions(self, arm: str) -> list[float]:
+ if arm not in ARM_JOINT_NAMES:
+ raise ValueError("arm must be left or right")
+ return [
+ float(self.data.qpos[self._qpos_addresses[arm][name]])
+ for name in ARM_JOINT_NAMES[arm]
+ ]
+```
+
+- [ ] **步骤 5:运行 MuJoCo 模型测试并确认通过**
+
+运行步骤 2 的同一命令。
+
+预期:全部 PASS;本机输出会使用 MuJoCo 3.10.0,模型为 `nq=14`、`nv=14`。
+
+- [ ] **步骤 6:提交新包基础**
+
+```bash
+git add \
+ src/xr_rm_mujoco/package.xml \
+ src/xr_rm_mujoco/setup.py \
+ src/xr_rm_mujoco/setup.cfg \
+ src/xr_rm_mujoco/resource/xr_rm_mujoco \
+ src/xr_rm_mujoco/xr_rm_mujoco/__init__.py \
+ src/xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py \
+ src/xr_rm_mujoco/test/test_dual_arm_simulator.py
+git commit -m "feat: 添加双臂 MuJoCo 运动学模型"
+```
+
+### 任务二:发布左右关节反馈与限速目标
+
+**文件:**
+
+- 修改:`xr_rm_teleop/package.xml`
+- 修改:`xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py`
+- 修改:`xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
+- 修改:`xr_rm_teleop/test/test_joint_control.py`
+
+- [ ] **步骤 1:先增加关节消息失败测试和测试发布器**
+
+在 `xr_rm_teleop/test/test_joint_control.py` 导入:
+
+```python
+from builtin_interfaces.msg import Time as TimeMsg
+```
+
+给现有 `FakeTime` 增加:
+
+```python
+ def to_msg(self):
+ return TimeMsg()
+```
+
+在文件前部增加:
+
+```python
+class FakePublisher:
+ def __init__(self) -> None:
+ self.messages = []
+
+ def publish(self, message) -> None:
+ self.messages.append(message)
+
+
+def _joint_publishing_teleop() -> SingleArmVelocityTeleop:
+ names = [f"omnipic_joint_{index}" for index in range(1, 8)]
+ teleop = object.__new__(SingleArmVelocityTeleop)
+ teleop._ik_solver = SimpleNamespace(
+ joint_names=names,
+ update_joint_state=lambda joints: np.eye(4),
+ )
+ teleop._joint_state_pub = FakePublisher()
+ teleop._joint_target_pub = FakePublisher()
+ teleop._active = False
+ teleop._last_valid_joint_target = None
+ teleop.get_clock = lambda: SimpleNamespace(now=lambda: FakeTime())
+ return teleop
+
+
+def test_reset_joint_state_publishes_named_feedback() -> None:
+ teleop = _joint_publishing_teleop()
+ positions = [0.1 * index for index in range(7)]
+ snapshot = JointStateSnapshot(positions, time.monotonic())
+
+ teleop._reset_joint_state(snapshot)
+
+ message = teleop._joint_state_pub.messages[-1]
+ assert message.name == teleop._ik_solver.joint_names
+ assert message.position == pytest.approx(positions)
+
+
+def test_sync_joint_feedback_publishes_each_sample() -> None:
+ teleop = _joint_publishing_teleop()
+ positions = [0.2] * 7
+
+ teleop._sync_joint_feedback(
+ JointStateSnapshot(positions, time.monotonic())
+ )
+
+ assert len(teleop._joint_state_pub.messages) == 1
+ assert teleop._joint_state_pub.messages[0].position == pytest.approx(positions)
+```
+
+给现有 `_timeout_teleop()` 补充一个 `FakePublisher`,防止后续成功发送目标时测试对象
+缺少新属性:
+
+```python
+ teleop._joint_state_pub = FakePublisher()
+ teleop._joint_target_pub = FakePublisher()
+ teleop._ik_solver.joint_names = [
+ f"omnipic_joint_{index}" for index in range(1, 8)
+ ]
+ teleop.get_clock = lambda: SimpleNamespace(now=lambda: FakeTime())
+```
+
+增加限速后目标发布测试:
+
+```python
+def test_send_joint_target_publishes_limited_command() -> None:
+ sent = []
+ teleop = _joint_publishing_teleop()
+ teleop._adapter = SimpleNamespace(
+ send_joint_target=lambda joints, follow: sent.append((list(joints), follow))
+ )
+ teleop._follow = False
+ teleop._latest_joint_positions = [0.0] * 7
+ teleop._last_joint_command_target = [0.0] * 7
+ teleop._last_joint_command_velocity = [0.0] * 7
+ teleop._joint_command_max_speed = 1.0
+ teleop._joint_command_max_acceleration = 100.0
+ teleop._dt = 0.1
+
+ assert teleop._send_joint_target([0.5] * 7)
+
+ assert len(sent) == 1
+ assert sent[0][0] == pytest.approx([0.1] * 7)
+ assert sent[0][1] is False
+ message = teleop._joint_target_pub.messages[-1]
+ assert message.name == teleop._ik_solver.joint_names
+ assert message.position == pytest.approx(teleop._last_joint_command_target)
+```
+
+现有以下两个测试也会经过新的反馈发布路径,分别补齐
+`joint_names`、`_joint_state_pub` 和可生成 ROS 时间戳的 fake clock:
+
+```python
+# test_startup_joint_query_initializes_qp_and_command_history
+teleop._ik_solver = SimpleNamespace(
+ joint_names=[f"omnipic_joint_{index}" for index in range(1, 8)],
+ update_joint_state=lambda joints: pose,
+)
+teleop._joint_state_pub = FakePublisher()
+teleop.get_clock = lambda: SimpleNamespace(now=lambda: FakeTime())
+
+# test_first_feedback_initializes_last_valid_target_without_solving
+teleop._ik_solver.joint_names = [
+ f"omnipic_joint_{index}" for index in range(1, 8)
+]
+teleop._joint_state_pub = FakePublisher()
+teleop.get_clock = lambda: SimpleNamespace(now=lambda: FakeTime())
+```
+
+`test_feedback_fault_blocks_grip_until_release` 的测试对象会调用 `_control_tick()` 并
+进入 `_sync_joint_feedback()`,同样增加:
+
+```python
+teleop._ik_solver.joint_names = [
+ f"omnipic_joint_{index}" for index in range(1, 8)
+]
+teleop._joint_state_pub = FakePublisher()
+```
+
+- [ ] **步骤 2:运行新增测试并确认因尚未发布消息而失败**
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+pytest \
+ src/xr_rm_teleop/test/test_joint_control.py::test_reset_joint_state_publishes_named_feedback \
+ src/xr_rm_teleop/test/test_joint_control.py::test_sync_joint_feedback_publishes_each_sample \
+ src/xr_rm_teleop/test/test_joint_control.py::test_send_joint_target_publishes_limited_command \
+ -v
+```
+
+预期:FAIL,反馈或目标发布器的消息列表仍为空。
+
+- [ ] **步骤 3:暴露求解器当前侧关节名称**
+
+在 `PlacoIkSolver` 的 `base_configuration` 属性前增加:
+
+```python
+ @property
+ def joint_names(self) -> list[str]:
+ return list(self._joint_names)
+```
+
+返回副本,调用者不能修改求解器内部关节顺序。
+
+- [ ] **步骤 4:创建发布器和标准 JointState 消息**
+
+在 `single_arm_velocity_teleop.py` 导入:
+
+```python
+from sensor_msgs.msg import JointState
+```
+
+在创建 `PlacoIkSolver` 后、连接适配器前创建两个发布器,使启动首帧可以立即发布:
+
+```python
+ debug_ns = f"{self._debug_topic_prefix}/{self._arm_name}"
+ self._joint_state_pub = self.create_publisher(
+ JointState,
+ f"{debug_ns}/joint_states",
+ 10,
+ )
+ self._joint_target_pub = self.create_publisher(
+ JointState,
+ f"{debug_ns}/joint_target",
+ 10,
+ )
+```
+
+保留后续五个现有调试发布器,并复用已经计算的 `debug_ns`,不要重复赋值。
+
+在 `_reset_joint_state()` 前增加:
+
+```python
+ def _publish_joint_positions(self, publisher, positions: list[float]) -> None:
+ message = JointState()
+ message.header.stamp = self.get_clock().now().to_msg()
+ message.name = self._ik_solver.joint_names
+ message.position = [float(value) for value in positions]
+ publisher.publish(message)
+```
+
+在 `_reset_joint_state()` 设置完状态后、返回前增加:
+
+```python
+ self._publish_joint_positions(self._joint_state_pub, positions)
+```
+
+在 `_sync_joint_feedback()` 设置完 `_last_current_pose` 后增加:
+
+```python
+ self._publish_joint_positions(
+ self._joint_state_pub,
+ list(snapshot.positions),
+ )
+```
+
+在 `_send_joint_target()` 成功更新 `_last_joint_command_velocity` 后增加:
+
+```python
+ self._publish_joint_positions(
+ self._joint_target_pub,
+ limited_target,
+ )
+```
+
+这样 `joint_target` 是经过关节速度/加速度限制后真正交给当前适配器的目标,而不是
+限速前的 QP 原始结果。
+
+- [ ] **步骤 5:声明 ROS 标准消息依赖**
+
+在 `xr_rm_teleop/package.xml` 的 `rclpy` 依赖后增加:
+
+```xml
+ sensor_msgs
+```
+
+- [ ] **步骤 6:运行关节控制测试并确认通过**
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+pytest src/xr_rm_teleop/test/test_joint_control.py -v
+```
+
+预期:全部 PASS,包括启动首帧、90 Hz 反馈同步路径和限速后目标发布。
+
+- [ ] **步骤 7:提交关节状态接口**
+
+```bash
+git add \
+ src/xr_rm_teleop/package.xml \
+ src/xr_rm_teleop/xr_rm_teleop/placo_ik_solver.py \
+ src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py \
+ src/xr_rm_teleop/test/test_joint_control.py
+git commit -m "feat: 发布双臂关节状态与目标"
+```
+
+### 任务三:让 Mock Reset 后立即重新锚定
+
+**文件:**
+
+- 修改:`xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
+- 修改:`xr_rm_teleop/test/test_joint_control.py`
+
+- [ ] **步骤 1:写 Mock 与真机 Reset 差异的失败测试**
+
+把现有 `_primary_button_teleop` 改为接收适配器模式:
+
+```python
+def _primary_button_teleop(*, use_mock=False, move_error=None):
+ # 保留现有函数体,并在 teleop 初始化处增加:
+ teleop._use_mock = use_mock
+```
+
+保留现有 `test_primary_button_rising_edge_moves_once_and_resyncs()` 对真机语义的
+`assert teleop._grip_rearm_required`,并新增:
+
+```python
+def test_mock_primary_reset_can_reanchor_without_grip_release() -> None:
+ teleop, events, _, snapshot = _primary_button_teleop(use_mock=True)
+
+ teleop._on_controller(SimpleNamespace(primary=False))
+ teleop._on_controller(SimpleNamespace(primary=True))
+
+ assert events == [
+ ("stop", True),
+ "move",
+ "read",
+ ("sync", snapshot),
+ ]
+ assert not teleop._grip_rearm_required
+
+
+def test_failed_mock_primary_reset_still_requires_grip_release() -> None:
+ failure = RuntimeError("mock reset failed")
+ teleop, _, _, _ = _primary_button_teleop(
+ use_mock=True,
+ move_error=failure,
+ )
+
+ teleop._on_controller(SimpleNamespace(primary=False))
+ teleop._on_controller(SimpleNamespace(primary=True))
+
+ assert teleop._grip_rearm_required
+```
+
+- [ ] **步骤 2:运行新增测试并确认自动重新锚定测试失败**
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+pytest \
+ src/xr_rm_teleop/test/test_joint_control.py::test_mock_primary_reset_can_reanchor_without_grip_release \
+ src/xr_rm_teleop/test/test_joint_control.py::test_failed_mock_primary_reset_still_requires_grip_release \
+ -v
+```
+
+预期:第一项 FAIL,因为当前成功 Reset 后仍设置 `_grip_rearm_required=True`;失败
+路径测试 PASS。
+
+- [ ] **步骤 3:缓存 use_mock 并只在成功 Mock Reset 后解除重新使能要求**
+
+在参数读取区域保存:
+
+```python
+ self._use_mock = self._bool_parameter("use_mock")
+```
+
+把 `_make_adapter()` 中的判断改为:
+
+```python
+ if self._use_mock:
+ return MockRealManAdapter(initial_joint_pose)
+```
+
+在 `_handle_initial_pose_button()` 中保持 Reset 前先锁存:
+
+```python
+ self._grip_rearm_required = True
+```
+
+并只在 `move_to_initial_pose()`、`read_joint_state()` 和 `_reset_joint_state()` 全部成功
+后增加:
+
+```python
+ if self._use_mock:
+ self._grip_rearm_required = False
+```
+
+不要更改异常分支。`_safe_stop(reset_active=True)` 已清除旧手柄/机械臂基准;下一次
+90 Hz 控制周期看到 Grip 仍按下时,会走现有 `_enter_active_control()`,以 Reset 后
+状态自动建立新基准。真机仍保留 `_grip_rearm_required=True`。
+
+- [ ] **步骤 4:运行完整关节与姿态控制测试**
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+pytest src/xr_rm_teleop/test/test_joint_control.py -v
+pytest src/xr_rm_teleop/test/test_orientation_control.py -v
+```
+
+预期:全部 PASS;Mock 成功 Reset 可立即重锚,真机和失败路径仍需 Grip 松开。
+
+- [ ] **步骤 5:提交 Reset 行为**
+
+```bash
+git add \
+ src/xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py \
+ src/xr_rm_teleop/test/test_joint_control.py
+git commit -m "feat: 支持 MuJoCo 双臂即时复位"
+```
+
+### 任务四:增加 ROS MuJoCo viewer 节点和独立配置
+
+**文件:**
+
+- 修改:`xr_rm_mujoco/setup.py`
+- 修改:`xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py`
+- 修改:`xr_rm_mujoco/test/test_dual_arm_simulator.py`
+- 新建:`xr_rm_bringup/config/dual_arm_mujoco.yaml`
+
+- [ ] **步骤 1:写独立 MuJoCo 配置的失败测试**
+
+在 `test_dual_arm_simulator.py` 增加:
+
+```python
+MUJOCO_CONFIG_PATH = (
+ SRC_DIR / "xr_rm_bringup" / "config" / "dual_arm_mujoco.yaml"
+)
+
+
+def test_mujoco_config_contains_only_render_parameters() -> None:
+ with MUJOCO_CONFIG_PATH.open(encoding="utf-8") as stream:
+ parameters = yaml.safe_load(stream)["dual_arm_simulator"]["ros__parameters"]
+
+ assert parameters == {"render_rate_hz": 60.0}
+```
+
+- [ ] **步骤 2:运行配置测试并确认文件不存在**
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+PYTHONPATH=src/xr_rm_mujoco \
+ /home/robot/miniconda3/envs/xr/bin/python -m pytest \
+ src/xr_rm_mujoco/test/test_dual_arm_simulator.py::test_mujoco_config_contains_only_render_parameters \
+ -v
+```
+
+预期:FAIL,提示 `dual_arm_mujoco.yaml` 不存在。
+
+- [ ] **步骤 3:创建 MuJoCo 专属配置**
+
+创建 `xr_rm_bringup/config/dual_arm_mujoco.yaml`:
+
+```yaml
+# 双 RM75 MuJoCo 运动学显示参数。初始姿态和控制限制仍由 dual_arm_rm75.yaml 管理。
+dual_arm_simulator:
+ ros__parameters:
+ render_rate_hz: 60.0
+```
+
+- [ ] **步骤 4:在同一生产文件中增加最小 ROS wrapper**
+
+在 `dual_arm_simulator.py` 增加导入:
+
+```python
+from typing import Callable
+
+import mujoco.viewer
+import rclpy
+from rclpy.node import Node
+from sensor_msgs.msg import JointState
+```
+
+在 `DualArmKinematicModel` 后增加:
+
+```python
+STATE_TOPICS = {
+ "left": "/xr_rm/left_rm75/joint_states",
+ "right": "/xr_rm/right_rm75/joint_states",
+}
+
+
+class DualArmSimulator(Node):
+ """订阅左右关节反馈并刷新一个 MuJoCo 双臂 viewer。"""
+
+ def __init__(
+ self,
+ viewer_factory: Callable = mujoco.viewer.launch_passive,
+ ) -> None:
+ super().__init__("dual_arm_simulator")
+ self.declare_parameter("robot_urdf_path", "")
+ self.declare_parameter("render_rate_hz", 60.0)
+
+ render_rate_hz = float(self.get_parameter("render_rate_hz").value)
+ if not math.isfinite(render_rate_hz) or render_rate_hz <= 0.0:
+ raise ValueError("render_rate_hz must be finite and > 0")
+
+ self._kinematics = DualArmKinematicModel(
+ str(self.get_parameter("robot_urdf_path").value)
+ )
+ self._viewer_factory = viewer_factory
+ self._viewer = None
+ self._subscriptions = [
+ self.create_subscription(
+ JointState,
+ topic,
+ lambda message, selected_arm=arm: self._on_joint_state(
+ selected_arm,
+ message,
+ ),
+ 10,
+ )
+ for arm, topic in STATE_TOPICS.items()
+ ]
+ self.create_timer(1.0 / render_rate_hz, self._render)
+ self.get_logger().info(
+ "MuJoCo 双臂节点已启动,等待左右关节状态,"
+ f"render_rate_hz={render_rate_hz:.1f}"
+ )
+
+ def _on_joint_state(self, arm: str, message: JointState) -> None:
+ try:
+ if self._viewer is None:
+ self._kinematics.apply_arm_state(
+ arm,
+ list(message.name),
+ list(message.position),
+ )
+ else:
+ with self._viewer.lock():
+ self._kinematics.apply_arm_state(
+ arm,
+ list(message.name),
+ list(message.position),
+ )
+ except (RuntimeError, ValueError) as exc:
+ self.get_logger().warn(
+ f"拒绝 {arm} 关节状态:{exc}",
+ throttle_duration_sec=1.0,
+ )
+
+ def _render(self) -> None:
+ for topic in STATE_TOPICS.values():
+ publisher_count = self.count_publishers(topic)
+ if publisher_count > 1:
+ self.get_logger().warn(
+ f"关节状态话题存在多个发布者:{topic}, count={publisher_count}",
+ throttle_duration_sec=5.0,
+ )
+
+ if not self._kinematics.ready:
+ return
+ if self._viewer is None:
+ self._viewer = self._viewer_factory(
+ self._kinematics.model,
+ self._kinematics.data,
+ )
+ if not self._viewer.is_running():
+ self.get_logger().info("MuJoCo viewer 已关闭。")
+ rclpy.shutdown()
+ return
+ self._viewer.sync()
+
+ def close_viewer(self) -> None:
+ if self._viewer is not None:
+ self._viewer.close()
+ self._viewer = None
+
+
+def main(args=None) -> None:
+ rclpy.init(args=args)
+ node = None
+ try:
+ node = DualArmSimulator()
+ rclpy.spin(node)
+ finally:
+ if node is not None:
+ node.close_viewer()
+ node.destroy_node()
+ if rclpy.ok():
+ rclpy.shutdown()
+
+
+if __name__ == "__main__":
+ main()
+```
+
+保留单线程 `rclpy.spin()`;ROS 回调和 render timer 不会并行修改 qpos,viewer 已启动
+后通过官方 `viewer.lock()` 保护写入。
+
+- [ ] **步骤 5:安装 ROS 可执行入口**
+
+在 `xr_rm_mujoco/setup.py` 的 `tests_require` 后增加:
+
+```python
+ entry_points={
+ "console_scripts": [
+ "dual_arm_simulator = xr_rm_mujoco.dual_arm_simulator:main",
+ ],
+ },
+```
+
+- [ ] **步骤 6:运行 MuJoCo 测试并确认通过**
+
+运行:
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+PYTHONPATH=src/xr_rm_mujoco \
+ /home/robot/miniconda3/envs/xr/bin/python -m pytest \
+ src/xr_rm_mujoco/test/test_dual_arm_simulator.py -v
+```
+
+预期:全部 PASS;测试只加载模型,不打开 viewer。
+
+- [ ] **步骤 7:提交 ROS viewer 与配置**
+
+```bash
+git add \
+ src/xr_rm_mujoco/setup.py \
+ src/xr_rm_mujoco/xr_rm_mujoco/dual_arm_simulator.py \
+ src/xr_rm_mujoco/test/test_dual_arm_simulator.py \
+ src/xr_rm_bringup/config/dual_arm_mujoco.yaml
+git commit -m "feat: 添加双臂 MuJoCo 显示节点"
+```
+
+### 任务五:接入统一 launch 并保持默认行为
+
+**文件:**
+
+- 新建:`xr_rm_bringup/test/test_arm_debug_launch.py`
+- 修改:`xr_rm_bringup/launch/arm_debug.launch.py`
+- 修改:`xr_rm_bringup/package.xml`
+
+- [ ] **步骤 1:写 launch 参数和范围约束的失败测试**
+
+创建 `xr_rm_bringup/test/test_arm_debug_launch.py`:
+
+```python
+import importlib.util
+from pathlib import Path
+
+import pytest
+from launch import LaunchContext
+from launch.actions import DeclareLaunchArgument
+from launch.utilities import perform_substitutions
+
+
+MODULE_PATH = Path(__file__).parents[1] / "launch" / "arm_debug.launch.py"
+SPEC = importlib.util.spec_from_file_location("arm_debug_launch", MODULE_PATH)
+arm_debug_launch = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+SPEC.loader.exec_module(arm_debug_launch)
+
+
+def test_launch_declares_mujoco_disabled_by_default() -> None:
+ description = arm_debug_launch.generate_launch_description()
+ arguments = {
+ entity.name: entity
+ for entity in description.entities
+ if isinstance(entity, DeclareLaunchArgument)
+ }
+
+ assert "use_mujoco" in arguments
+ assert perform_substitutions(
+ LaunchContext(),
+ arguments["use_mujoco"].default_value,
+ ) == "false"
+
+
+def test_mujoco_mode_requires_both_arms() -> None:
+ arm_debug_launch._validate_mujoco_mode("both", True)
+ arm_debug_launch._validate_mujoco_mode("left", False)
+
+ with pytest.raises(ValueError, match="arm:=both"):
+ arm_debug_launch._validate_mujoco_mode("left", True)
+```
+
+- [ ] **步骤 2:运行 launch 测试并确认缺少参数和校验函数**
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+pytest src/xr_rm_bringup/test/test_arm_debug_launch.py -v
+```
+
+预期:FAIL,提示缺少 `use_mujoco` 或 `_validate_mujoco_mode`。
+
+- [ ] **步骤 3:增加 MuJoCo 节点构造与模式校验**
+
+在 `arm_debug.launch.py` 的 `_udp_receiver_node()` 后增加:
+
+```python
+def _mujoco_node() -> Node:
+ """启动只读双臂 MuJoCo 运动学显示节点。"""
+ return Node(
+ package="xr_rm_mujoco",
+ executable="dual_arm_simulator",
+ name="dual_arm_simulator",
+ output="screen",
+ prefix=[XR_PYTHON],
+ parameters=[
+ _config_file("dual_arm_mujoco.yaml"),
+ {"robot_urdf_path": _dual_rm75_urdf()},
+ ],
+ )
+
+
+def _validate_mujoco_mode(arm: str, use_mujoco: bool) -> None:
+ if use_mujoco and arm != "both":
+ raise ValueError("use_mujoco:=true requires arm:=both")
+```
+
+在 `_launch_setup()` 读取:
+
+```python
+ use_mujoco = _as_bool(
+ LaunchConfiguration("use_mujoco").perform(context)
+ )
+```
+
+在已有 `arm` 校验后调用:
+
+```python
+ _validate_mujoco_mode(arm, use_mujoco)
+```
+
+在左右遥操作节点加入完成后追加:
+
+```python
+ if use_mujoco:
+ nodes.append(_mujoco_node())
+```
+
+在 `generate_launch_description()` 中 `use_mock` 后增加:
+
+```python
+ # true 时额外启动只读 MuJoCo 双臂显示,默认不改变现有启动行为。
+ DeclareLaunchArgument("use_mujoco", default_value="false"),
+```
+
+- [ ] **步骤 4:声明 bringup 对新包的运行依赖**
+
+在 `xr_rm_bringup/package.xml` 的 `xr_rm_input` 后增加:
+
+```xml
+ xr_rm_mujoco
+```
+
+- [ ] **步骤 5:运行 launch 测试并确认通过**
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+pytest src/xr_rm_bringup/test/test_arm_debug_launch.py -v
+```
+
+预期:全部 PASS;`use_mujoco` 默认关闭,只有 `arm:=both` 可以启用。
+
+- [ ] **步骤 6:提交 launch 集成**
+
+```bash
+git add \
+ src/xr_rm_bringup/package.xml \
+ src/xr_rm_bringup/launch/arm_debug.launch.py \
+ src/xr_rm_bringup/test/test_arm_debug_launch.py
+git commit -m "feat: 接入双臂 MuJoCo 启动模式"
+```
+
+### 任务六:更新文档并完成全量 Mock 验收
+
+**文件:**
+
+- 修改:`README.md`
+
+- [ ] **步骤 1:更新 README 的范围、结构与运行说明**
+
+在 README 中做以下明确修改:
+
+1. 在“已完成”加入 MuJoCo 运动学显示;
+2. 在结构树加入 `xr_rm_mujoco` 和 `dual_arm_mujoco.yaml`;
+3. 在 launch 参数加入 `use_mujoco`;
+4. 增加下面两条命令,并明确第二条会连接真机:
+
+```bash
+# 无真机:Mock 状态驱动 MuJoCo
+ros2 launch xr_rm_bringup arm_debug.launch.py \
+ arm:=both use_mock:=true use_mujoco:=true
+
+# 真机:实际反馈同步到 MuJoCo
+ros2 launch xr_rm_bringup arm_debug.launch.py \
+ arm:=both use_mock:=false use_mujoco:=true
+```
+
+5. 记录关节反馈话题、目标话题、60 Hz viewer、90 Hz MuJoCo 状态输入和真机原始
+ 200 Hz 反馈;
+6. 记录左 X/右 A Reset:Mock 立即复位且 Grip 保持时自动重锚,真机仍需松开 Grip;
+7. 明确 `move_to_initial_pose_on_connect` 继续保持 `false`。
+
+- [ ] **步骤 2:运行局部测试**
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+PYTHONPATH=src/xr_rm_mujoco \
+ /home/robot/miniconda3/envs/xr/bin/python -m pytest \
+ src/xr_rm_mujoco/test/test_dual_arm_simulator.py -v
+pytest src/xr_rm_teleop/test/test_joint_control.py -v
+pytest src/xr_rm_teleop/test/test_orientation_control.py -v
+pytest src/xr_rm_bringup/test/test_arm_debug_launch.py -v
+```
+
+预期:全部 PASS,不允许把跳过或收集失败报告为通过。
+
+- [ ] **步骤 3:构建整个工作空间并检查安装资源**
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+colcon build --symlink-install
+source install/setup.bash
+ros2 pkg executables xr_rm_mujoco
+test -f install/xr_rm_bringup/share/xr_rm_bringup/config/dual_arm_mujoco.yaml
+```
+
+预期:构建成功;`ros2 pkg executables` 显示
+`xr_rm_mujoco dual_arm_simulator`;配置文件检查返回 0。
+
+- [ ] **步骤 4:验证原有 Mock 默认路径不启动 MuJoCo**
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+source install/setup.bash
+timeout 15s ros2 launch xr_rm_bringup arm_debug.launch.py \
+ arm:=both use_mock:=true use_mujoco:=false
+```
+
+预期:左右 `left_arm_teleop`、`right_arm_teleop` 和 UDP receiver 正常启动,没有
+`dual_arm_simulator`;`timeout` 返回 124 属于预期。
+
+- [ ] **步骤 5:在有图形桌面的终端验证 MuJoCo Mock 链路**
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+source install/setup.bash
+ros2 launch xr_rm_bringup arm_debug.launch.py \
+ arm:=both use_mock:=true use_mujoco:=true
+```
+
+另开终端启动 PICO bridge 或现有 sample sender。确认:
+
+- viewer 收齐左右首帧后以 `dual_arm_rm75.yaml` 初始姿态打开;
+- 左右 Grip 分别驱动对应机械臂,不串臂;
+- 左 X、右 A 分别立即 Reset,Grip 保持时下一周期重新锚定并可继续运动;
+- `ros2 topic hz /xr_rm/left_rm75/joint_states` 和右侧话题接近 `90 Hz`;
+- 关闭 viewer 只结束 MuJoCo 节点,不改变遥操作节点。
+
+不得为完成本步骤使用 `use_mock:=false`。如果当前会话没有图形桌面,记录
+“未执行 viewer 人工检查:无 DISPLAY”,但模型测试、构建和非 viewer Mock 启动仍
+必须完成。
+
+- [ ] **步骤 6:检查安全配置未被改变**
+
+```bash
+cd /home/robot/WS_xr
+rg -n \
+ "configure_safety_limits: true|move_to_initial_pose_on_connect: false|control_rate_hz: 90.0" \
+ src/xr_rm_bringup/config/dual_arm_rm75.yaml \
+ src/xr_rm_bringup/config/left_arm_rm75.yaml \
+ src/xr_rm_bringup/config/right_arm_rm75.yaml
+```
+
+预期:三份配置仍保留安全限位、禁用连接即移动,并保持 90 Hz 控制频率;本任务只
+新增 `dual_arm_mujoco.yaml`,不改这些值。
+
+- [ ] **步骤 7:提交 README**
+
+```bash
+git add src/README.md
+git commit -m "docs: 补充双臂 MuJoCo 使用说明"
+```
+
+## 完成标准
+
+- `Dual_arm.urdf` 是 MuJoCo 和 Placo 唯一双臂模型源;
+- `use_mujoco` 默认关闭,现有 mock/真机命令行为不变;
+- Mock 和真机模式的 MuJoCo 输入均为当前适配器反馈,名义发布频率 90 Hz;
+- 真机原始反馈保持 5 ms 周期,MuJoCo viewer 为 60 Hz;
+- Mock A/X Reset 立即回 YAML 初始姿态并可在 Grip 保持时重新锚定;
+- 真机 Reset、安全限位、超时、停止和单 RealMan 连接行为不变;
+- 所有指定测试、colcon 构建和安装资源检查通过;
+- 未执行任何真机运动、夹爪操作、远程提交或推送。
diff --git a/docs/superpowers/specs/2026-08-04-dual-arm-mujoco-teleoperation-design.md b/docs/superpowers/specs/2026-08-04-dual-arm-mujoco-teleoperation-design.md
new file mode 100644
index 0000000..1b9ac3c
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-04-dual-arm-mujoco-teleoperation-design.md
@@ -0,0 +1,249 @@
+# 双臂 MuJoCo 运动学遥操作设计
+
+## 背景与目标
+
+当前项目已经通过 PICO/XR 手柄、两个独立的单臂遥操作节点和 Placo QP 完成双
+RM75 遥操作。左右节点共同加载
+`xr_rm_teleop/models/dual_rm75/Dual_arm.urdf`,但现有 `use_mock:=true` 只在内存中
+保存关节状态,没有可视化模型。
+
+本次变更增加一个独立的 MuJoCo 运动学仿真包,使双臂在不连接真机时可以由 PICO
+遥操作并可视化,也允许连接真机时把实际关节反馈同步显示在 MuJoCo 中。仿真用于更
+方便地观察和改进现有 QP 算法,不替代现有控制与安全链路。
+
+首版目标:
+
+- 直接加载现有双臂 URDF,保持它是唯一模型源;
+- 复用现有 PICO 输入、目标生成、工作空间限制和 Placo QP;
+- 使用一个 MuJoCo 进程显示完整 14 关节双臂模型;
+- 无真机时显示 Mock 关节状态,连接真机时显示实际关节反馈;
+- 支持 Mock 模式下用左手 X、右手 A 立即 Reset 对应机械臂;
+- 保持当前 mock、真机和夹爪功能的默认行为不变。
+
+首版不实现 MuJoCo 动力学、执行器、接触、碰撞约束、双臂协同 QP、轨迹记录或
+MuJoCo 对真机的任何控制。
+
+## 目录与包边界
+
+新增独立的 `ament_python` 包 `xr_rm_mujoco`,运行配置仍统一由
+`xr_rm_bringup` 管理:
+
+```text
+src/
+├── xr_rm_mujoco/
+│ ├── package.xml
+│ ├── setup.py
+│ ├── setup.cfg
+│ ├── resource/
+│ │ └── xr_rm_mujoco
+│ ├── xr_rm_mujoco/
+│ │ ├── __init__.py
+│ │ └── dual_arm_simulator.py
+│ └── test/
+│ └── test_dual_arm_simulator.py
+├── xr_rm_bringup/
+│ ├── config/
+│ │ ├── dual_arm_rm75.yaml
+│ │ └── dual_arm_mujoco.yaml
+│ └── launch/
+│ └── arm_debug.launch.py
+└── xr_rm_teleop/
+ ├── models/
+ │ └── dual_rm75/
+ │ └── Dual_arm.urdf
+ └── xr_rm_teleop/
+ └── single_arm_velocity_teleop.py
+```
+
+各部分职责:
+
+- `xr_rm_mujoco` 只加载模型、接收关节状态、更新 MuJoCo `qpos` 和刷新画面;
+- `xr_rm_teleop` 继续负责 PICO 映射、目标滤波、安全限幅、QP 和适配器选择,只
+ 增加关节目标及当前关节状态发布;
+- `xr_rm_bringup` 保持唯一遥操作 launch 入口,并保存 MuJoCo 运行参数;
+- `Dual_arm.urdf` 和现有 meshes 保持原位置,不复制或生成持久化 MJCF;
+- 不拆出新的 description 包,不增加第二套遥操作实现。
+
+## 模型与 MuJoCo 更新方式
+
+`dual_arm_simulator` 从安装空间解析
+`xr_rm_teleop/models/dual_rm75/Dual_arm.urdf`,MuJoCo 直接加载该文件及其相对路径
+网格。节点按 URDF 关节名称查找 MuJoCo qpos 地址,不写死 14 个数组下标。
+
+左右首帧合法关节状态到达后,节点把状态写入相应 `qpos`,调用 `mj_forward()`
+更新运动学,再由被动 viewer 显示。首版不调用 `mj_step()` 推进动力学,MuJoCo
+不会生成控制量或新的关节运动。
+
+画面按 `60 Hz` 刷新。`xr_rm_bringup/config/dual_arm_mujoco.yaml` 首版只包含:
+
+```yaml
+dual_arm_simulator:
+ ros__parameters:
+ render_rate_hz: 60.0
+```
+
+初始关节角不在该文件中重复配置。
+
+## ROS 话题与状态来源
+
+左右遥操作节点使用标准 `sensor_msgs/msg/JointState` 发布:
+
+| 话题 | 内容 |
+|---|---|
+| `/xr_rm/left_rm75/joint_states` | 左臂当前适配器反馈 |
+| `/xr_rm/right_rm75/joint_states` | 右臂当前适配器反馈 |
+| `/xr_rm/left_rm75/joint_target` | 左臂经关节限速后实际下发的目标 |
+| `/xr_rm/right_rm75/joint_target` | 右臂经关节限速后实际下发的目标 |
+
+MuJoCo 只订阅两个 `joint_states` 话题。`joint_target` 用于后续记录和比较,不驱动
+MuJoCo。消息必须携带对应侧完整的 7 个关节名称和位置,MuJoCo 按名称映射,不能
+依赖消息数组顺序。
+
+状态来源由现有 `use_mock` 唯一决定:
+
+```text
+use_mock:=true
+PICO → Placo QP → MockRealManAdapter → joint_states → MuJoCo
+
+use_mock:=false
+PICO → Placo QP → RealManAdapter → 真机
+ 真机实时反馈 → joint_states → MuJoCo
+```
+
+每个遥操作节点只创建一种适配器。真机连接或反馈失败时不得创建、切换或回退到
+Mock。MuJoCo 不需要独立的状态来源参数;同一状态话题发现多个发布者时输出明确
+报警,防止同时运行两套 launch 造成状态混合。
+
+## 更新频率
+
+两侧 `dual_arm_rm75.yaml` 的 `control_rate_hz` 均为 `90.0`:
+
+- Mock 模式:Mock 状态在遥操作节点的 `90 Hz` 控制周期中读取并发布,MuJoCo
+ 名义关节接收频率为 `90 Hz`;
+- 真机模式:RealMan 的 `realtime_push_cycle_ms: 5` 使适配器原始反馈名义频率为
+ `200 Hz`,遥操作节点在 `90 Hz` 控制周期取最新快照并发布,因此 MuJoCo 名义
+ 关节接收频率仍为 `90 Hz`;
+- 画面独立按 `render_rate_hz: 60.0` 刷新,每帧显示当时最新的 14 关节状态。
+
+以上是名义频率,实际频率会受系统调度影响,运行时使用 `ros2 topic hz` 检查。
+
+## 初始姿态与 A/X Reset
+
+`dual_arm_rm75.yaml` 继续作为双臂初始姿态和控制限制的唯一配置源。Mock 适配器
+创建时已经读取对应节点的 `initial_joint_pose`,将角度转换成弧度并作为初始关节
+状态。左右遥操作节点初始化完成后立即各发布一帧状态,因此无真机 MuJoCo 的默认
+姿态就是 YAML 中的左右初始姿态。
+
+现有 `XrController.primary` 和按键上升沿逻辑继续复用:
+
+```text
+左手 X → 左臂立即 Reset 到左臂 initial_joint_pose
+右手 A → 右臂立即 Reset 到右臂 initial_joint_pose
+同时按 X、A → 双臂分别立即 Reset
+```
+
+Mock Reset 不生成平滑轨迹,而是立即更新对应 7 个关节并发布新状态。Reset 前先
+退出旧的相对位姿控制;如果 Grip 仍保持按下,下一控制周期使用“当前手柄姿态 +
+Reset 后机械臂姿态”自动建立新基准,随后可以继续遥操作,不要求先松开 Grip,
+也不能沿用 Reset 前的相对位姿基准。
+
+真机的 A/X 回位行为保持现状:调用 RealMan 初始位姿运动,完成后重新同步反馈,
+并要求先松开 Grip 才能重新使能。该差异只由 `use_mock` 决定。
+
+三份 RM75 配置中的 `move_to_initial_pose_on_connect` 默认继续保持 `false`。MuJoCo
+初始显示和按键 Reset 都不依赖该开关,连接真机时不得默认自动移动双臂。
+
+## 控制限制与安全隔离
+
+Mock + MuJoCo 继续执行 `dual_arm_rm75.yaml` 中现有的软件控制约束:
+
+- `workspace_min`、`workspace_max`、`cyl_radius_limit` 和低位圆柱限制;
+- `max_linear_speed` 和 `max_orientation_speed`;
+- `joint_max_speed` 和 `joint_max_acc`;
+- Placo 的关节位置、速度和求解收敛检查;
+- Grip 运动门控、XR/反馈超时、QP 失败保持和安全停止。
+
+MuJoCo 直接显示已经受限的离散关节状态,本身不额外模拟连续动力学。
+`max_line_speed`、`max_angular_speed`、`max_line_acc`、`max_angular_acc` 以及
+`configure_safety_limits` 是 RealMan 控制器配置,只在真机适配器中调用;这不影响
+上述对 Mock 同样生效的软件限位。
+
+MuJoCo 节点只订阅状态,不发布机器人控制指令,不导入 RealMan SDK,也不创建新的
+RealMan 连接。MuJoCo 启动失败、运行异常或窗口关闭不得改变真机命令、安全停止或
+夹爪行为。
+
+## 启动设计
+
+继续使用唯一入口 `xr_rm_bringup/launch/arm_debug.launch.py`,增加默认关闭的
+`use_mujoco` 参数:
+
+| `use_mock` | `use_mujoco` | 行为 |
+|---|---|---|
+| `true` | `false` | 现有内存 Mock,无 MuJoCo |
+| `true` | `true` | Mock + MuJoCo 双臂显示 |
+| `false` | `false` | 现有双臂真机遥操作 |
+| `false` | `true` | 双臂真机遥操作 + 实际反馈同步显示 |
+
+`use_mujoco` 不参与适配器选择。首版只接受
+`arm:=both use_mujoco:=true`,避免单臂启动时另一侧状态和初始姿态不明确。
+
+无真机使用方式:
+
+```bash
+ros2 launch xr_rm_bringup arm_debug.launch.py \
+ arm:=both use_mock:=true use_mujoco:=true
+```
+
+真机同步显示方式:
+
+```bash
+ros2 launch xr_rm_bringup arm_debug.launch.py \
+ arm:=both use_mock:=false use_mujoco:=true
+```
+
+第二条命令会连接并控制真机,只能在完成现有真机安全检查后使用。所有自动化和首次
+集成验收只运行 `use_mock:=true`。
+
+MuJoCo 进程使用项目现有的 XR Conda Python,因为本机 MuJoCo 与 Placo 均安装在
+该环境中。未启用 `use_mujoco` 时不启动或导入 MuJoCo,新包不能让现有 mock 模式
+强制依赖厂商 SDK。
+
+## 校验与异常处理
+
+- URDF、网格或 MuJoCo 加载失败:MuJoCo 节点明确报错并退出,现有遥操节点不改变;
+- 收到关节缺失、重复、数量错误或包含 NaN/Inf 的消息:拒绝整帧并保持上一姿态;
+- 尚未收齐左右首帧状态:等待并报告缺失侧,不把零位姿冒充有效初始姿态;
+- 任一侧状态暂时中断:保持该侧最后有效姿态,不生成运动、不切换来源;
+- 同一状态话题存在多个发布者:输出明确报警;
+- viewer 关闭:只结束 MuJoCo 显示,不触发或改变机器人运动。
+
+## 测试与验收
+
+使用现有 pytest、ROS2 Humble 和 colcon,不增加测试框架,不连接真机。
+
+最小自动化覆盖:
+
+- MuJoCo 可以直接加载现有双臂 URDF;
+- 14 个活动关节名称与左右 qpos 映射正确,消息顺序变化不会串臂;
+- YAML 初始角度经 Mock 转换后能正确写入 MuJoCo;
+- 非法关节消息不会部分污染当前状态;
+- Mock A/X Reset 后回到对应 YAML 姿态;
+- Reset 时 Grip 保持按下能够重新锚定并继续控制;
+- 真机路径仍保留 Grip 松开后重新使能要求;
+- `use_mujoco` 默认关闭,现有三种 mock/真机启动行为不变。
+
+在工作空间根目录执行:
+
+```bash
+cd /home/robot/WS_xr
+source /opt/ros/humble/setup.bash
+/home/robot/miniconda3/envs/xr/bin/python -m pytest \
+ src/xr_rm_mujoco/test/test_dual_arm_simulator.py -v
+pytest src/xr_rm_teleop/test/test_joint_control.py -v
+pytest src/xr_rm_teleop/test/test_orientation_control.py -v
+colcon build --symlink-install
+```
+
+构建后只用 Mock 启动并通过 PICO 或 sample UDP 检查:左右模型初始姿态、独立运动、
+A/X Reset、Reset 后继续遥操作、话题频率和关闭 viewer 后遥操作节点状态。不得在
+自动化验收中使用 `use_mock:=false`。