feat: Implement UDP feedback for RM75 robot arms
This commit is contained in:
@@ -207,23 +207,18 @@ ros2 launch xr_rm_bringup arm_debug.launch.py arm:=left use_mock:=false
|
|||||||
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=false
|
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=false
|
||||||
```
|
```
|
||||||
|
|
||||||
所有配置默认都不会执行 `movej(initial_joint_pose)`;只有确认安全区清空后,才可显式传入
|
所有 YAML 默认都不会执行 `movej(initial_joint_pose)`。只有确认安全区清空后,才可在当前使用的
|
||||||
`move_to_initial_pose_on_connect:=true`。
|
`left_arm_rm75.yaml`、`right_arm_rm75.yaml` 或 `dual_arm_rm75.yaml` 中将
|
||||||
|
`move_to_initial_pose_on_connect` 改为 `true`。
|
||||||
|
|
||||||
第四步:双臂真机。
|
第四步:双臂真机。
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false \
|
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false
|
||||||
left_robot_ip:=192.168.192.18 \
|
|
||||||
right_robot_ip:=192.168.192.19
|
|
||||||
```
|
```
|
||||||
|
|
||||||
双臂默认不会自动移动到初始化点。以后需要启用时,在确认安全区清空后显式打开:
|
双臂默认不会自动移动到初始化点;机器人地址、控制参数和初始化移动开关均从
|
||||||
|
`dual_arm_rm75.yaml` 读取。
|
||||||
```bash
|
|
||||||
ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false \
|
|
||||||
move_to_initial_pose_on_connect:=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## Launch 入口说明
|
## Launch 入口说明
|
||||||
|
|
||||||
@@ -236,19 +231,9 @@ ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false \
|
|||||||
- `udp_host`:UDP 监听地址,默认 `0.0.0.0`。
|
- `udp_host`:UDP 监听地址,默认 `0.0.0.0`。
|
||||||
- `udp_port`:UDP 监听端口,默认 `15000`。
|
- `udp_port`:UDP 监听端口,默认 `15000`。
|
||||||
- `udp_timer_hz`:UDP receiver 轮询频率,默认 `200.0`。
|
- `udp_timer_hz`:UDP receiver 轮询频率,默认 `200.0`。
|
||||||
- `left_robot_ip`:左臂 IP,默认 `192.168.192.18`。
|
|
||||||
- `right_robot_ip`:右臂 IP,默认 `192.168.192.19`。
|
机器人 IP/端口、控制频率、CANFD、限速、工具和初始化位姿等行为参数只由对应 YAML
|
||||||
- `robot_port`:RM75 TCP 端口,默认 `8080`。
|
配置,launch 不再提供同名覆盖项。
|
||||||
- `left_avoid_singularity` / `right_avoid_singularity`:左右臂避奇异参数,默认左 `0`、右 `1`。
|
|
||||||
- `avoid_singularity`:非空时覆盖左右臂避奇异参数。
|
|
||||||
- `control_rate_hz`:同步关节反馈、执行一次 QP 并发送一次关节目标的频率,默认 `125.0`。
|
|
||||||
- `follow`:传给 `rm_movej_canfd` 的跟随标志,默认 `false`。
|
|
||||||
- `configure_safety_limits`:连接真机后是否配置速度/加速度安全参数,默认 `true`。
|
|
||||||
- `enable_tool_control`:是否在遥操作节点内启用末端工具控制 topic,默认 `true`。
|
|
||||||
- `enable_trigger_gripper_control`:是否允许用 `trigger` 点击切换对应夹爪状态,默认 `true`。
|
|
||||||
- `trigger_close_threshold`:trigger 点击判定阈值,默认 `0.95`。
|
|
||||||
- `configure_peripheral_on_connect`:遥操作节点连接真机后是否配置末端外设,默认 `true`;工具控制会复用同一个 RealMan 连接,避免两个进程同时抢占同一机械臂。
|
|
||||||
- `move_to_initial_pose_on_connect`:连接后是否执行 `movej(initial_joint_pose)`;默认 `auto`,沿用 YAML 中的 `false`,也可显式传 `true`/`false` 覆盖。
|
|
||||||
|
|
||||||
## 配置文件说明
|
## 配置文件说明
|
||||||
|
|
||||||
@@ -274,6 +259,13 @@ ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false \
|
|||||||
- `workspace_min` / `workspace_max`:笛卡尔工作空间边界。
|
- `workspace_min` / `workspace_max`:笛卡尔工作空间边界。
|
||||||
- `cyl_radius_limit`:基座圆柱半径限制。
|
- `cyl_radius_limit`:基座圆柱半径限制。
|
||||||
- `xr_to_robot_matrix`:`/xr/*_controller` Project 位移到 RM75 base 坐标的映射矩阵。
|
- `xr_to_robot_matrix`:`/xr/*_controller` Project 位移到 RM75 base 坐标的映射矩阵。
|
||||||
|
- `robot_ip` / `robot_port`:RM75 TCP 控制连接地址。
|
||||||
|
- `realtime_push_host_ip`:连接机械臂 Wi-Fi 后本机实际 IPv4;可用
|
||||||
|
`ip -4 route get 192.168.192.19` 查看输出中的 `src`,当前为 `192.168.192.148`。
|
||||||
|
- `realtime_push_port`:UDP 主动反馈端口;左臂 `8089`、右臂 `8090`,同机双臂不能重复。
|
||||||
|
- `realtime_push_cycle_ms`:UDP 主动反馈周期,当前为厂商支持的 `5 ms`。
|
||||||
|
- `follow` / `canfd_trajectory_mode`:`rm_movej_canfd` 的高跟随和轨迹模式参数。
|
||||||
|
- 当前三份 YAML 默认均使用 `follow: false` 完成安全基线验证;确认关节加速度与反馈稳定后,再单独测试高跟随。
|
||||||
- `initial_joint_pose`:mock 的初始关节反馈,以及显式开启初始化移动时的真机初始关节角。
|
- `initial_joint_pose`:mock 的初始关节反馈,以及显式开启初始化移动时的真机初始关节角。
|
||||||
|
|
||||||
当前 `/xr/*_controller` 的 Project 坐标约定:
|
当前 `/xr/*_controller` 的 Project 坐标约定:
|
||||||
@@ -432,7 +424,7 @@ ros2 topic echo /xr/right_controller --field trigger
|
|||||||
|
|
||||||
1. 确认急停、网络、机械臂工作区和人员位置。
|
1. 确认急停、网络、机械臂工作区和人员位置。
|
||||||
2. `launcher_ui.py` 中先 `Ping Left RM75` 或 `Ping Right RM75`。
|
2. `launcher_ui.py` 中先 `Ping Left RM75` 或 `Ping Right RM75`。
|
||||||
3. 单臂启动,`move_to_initial_pose_on_connect:=false`。
|
3. 确认对应 YAML 中 `move_to_initial_pose_on_connect: false` 后单臂启动。
|
||||||
4. 手握急停,按住 `grip` 后只做小幅单轴移动。
|
4. 手握急停,按住 `grip` 后只做小幅单轴移动。
|
||||||
5. 逐个确认上/下、前/后、左/右方向。
|
5. 逐个确认上/下、前/后、左/右方向。
|
||||||
6. 小角度转动手柄,确认 `/xr_rm/<arm>/target_pose` 姿态和 `/xr_rm/<arm>/cmd_vel.twist.angular` 变化符合预期。
|
6. 小角度转动手柄,确认 `/xr_rm/<arm>/target_pose` 姿态和 `/xr_rm/<arm>/cmd_vel.twist.angular` 变化符合预期。
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
# RM75 CANFD UDP Feedback 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:** Replace synchronous TCP joint polling with the vendor UDP realtime callback while making YAML the source of robot behavior and hardware defaults.
|
||||||
|
|
||||||
|
**Architecture:** Keep one `RoboticArm(RM_TRIPLE_MODE_E)` handle per arm. TCP sends CANFD and safety/tool commands; a 5 ms controller UDP push invokes a minimal callback that updates the existing locked joint snapshot. Launch keeps only topology, mock safety mode, PICO input, and generated paths/topics.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3.10, ROS2 Humble, RealMan Python API2, YAML, pytest, colcon
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add failing UDP feedback adapter tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `xr_rm_teleop/test/test_initial_joint_pose.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace polling-specific tests with UDP callback tests**
|
||||||
|
|
||||||
|
Add `sys`, `types`, and `SimpleNamespace` imports. Replace
|
||||||
|
`test_joint_feedback_is_cached_in_radians` and
|
||||||
|
`test_feedback_loop_uses_absolute_schedule_without_catch_up` with helpers and
|
||||||
|
tests equivalent to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _udp_state(robot_ip="127.0.0.1", joints=None, error_code=0):
|
||||||
|
return SimpleNamespace(
|
||||||
|
errCode=error_code,
|
||||||
|
arm_ip=robot_ip.encode(),
|
||||||
|
joint_status=SimpleNamespace(
|
||||||
|
joint_position=joints or [0.0, 10.0, -20.0, 30.0, -40.0, 50.0, -60.0]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_udp_feedback_is_cached_in_radians(monkeypatch) -> None:
|
||||||
|
monotonic = iter([10.0, 10.005])
|
||||||
|
monkeypatch.setattr(realman_adapter.time, "monotonic", lambda: next(monotonic))
|
||||||
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"127.0.0.1",
|
||||||
|
8090,
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter._accept_realtime_feedback = True
|
||||||
|
adapter._on_realtime_arm_state(_udp_state())
|
||||||
|
first = adapter.get_latest_joint_state()
|
||||||
|
adapter._on_realtime_arm_state(_udp_state())
|
||||||
|
second = adapter.get_latest_joint_state()
|
||||||
|
|
||||||
|
assert first is not None
|
||||||
|
assert first.positions == pytest.approx(
|
||||||
|
[math.radians(value) for value in [0, 10, -20, 30, -40, 50, -60]]
|
||||||
|
)
|
||||||
|
assert first.read_duration_ms is None
|
||||||
|
assert first.update_interval_ms is None
|
||||||
|
assert second is not None
|
||||||
|
assert second.update_interval_ms == pytest.approx(5.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"state",
|
||||||
|
[
|
||||||
|
_udp_state(error_code=-3),
|
||||||
|
_udp_state(robot_ip="192.168.192.18"),
|
||||||
|
_udp_state(joints=[0.0] * 6),
|
||||||
|
_udp_state(joints=[0.0, 0.0, 0.0, math.nan, 0.0, 0.0, 0.0]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_udp_feedback_does_not_replace_snapshot(state) -> None:
|
||||||
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"127.0.0.1",
|
||||||
|
8090,
|
||||||
|
)
|
||||||
|
adapter._accept_realtime_feedback = True
|
||||||
|
adapter._on_realtime_arm_state(_udp_state(joints=[1.0] * 7))
|
||||||
|
before = adapter.get_latest_joint_state()
|
||||||
|
|
||||||
|
adapter._on_realtime_arm_state(state)
|
||||||
|
|
||||||
|
assert adapter.get_latest_joint_state() == before
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a fake vendor module that records callback registration and push config.
|
||||||
|
Its `rm_set_realtime_push()` invokes the registered callback with `_udp_state()`.
|
||||||
|
Assert:
|
||||||
|
|
||||||
|
```python
|
||||||
|
adapter.connect()
|
||||||
|
arm = fake_module.RoboticArm.instance
|
||||||
|
assert arm.config.args == (5, True, 8090, 0, "192.168.192.148")
|
||||||
|
assert arm.callback is adapter._realtime_callback
|
||||||
|
assert adapter.get_latest_joint_state() is not None
|
||||||
|
assert not hasattr(adapter, "_feedback_thread")
|
||||||
|
```
|
||||||
|
|
||||||
|
Add failure cases where `rm_set_realtime_push()` returns `1`, and where
|
||||||
|
`adapter._feedback_ready.wait` returns `False`. Both must raise `RuntimeError`;
|
||||||
|
the fake arm must record one `rm_delete_robot_arm()` call.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the focused tests and verify RED**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/robot/WS_xr
|
||||||
|
source /opt/ros/humble/setup.bash
|
||||||
|
python3 -m pytest -q src/xr_rm_teleop/test/test_initial_joint_pose.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because `RealManAdapter` does not accept realtime push
|
||||||
|
parameters and has no `_on_realtime_arm_state`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Implement single-handle UDP feedback
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `xr_rm_teleop/xr_rm_teleop/realman_adapter.py`
|
||||||
|
- Modify: `xr_rm_teleop/xr_rm_teleop/single_arm_velocity_teleop.py`
|
||||||
|
- Modify: `xr_rm_teleop/test/test_initial_joint_pose.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace polling constructor state with realtime push state**
|
||||||
|
|
||||||
|
Change `RealManAdapter.__init__` positional parameters from `feedback_period`
|
||||||
|
to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
realtime_push_host_ip: str,
|
||||||
|
realtime_push_port: int,
|
||||||
|
realtime_push_cycle_ms: int = 5,
|
||||||
|
```
|
||||||
|
|
||||||
|
Validate with stdlib `ipaddress.IPv4Address`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
self._realtime_push_host_ip = str(
|
||||||
|
ipaddress.IPv4Address(realtime_push_host_ip)
|
||||||
|
)
|
||||||
|
except ipaddress.AddressValueError as exc:
|
||||||
|
raise ValueError("realtime_push_host_ip must be a valid IPv4 address") from exc
|
||||||
|
if not 1 <= realtime_push_port <= 65535:
|
||||||
|
raise ValueError("realtime_push_port must be between 1 and 65535")
|
||||||
|
if realtime_push_cycle_ms <= 0 or realtime_push_cycle_ms % 5 != 0:
|
||||||
|
raise ValueError("realtime_push_cycle_ms must be a positive multiple of 5")
|
||||||
|
```
|
||||||
|
|
||||||
|
Store the port and cycle, then replace feedback thread members with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._feedback_ready = threading.Event()
|
||||||
|
self._realtime_callback: Any | None = None
|
||||||
|
self._accept_realtime_feedback = False
|
||||||
|
self._feedback_fault_logged = False
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Configure callback and UDP push during connect**
|
||||||
|
|
||||||
|
Import these SDK symbols inside `connect()` so mock mode stays SDK-free:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from Robotic_Arm.rm_robot_interface import (
|
||||||
|
RoboticArm,
|
||||||
|
rm_realtime_arm_state_callback_ptr,
|
||||||
|
rm_realtime_push_config_t,
|
||||||
|
rm_thread_mode_e,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
After existing safety and optional initial-pose configuration:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self._feedback_ready.clear()
|
||||||
|
self._accept_realtime_feedback = True
|
||||||
|
self._realtime_callback = rm_realtime_arm_state_callback_ptr(
|
||||||
|
self._on_realtime_arm_state
|
||||||
|
)
|
||||||
|
self._arm.rm_realtime_arm_state_call_back(self._realtime_callback)
|
||||||
|
config = rm_realtime_push_config_t(
|
||||||
|
self._realtime_push_cycle_ms,
|
||||||
|
True,
|
||||||
|
self._realtime_push_port,
|
||||||
|
0,
|
||||||
|
self._realtime_push_host_ip,
|
||||||
|
)
|
||||||
|
self._check_return(
|
||||||
|
self._arm.rm_set_realtime_push(config),
|
||||||
|
"rm_set_realtime_push",
|
||||||
|
)
|
||||||
|
if not self._feedback_ready.wait(timeout=2.0):
|
||||||
|
raise RuntimeError(
|
||||||
|
"RealMan UDP realtime feedback did not receive a valid frame within 2 seconds"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Wrap post-handle initialization so any exception disables callback acceptance,
|
||||||
|
deletes the handle, sets `_arm = None`, and re-raises.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the bounded callback**
|
||||||
|
|
||||||
|
Replace `_feedback_loop()` and `_read_joint_state_once()` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _on_realtime_arm_state(self, data: Any) -> None:
|
||||||
|
if not self._accept_realtime_feedback:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if data is None or int(data.errCode) != 0:
|
||||||
|
raise ValueError("invalid realtime feedback error code")
|
||||||
|
arm_ip = data.arm_ip
|
||||||
|
if isinstance(arm_ip, bytes):
|
||||||
|
arm_ip = arm_ip.decode("utf-8").split("\x00", 1)[0]
|
||||||
|
if str(arm_ip) != self._robot_ip:
|
||||||
|
raise ValueError(f"unexpected realtime feedback source: {arm_ip}")
|
||||||
|
degrees = list(data.joint_status.joint_position)
|
||||||
|
if (
|
||||||
|
len(degrees) != 7
|
||||||
|
or not all(isinstance(value, Number) for value in degrees)
|
||||||
|
):
|
||||||
|
raise ValueError("RM75 UDP feedback must contain 7 numeric joints")
|
||||||
|
positions = [math.radians(float(value)) for value in degrees]
|
||||||
|
if not all(math.isfinite(value) for value in positions):
|
||||||
|
raise ValueError("RM75 UDP feedback contains NaN/Inf")
|
||||||
|
received_at = time.monotonic()
|
||||||
|
with self._joint_state_lock:
|
||||||
|
update_interval_ms = (
|
||||||
|
None
|
||||||
|
if self._latest_joint_state is None
|
||||||
|
else (received_at - self._latest_joint_state.received_at) * 1000.0
|
||||||
|
)
|
||||||
|
self._latest_joint_state = JointStateSnapshot(
|
||||||
|
positions,
|
||||||
|
received_at,
|
||||||
|
None,
|
||||||
|
update_interval_ms,
|
||||||
|
)
|
||||||
|
self._feedback_fault_logged = False
|
||||||
|
self._feedback_ready.set()
|
||||||
|
except Exception as exc:
|
||||||
|
if not self._feedback_fault_logged:
|
||||||
|
self._log_warn(f"RealMan UDP realtime feedback invalid: {exc}")
|
||||||
|
self._feedback_fault_logged = True
|
||||||
|
```
|
||||||
|
|
||||||
|
In `close()`, set `_accept_realtime_feedback = False` before slow-stop and
|
||||||
|
handle deletion. Remove feedback thread stop/join logic. Keep the callback
|
||||||
|
reference alive until after `rm_delete_robot_arm()`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Declare and pass ROS parameters**
|
||||||
|
|
||||||
|
In `SingleArmVelocityTeleop`, declare:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.declare_parameter("realtime_push_host_ip", "")
|
||||||
|
self.declare_parameter("realtime_push_port", 0)
|
||||||
|
self.declare_parameter("realtime_push_cycle_ms", 5)
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `feedback_period=self._dt` in `_make_adapter()` with:
|
||||||
|
|
||||||
|
```python
|
||||||
|
realtime_push_host_ip=str(
|
||||||
|
self.get_parameter("realtime_push_host_ip").value
|
||||||
|
),
|
||||||
|
realtime_push_port=int(
|
||||||
|
self.get_parameter("realtime_push_port").value
|
||||||
|
),
|
||||||
|
realtime_push_cycle_ms=int(
|
||||||
|
self.get_parameter("realtime_push_cycle_ms").value
|
||||||
|
),
|
||||||
|
```
|
||||||
|
|
||||||
|
Update all direct `RealManAdapter(...)` calls in tests to pass a host and port.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run focused tests and verify GREEN**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/robot/WS_xr
|
||||||
|
source /opt/ros/humble/setup.bash
|
||||||
|
python3 -m pytest -q src/xr_rm_teleop/test/test_initial_joint_pose.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all focused tests pass, with no real SDK connection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Move robot defaults into YAML and simplify launch
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `xr_rm_bringup/config/right_arm_rm75.yaml`
|
||||||
|
- Modify: `xr_rm_bringup/config/left_arm_rm75.yaml`
|
||||||
|
- Modify: `xr_rm_bringup/config/dual_arm_rm75.yaml`
|
||||||
|
- Modify: `xr_rm_bringup/launch/arm_debug.launch.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run a failing ownership assertion**
|
||||||
|
|
||||||
|
Run a one-off Python assertion that requires the three YAMLs to contain UDP
|
||||||
|
and tool parameters, and requires launch not to declare robot behavior
|
||||||
|
arguments:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pathlib import Path
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
config_dir = Path("xr_rm_bringup/config")
|
||||||
|
for name in ("left_arm_rm75.yaml", "right_arm_rm75.yaml"):
|
||||||
|
params = yaml.safe_load((config_dir / name).read_text())
|
||||||
|
params = params["single_arm_velocity_teleop"]["ros__parameters"]
|
||||||
|
assert "use_mock" not in params
|
||||||
|
assert params["realtime_push_host_ip"] == "192.168.192.148"
|
||||||
|
assert params["realtime_push_cycle_ms"] == 5
|
||||||
|
assert params["enable_tool_control"] is True
|
||||||
|
|
||||||
|
source = Path("xr_rm_bringup/launch/arm_debug.launch.py").read_text()
|
||||||
|
for name in (
|
||||||
|
"left_robot_ip",
|
||||||
|
"right_robot_ip",
|
||||||
|
"robot_port",
|
||||||
|
"avoid_singularity",
|
||||||
|
"control_rate_hz",
|
||||||
|
"follow",
|
||||||
|
"configure_safety_limits",
|
||||||
|
"move_to_initial_pose_on_connect",
|
||||||
|
):
|
||||||
|
assert f'DeclareLaunchArgument("{name}"' not in source
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: FAIL because the YAML parameters are missing and launch still
|
||||||
|
declares overrides.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update all YAML nodes**
|
||||||
|
|
||||||
|
Remove `use_mock`. Add:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
realtime_push_host_ip: 192.168.192.148
|
||||||
|
realtime_push_cycle_ms: 5
|
||||||
|
enable_tool_control: true
|
||||||
|
enable_trigger_gripper_control: true
|
||||||
|
trigger_close_threshold: 0.95
|
||||||
|
configure_peripheral_on_connect: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `realtime_push_port: 8089` for left-arm nodes and `8090` for right-arm
|
||||||
|
nodes. Keep:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# all single-arm and dual-arm nodes
|
||||||
|
follow: false
|
||||||
|
canfd_trajectory_mode: 2
|
||||||
|
```
|
||||||
|
|
||||||
|
The right-arm high-follow default was reverted after the first hardware test
|
||||||
|
exposed an unplanned stationary null-space trajectory. Do not change speeds,
|
||||||
|
workspace limits, timeouts, safety limits, or initial pose defaults.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Reduce launch overrides**
|
||||||
|
|
||||||
|
Make `_single_arm_node(arm, use_mock)` and `_dual_arm_nodes(use_mock)` load
|
||||||
|
their YAML first, then pass only:
|
||||||
|
|
||||||
|
```python
|
||||||
|
{
|
||||||
|
"use_mock": use_mock,
|
||||||
|
"robot_urdf_path": _rm75_urdf(),
|
||||||
|
"peripheral_config_file": _config_file("peripherals_rm75.yaml"),
|
||||||
|
"peripheral_arm": arm,
|
||||||
|
"tool_command_topic": f"/xr_rm/{_arm_name(arm)}/tool_enable",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep equivalent per-side generated values in dual mode. Remove
|
||||||
|
`_initial_pose_override`, robot IP/port, avoid-singularity, control-rate,
|
||||||
|
follow, safety, tool-control and initial-pose parsing from `_launch_setup`.
|
||||||
|
|
||||||
|
Keep only these launch arguments:
|
||||||
|
|
||||||
|
```python
|
||||||
|
DeclareLaunchArgument("arm", default_value="right")
|
||||||
|
DeclareLaunchArgument("use_mock", default_value="true")
|
||||||
|
DeclareLaunchArgument("udp_host", default_value="0.0.0.0")
|
||||||
|
DeclareLaunchArgument("udp_port", default_value="15000")
|
||||||
|
DeclareLaunchArgument("udp_timer_hz", default_value="200.0")
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-run ownership assertion and inspect launch arguments**
|
||||||
|
|
||||||
|
Run the assertion from Step 1, then:
|
||||||
|
|
||||||
|
```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 --show-args
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the assertion passes; launch lists only `arm`, `use_mock`,
|
||||||
|
`udp_host`, `udp_port`, and `udp_timer_hz`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Synchronize launcher UI and README
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `xr_rm_bringup/tools/launcher_ui.py`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove deleted launch arguments from UI commands**
|
||||||
|
|
||||||
|
Keep ping targets unchanged. Change real launch commands to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=left use_mock:=false"
|
||||||
|
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=false"
|
||||||
|
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Update README ownership and commands**
|
||||||
|
|
||||||
|
Remove examples and launch-argument descriptions for robot IP/port,
|
||||||
|
avoid-singularity, control-rate, follow, safety/tool flags, and initial-pose
|
||||||
|
overrides. State that these values live in the selected YAML. Add the UDP
|
||||||
|
feedback parameters, host `192.168.192.148`, ports `8089/8090`, 5 ms cycle,
|
||||||
|
and the command used after Wi-Fi changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ip -4 route get 192.168.192.19
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `arm`, `use_mock`, and PICO UDP arguments documented as launch
|
||||||
|
arguments. Keep the warning that checked-in default `use_mock=true` prevents
|
||||||
|
an accidental real connection.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Check syntax and stale references**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m py_compile \
|
||||||
|
xr_rm_bringup/launch/arm_debug.launch.py \
|
||||||
|
xr_rm_bringup/tools/launcher_ui.py
|
||||||
|
rg -n "left_robot_ip:=|right_robot_ip:=|move_to_initial_pose_on_connect:=" \
|
||||||
|
README.md xr_rm_bringup/tools/launcher_ui.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: compilation passes; `rg` returns no stale command-line overrides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Full verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Verify all files changed by Tasks 1–4
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run teleop tests**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/robot/WS_xr
|
||||||
|
source /opt/ros/humble/setup.bash
|
||||||
|
python3 -m pytest -q src/xr_rm_teleop/test
|
||||||
|
python3 -m pytest -q src/xr_rm_teleop/test/test_orientation_control.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests pass.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build all workspace packages**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/robot/WS_xr
|
||||||
|
source /opt/ros/humble/setup.bash
|
||||||
|
colcon build --symlink-install --executor sequential
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `xr_rm_interfaces`, `xr_rm_input`, `xr_rm_teleop`, and
|
||||||
|
`xr_rm_bringup` all finish successfully.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify mock launch without vendor hardware**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/robot/WS_xr
|
||||||
|
source /opt/ros/humble/setup.bash
|
||||||
|
source install/setup.bash
|
||||||
|
timeout 8s ros2 launch xr_rm_bringup arm_debug.launch.py \
|
||||||
|
arm:=right use_mock:=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: the mock teleop and UDP input nodes start; timeout ends the launch.
|
||||||
|
No RealMan SDK connection is attempted.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Inspect final diff**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/robot/WS_xr/src
|
||||||
|
git diff --check
|
||||||
|
git status --short
|
||||||
|
git diff --stat
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no whitespace errors and no unrelated files. Do not commit, push,
|
||||||
|
or connect to the real robot unless the user explicitly requests it.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# RM75 Right-Arm High-Follow YAML Defaults 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:** Make right-arm single-arm debugging default to RealMan high-follow complete passthrough for phase-two testing.
|
||||||
|
|
||||||
|
**Architecture:** Change only the existing right-arm YAML parameters. Keep launch, left-arm, dual-arm, speed limits, safety limits, timeouts, and stop behavior unchanged.
|
||||||
|
|
||||||
|
**Tech Stack:** ROS2 Humble, YAML, pytest, colcon
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Change right-arm CANFD defaults
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `xr_rm_bringup/config/right_arm_rm75.yaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run a failing configuration assertion**
|
||||||
|
|
||||||
|
Run a Python YAML assertion requiring `follow is True` and
|
||||||
|
`canfd_trajectory_mode == 0`.
|
||||||
|
|
||||||
|
Expected: FAIL because the current values are `false` and `2`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Apply the minimal configuration change**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
follow: true
|
||||||
|
canfd_trajectory_mode: 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify configuration and regressions**
|
||||||
|
|
||||||
|
Run the same YAML assertion and expect PASS. Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source /opt/ros/humble/setup.bash
|
||||||
|
python3 -m pytest -q src/xr_rm_teleop/test
|
||||||
|
colcon build --symlink-install --executor sequential
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: all tests and all four workspace packages pass.
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# RM75 CANFD UDP 主动反馈设计
|
||||||
|
|
||||||
|
> 真机验证修正:右臂高跟随首轮测试触发掉使能。离线复现发现静止目标存在
|
||||||
|
> `23.582°` 空空间漂移,第一周期约 `3315°/s²`。当前实现已移除
|
||||||
|
> manipulability 自运动、增加软件关节加速度限幅和故障 Grip 锁存,并将三份
|
||||||
|
> YAML 恢复为 `follow: false` 安全基线;高跟随须在基线验证后单独测试。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
按睿尔曼 MovejCANFD 示例,将真机关节反馈从同一 TCP 控制连接上的
|
||||||
|
`rm_get_joint_degree()` 周期轮询,替换为控制器 UDP 主动状态推送。
|
||||||
|
|
||||||
|
控制命令继续通过现有单个 `RoboticArm(RM_TRIPLE_MODE_E)` 句柄发送,不新增
|
||||||
|
RealMan 连接,不修改 Placo QP、工作空间/圆柱限位、速度限制、指令超时或安全
|
||||||
|
停止条件。
|
||||||
|
|
||||||
|
## 根因与证据
|
||||||
|
|
||||||
|
低跟随模式下,125 Hz CANFD 发送与绝对周期 TCP 反馈轮询能够同时工作:
|
||||||
|
|
||||||
|
- `feedback_interval mean=10.041–10.389 ms`;
|
||||||
|
- `feedback_age mean=5.730–6.138 ms`;
|
||||||
|
- 控制周期最大值不超过 `8.979 ms`。
|
||||||
|
|
||||||
|
启用高跟随后,即使 `canfd_trajectory_mode=2`,控制发送仍正常:
|
||||||
|
|
||||||
|
- `period max=9.275 ms`;
|
||||||
|
- `send max=0.235 ms`。
|
||||||
|
|
||||||
|
但同步反馈退化为:
|
||||||
|
|
||||||
|
- `feedback_read mean=11.278 ms`、`max=80.320 ms`;
|
||||||
|
- `feedback_age max=117.502 ms`。
|
||||||
|
|
||||||
|
反馈年龄逼近现有 `command_timeout_sec=0.12 s`,触发“关节反馈缺失或过期”
|
||||||
|
安全停止,造成 Grip 按住期间控制反复退出和重新锁定。增大超时只会允许 QP
|
||||||
|
继续使用更旧的关节状态,不解决 TCP 反馈阻塞。
|
||||||
|
|
||||||
|
睿尔曼 MovejCANFD 示例使用三线程模式、`rm_set_realtime_push()` 和
|
||||||
|
`rm_realtime_arm_state_call_back()`,通过 UDP 回调获取关节状态,而不是在
|
||||||
|
CANFD 透传期间同步轮询关节角。
|
||||||
|
|
||||||
|
## 数据流
|
||||||
|
|
||||||
|
```text
|
||||||
|
PICO -> ROS 125 Hz 控制回调 -> Placo QP -> TCP rm_movej_canfd
|
||||||
|
|
||||||
|
RM75 控制器 -> UDP 5 ms 主动推送 -> SDK 第三线程回调
|
||||||
|
-> JointStateSnapshot 缓存 -> ROS 125 Hz 控制回调
|
||||||
|
```
|
||||||
|
|
||||||
|
TCP 仍承担 CANFD、慢停、安全配置和末端工具命令。UDP 只承担状态反馈,两条
|
||||||
|
传输路径共用同一个机械臂句柄。
|
||||||
|
|
||||||
|
## SDK 连接与反馈生命周期
|
||||||
|
|
||||||
|
`RealManAdapter.connect()` 保持三线程模式和单次 `rm_create_robot_arm()`:
|
||||||
|
|
||||||
|
1. 创建并检查机械臂句柄。
|
||||||
|
2. 下发已有安全参数和可选初始位姿。
|
||||||
|
3. 创建并保存 `rm_realtime_arm_state_callback_ptr`,避免 Python 回调被垃圾
|
||||||
|
回收。
|
||||||
|
4. 使用 `rm_realtime_push_config_t` 配置 5 ms UDP 主动上报。
|
||||||
|
5. 注册 `rm_realtime_arm_state_call_back()`。
|
||||||
|
6. 等待第一帧有效 UDP 反馈,最长 2 秒。
|
||||||
|
|
||||||
|
若配置接口返回非零,或 2 秒内没有有效反馈,连接初始化失败并删除已创建的
|
||||||
|
机械臂句柄;不静默回退到 TCP 轮询。
|
||||||
|
|
||||||
|
连接成功后不再创建反馈线程,也不再调用 `rm_get_joint_degree()`。
|
||||||
|
`close()` 先停止接受回调更新,再执行现有慢停和句柄删除。控制器的 UDP 配置
|
||||||
|
由下一次启动重新覆盖,不额外增加关闭阶段配置命令。
|
||||||
|
|
||||||
|
## UDP 回调与缓存
|
||||||
|
|
||||||
|
回调只执行有界、非阻塞工作:
|
||||||
|
|
||||||
|
1. 检查回调对象、`errCode` 和来源机械臂 IP。
|
||||||
|
2. 读取 7 个 `joint_position`,检查数量、数值类型及 NaN/Inf。
|
||||||
|
3. 将厂商反馈的角度转换为弧度。
|
||||||
|
4. 使用 `time.monotonic()` 记录接收时刻,并计算与上一帧的更新间隔。
|
||||||
|
5. 在现有 `_joint_state_lock` 下替换 `JointStateSnapshot`。
|
||||||
|
6. 第一帧有效数据唤醒连接初始化等待。
|
||||||
|
|
||||||
|
无效 UDP 帧不覆盖上一帧缓存。若后续持续丢包,现有 120 ms 新鲜度检查自然
|
||||||
|
触发安全停止。
|
||||||
|
|
||||||
|
UDP 回调不执行 QP、ROS 发布、停止命令或其他 SDK 调用,避免阻塞 SDK 接收
|
||||||
|
线程。
|
||||||
|
|
||||||
|
## 参数与三份 YAML
|
||||||
|
|
||||||
|
新增真机参数:
|
||||||
|
|
||||||
|
- `realtime_push_host_ip`:机械臂可直接访问的上位机地址;
|
||||||
|
- `realtime_push_port`:单臂 UDP 接收端口;
|
||||||
|
- `realtime_push_cycle_ms`:主动上报周期,默认并配置为 `5`。
|
||||||
|
|
||||||
|
本次现场配置:
|
||||||
|
|
||||||
|
| 配置 | 节点 | host | port |
|
||||||
|
|---|---|---|---:|
|
||||||
|
| `right_arm_rm75.yaml` | 右臂 | `192.168.192.148` | 8090 |
|
||||||
|
| `left_arm_rm75.yaml` | 左臂 | `192.168.192.148` | 8089 |
|
||||||
|
| `dual_arm_rm75.yaml` | 左臂 | `192.168.192.148` | 8089 |
|
||||||
|
| `dual_arm_rm75.yaml` | 右臂 | `192.168.192.148` | 8090 |
|
||||||
|
|
||||||
|
左右臂端口必须不同。更换上位机或网络后,只需同步修改 YAML 中的
|
||||||
|
`realtime_push_host_ip`。
|
||||||
|
|
||||||
|
Mock 模式不导入厂商 SDK,也不要求 UDP 参数有效。
|
||||||
|
|
||||||
|
## YAML 与 launch 参数所有权
|
||||||
|
|
||||||
|
此前 `arm_debug.launch.py` 会用 launch 默认值覆盖 YAML 中的机械臂参数,
|
||||||
|
导致 YAML 无法单独控制 `follow` 等行为。
|
||||||
|
|
||||||
|
用户选择由 YAML 作为机械臂行为和硬件参数的唯一默认来源。
|
||||||
|
|
||||||
|
以下参数只由 `left_arm_rm75.yaml`、`right_arm_rm75.yaml` 和
|
||||||
|
`dual_arm_rm75.yaml` 管理,launch 不再声明或覆盖:
|
||||||
|
|
||||||
|
- `robot_ip`、`robot_port`;
|
||||||
|
- `avoid_singularity`;
|
||||||
|
- `control_rate_hz`;
|
||||||
|
- `follow`、`canfd_trajectory_mode`、`canfd_radio`;
|
||||||
|
- `configure_safety_limits`;
|
||||||
|
- `move_to_initial_pose_on_connect`;
|
||||||
|
- `enable_tool_control`、`enable_trigger_gripper_control`;
|
||||||
|
- `trigger_close_threshold`;
|
||||||
|
- `configure_peripheral_on_connect`;
|
||||||
|
- 本设计新增的 UDP 主动反馈参数。
|
||||||
|
|
||||||
|
三份 YAML 补齐工具控制参数;删除其中不再生效的 `use_mock`,避免出现两个
|
||||||
|
配置来源。
|
||||||
|
|
||||||
|
`arm_debug.launch.py` 只保留:
|
||||||
|
|
||||||
|
- `arm=left|right|both`,选择启动拓扑;
|
||||||
|
- `use_mock=true|false`,作为显式安全运行模式,默认仍为 `true`;
|
||||||
|
- PICO 输入节点的 `udp_host`、`udp_port`、`udp_timer_hz`;
|
||||||
|
- launch 根据安装路径和左右臂生成的 `robot_urdf_path`、
|
||||||
|
`peripheral_config_file`、`peripheral_arm` 和 `tool_command_topic`。
|
||||||
|
|
||||||
|
`launcher_ui.py` 和 README 中的启动命令同步删除机械臂 IP、初始化移动等已
|
||||||
|
移交 YAML 的 launch 参数,只保留 `arm`、`use_mock` 和 PICO 输入覆盖。
|
||||||
|
|
||||||
|
控制模式保持分阶段范围:
|
||||||
|
|
||||||
|
- 三份 YAML 均使用 `follow: false`、`canfd_trajectory_mode: 2` 完成安全基线;
|
||||||
|
- 基线验证通过前不启用高跟随或模式 0,不提高 `max_linear_speed`。
|
||||||
|
|
||||||
|
## Timing 日志
|
||||||
|
|
||||||
|
保留:
|
||||||
|
|
||||||
|
- `period`;
|
||||||
|
- `total`;
|
||||||
|
- `qp`;
|
||||||
|
- `send`;
|
||||||
|
- `feedback_age`;
|
||||||
|
- `feedback_interval`。
|
||||||
|
|
||||||
|
`feedback_read` 表示同步 SDK 查询耗时;UDP 架构不存在该查询,因此该字段
|
||||||
|
不再产生样本,现有条件日志逻辑会自动省略它,不新增同义统计项。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
使用 FakeArm 和伪造 SDK 模块覆盖:
|
||||||
|
|
||||||
|
- 连接时使用正确 host、port、5 ms 周期配置 UDP 并注册回调;
|
||||||
|
- 第一帧有效回调转换 7 个关节角为弧度并解除启动等待;
|
||||||
|
- 连续回调正确记录 `feedback_interval`;
|
||||||
|
- 错误码、来源 IP、长度或 NaN/Inf 无效帧不覆盖缓存;
|
||||||
|
- UDP 配置失败或首帧超时会清理句柄并抛出明确异常;
|
||||||
|
- 真机适配器不再启动轮询线程或调用 `rm_get_joint_degree()`;
|
||||||
|
- Mock 模式无需厂商 SDK。
|
||||||
|
- launch 不再覆盖 YAML 的机械臂行为和硬件参数;
|
||||||
|
- `use_mock` 仍由 launch 默认设为 `true`;
|
||||||
|
- `launcher_ui.py` 不再传递已经删除的 launch 参数。
|
||||||
|
|
||||||
|
随后运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/robot/WS_xr
|
||||||
|
source /opt/ros/humble/setup.bash
|
||||||
|
python3 -m pytest -q src/xr_rm_teleop/test
|
||||||
|
python3 -m pytest -q src/xr_rm_teleop/test/test_orientation_control.py
|
||||||
|
colcon build --symlink-install --executor sequential
|
||||||
|
```
|
||||||
|
|
||||||
|
Codex 不连接真机。用户在右臂小范围测试中确认:
|
||||||
|
|
||||||
|
- 启动日志显示收到 UDP 首帧;
|
||||||
|
- 按住 Grip 不再出现反馈过期或 SDK `-2`;
|
||||||
|
- 连续四个 timing 窗口 `period max < 10 ms`、`total max < 8 ms`;
|
||||||
|
- `feedback_interval mean` 接近 5 ms;
|
||||||
|
- `feedback_age mean < 5 ms`,且最大值不触发 120 ms 安全停止。
|
||||||
@@ -39,14 +39,20 @@ left_arm_teleop:
|
|||||||
0.0, 0.0, 1.0,
|
0.0, 0.0, 1.0,
|
||||||
-1.0, 0.0, 0.0]
|
-1.0, 0.0, 0.0]
|
||||||
|
|
||||||
use_mock: false
|
|
||||||
robot_ip: 192.168.192.18
|
robot_ip: 192.168.192.18
|
||||||
robot_port: 8080
|
robot_port: 8080
|
||||||
|
realtime_push_host_ip: 192.168.192.148
|
||||||
|
realtime_push_port: 8089
|
||||||
|
realtime_push_cycle_ms: 5
|
||||||
avoid_singularity: 0
|
avoid_singularity: 0
|
||||||
follow: false
|
follow: false
|
||||||
canfd_trajectory_mode: 2
|
canfd_trajectory_mode: 2
|
||||||
canfd_radio: 0
|
canfd_radio: 0
|
||||||
configure_safety_limits: true
|
configure_safety_limits: true
|
||||||
|
enable_tool_control: true
|
||||||
|
enable_trigger_gripper_control: true
|
||||||
|
trigger_close_threshold: 0.95
|
||||||
|
configure_peripheral_on_connect: true
|
||||||
max_line_speed: 1.0
|
max_line_speed: 1.0
|
||||||
max_angular_speed: 1.5
|
max_angular_speed: 1.5
|
||||||
max_line_acc: 1.0
|
max_line_acc: 1.0
|
||||||
@@ -89,14 +95,20 @@ right_arm_teleop:
|
|||||||
0.0, 0.0, 1.0,
|
0.0, 0.0, 1.0,
|
||||||
1.0, 0.0, 0.0]
|
1.0, 0.0, 0.0]
|
||||||
|
|
||||||
use_mock: false
|
|
||||||
robot_ip: 192.168.192.19
|
robot_ip: 192.168.192.19
|
||||||
robot_port: 8080
|
robot_port: 8080
|
||||||
|
realtime_push_host_ip: 192.168.192.148
|
||||||
|
realtime_push_port: 8090
|
||||||
|
realtime_push_cycle_ms: 5
|
||||||
avoid_singularity: 1
|
avoid_singularity: 1
|
||||||
follow: false
|
follow: false
|
||||||
canfd_trajectory_mode: 2
|
canfd_trajectory_mode: 2
|
||||||
canfd_radio: 0
|
canfd_radio: 0
|
||||||
configure_safety_limits: true
|
configure_safety_limits: true
|
||||||
|
enable_tool_control: true
|
||||||
|
enable_trigger_gripper_control: true
|
||||||
|
trigger_close_threshold: 0.95
|
||||||
|
configure_peripheral_on_connect: true
|
||||||
max_line_speed: 1.0
|
max_line_speed: 1.0
|
||||||
max_angular_speed: 1.5
|
max_angular_speed: 1.5
|
||||||
max_line_acc: 1.0
|
max_line_acc: 1.0
|
||||||
|
|||||||
@@ -32,14 +32,20 @@ single_arm_velocity_teleop:
|
|||||||
0.0, 0.0, 1.0,
|
0.0, 0.0, 1.0,
|
||||||
-1.0, 0.0, 0.0]
|
-1.0, 0.0, 0.0]
|
||||||
|
|
||||||
use_mock: false
|
|
||||||
robot_ip: 192.168.192.18
|
robot_ip: 192.168.192.18
|
||||||
robot_port: 8080
|
robot_port: 8080
|
||||||
|
realtime_push_host_ip: 192.168.192.148
|
||||||
|
realtime_push_port: 8089
|
||||||
|
realtime_push_cycle_ms: 5
|
||||||
avoid_singularity: 0
|
avoid_singularity: 0
|
||||||
follow: false
|
follow: false
|
||||||
canfd_trajectory_mode: 2
|
canfd_trajectory_mode: 2
|
||||||
canfd_radio: 0
|
canfd_radio: 0
|
||||||
configure_safety_limits: true
|
configure_safety_limits: true
|
||||||
|
enable_tool_control: true
|
||||||
|
enable_trigger_gripper_control: true
|
||||||
|
trigger_close_threshold: 0.95
|
||||||
|
configure_peripheral_on_connect: true
|
||||||
max_line_speed: 1.0
|
max_line_speed: 1.0
|
||||||
max_angular_speed: 1.5
|
max_angular_speed: 1.5
|
||||||
max_line_acc: 1.0
|
max_line_acc: 1.0
|
||||||
|
|||||||
@@ -31,14 +31,21 @@ single_arm_velocity_teleop:
|
|||||||
0.0, 0.0, 1.0,
|
0.0, 0.0, 1.0,
|
||||||
1.0, 0.0, 0.0]
|
1.0, 0.0, 0.0]
|
||||||
|
|
||||||
use_mock: false
|
|
||||||
robot_ip: 192.168.192.19
|
robot_ip: 192.168.192.19
|
||||||
robot_port: 8080
|
robot_port: 8080
|
||||||
|
realtime_push_host_ip: 192.168.192.148
|
||||||
|
realtime_push_port: 8090
|
||||||
|
realtime_push_cycle_ms: 5
|
||||||
avoid_singularity: 1
|
avoid_singularity: 1
|
||||||
follow: False
|
# 厂商 MovejCANFD 示例默认低跟随;高跟随仅在验证规划轨迹后单独开启。
|
||||||
|
follow: false
|
||||||
canfd_trajectory_mode: 2
|
canfd_trajectory_mode: 2
|
||||||
canfd_radio: 0
|
canfd_radio: 0
|
||||||
configure_safety_limits: true
|
configure_safety_limits: true
|
||||||
|
enable_tool_control: true
|
||||||
|
enable_trigger_gripper_control: true
|
||||||
|
trigger_close_threshold: 0.95
|
||||||
|
configure_peripheral_on_connect: true
|
||||||
max_line_speed: 0.25
|
max_line_speed: 0.25
|
||||||
max_angular_speed: 0.6
|
max_angular_speed: 0.6
|
||||||
max_line_acc: 1.3
|
max_line_acc: 1.3
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ def _rm75_urdf() -> PathJoinSubstitution:
|
|||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
def _initial_pose_override(value: str) -> dict[str, bool]:
|
|
||||||
return {} if value == "auto" else {"move_to_initial_pose_on_connect": _as_bool(value)}
|
|
||||||
|
|
||||||
|
|
||||||
def _udp_receiver_node() -> Node:
|
def _udp_receiver_node() -> Node:
|
||||||
"""接收 PICO/XR UDP 数据,并发布左右手柄 ROS2 话题。"""
|
"""接收 PICO/XR UDP 数据,并发布左右手柄 ROS2 话题。"""
|
||||||
return Node(
|
return Node(
|
||||||
@@ -65,19 +61,9 @@ def _udp_receiver_node() -> Node:
|
|||||||
def _single_arm_node(
|
def _single_arm_node(
|
||||||
arm: str,
|
arm: str,
|
||||||
use_mock: bool,
|
use_mock: bool,
|
||||||
move_to_initial_pose: str,
|
|
||||||
avoid_singularity: int,
|
|
||||||
control_rate_hz: float,
|
|
||||||
follow: bool,
|
|
||||||
configure_safety_limits: bool,
|
|
||||||
enable_tool_control: bool,
|
|
||||||
enable_trigger_gripper_control: bool,
|
|
||||||
trigger_close_threshold: float,
|
|
||||||
configure_peripheral_on_connect: bool,
|
|
||||||
) -> Node:
|
) -> Node:
|
||||||
"""创建单臂调试节点;左/右臂分别使用独立 YAML,节点名保持单臂默认名。"""
|
"""创建单臂调试节点;左/右臂分别使用独立 YAML,节点名保持单臂默认名。"""
|
||||||
config_name = "left_arm_rm75.yaml" if arm == "left" else "right_arm_rm75.yaml"
|
config_name = "left_arm_rm75.yaml" if arm == "left" else "right_arm_rm75.yaml"
|
||||||
robot_ip = LaunchConfiguration("left_robot_ip" if arm == "left" else "right_robot_ip")
|
|
||||||
arm_name = _arm_name(arm)
|
arm_name = _arm_name(arm)
|
||||||
return Node(
|
return Node(
|
||||||
package="xr_rm_teleop",
|
package="xr_rm_teleop",
|
||||||
@@ -89,18 +75,7 @@ def _single_arm_node(
|
|||||||
_config_file(config_name),
|
_config_file(config_name),
|
||||||
{
|
{
|
||||||
"use_mock": use_mock,
|
"use_mock": use_mock,
|
||||||
"robot_ip": robot_ip,
|
|
||||||
"robot_port": LaunchConfiguration("robot_port"),
|
|
||||||
"avoid_singularity": avoid_singularity,
|
|
||||||
"robot_urdf_path": _rm75_urdf(),
|
"robot_urdf_path": _rm75_urdf(),
|
||||||
"control_rate_hz": control_rate_hz,
|
|
||||||
"follow": follow,
|
|
||||||
"configure_safety_limits": configure_safety_limits,
|
|
||||||
**_initial_pose_override(move_to_initial_pose),
|
|
||||||
"enable_tool_control": enable_tool_control,
|
|
||||||
"enable_trigger_gripper_control": enable_trigger_gripper_control,
|
|
||||||
"trigger_close_threshold": trigger_close_threshold,
|
|
||||||
"configure_peripheral_on_connect": configure_peripheral_on_connect,
|
|
||||||
"peripheral_config_file": _config_file("peripherals_rm75.yaml"),
|
"peripheral_config_file": _config_file("peripherals_rm75.yaml"),
|
||||||
"peripheral_arm": arm,
|
"peripheral_arm": arm,
|
||||||
"tool_command_topic": f"/xr_rm/{arm_name}/tool_enable",
|
"tool_command_topic": f"/xr_rm/{arm_name}/tool_enable",
|
||||||
@@ -113,19 +88,7 @@ def _arm_name(arm: str) -> str:
|
|||||||
return "left_rm75" if arm == "left" else "right_rm75"
|
return "left_rm75" if arm == "left" else "right_rm75"
|
||||||
|
|
||||||
|
|
||||||
def _dual_arm_nodes(
|
def _dual_arm_nodes(use_mock: bool) -> list[Node]:
|
||||||
use_mock: bool,
|
|
||||||
move_to_initial_pose: str,
|
|
||||||
left_avoid_singularity: int,
|
|
||||||
right_avoid_singularity: int,
|
|
||||||
control_rate_hz: float,
|
|
||||||
follow: bool,
|
|
||||||
configure_safety_limits: bool,
|
|
||||||
enable_tool_control: bool,
|
|
||||||
enable_trigger_gripper_control: bool,
|
|
||||||
trigger_close_threshold: float,
|
|
||||||
configure_peripheral_on_connect: bool,
|
|
||||||
) -> list[Node]:
|
|
||||||
"""创建双臂节点;两个节点共用双臂 YAML,但节点名区分左右臂参数命名空间。"""
|
"""创建双臂节点;两个节点共用双臂 YAML,但节点名区分左右臂参数命名空间。"""
|
||||||
config_file = _config_file("dual_arm_rm75.yaml")
|
config_file = _config_file("dual_arm_rm75.yaml")
|
||||||
return [
|
return [
|
||||||
@@ -139,18 +102,7 @@ def _dual_arm_nodes(
|
|||||||
config_file,
|
config_file,
|
||||||
{
|
{
|
||||||
"use_mock": use_mock,
|
"use_mock": use_mock,
|
||||||
"robot_ip": LaunchConfiguration("left_robot_ip"),
|
|
||||||
"robot_port": LaunchConfiguration("robot_port"),
|
|
||||||
"avoid_singularity": left_avoid_singularity,
|
|
||||||
"robot_urdf_path": _rm75_urdf(),
|
"robot_urdf_path": _rm75_urdf(),
|
||||||
"control_rate_hz": control_rate_hz,
|
|
||||||
"follow": follow,
|
|
||||||
"configure_safety_limits": configure_safety_limits,
|
|
||||||
**_initial_pose_override(move_to_initial_pose),
|
|
||||||
"enable_tool_control": enable_tool_control,
|
|
||||||
"enable_trigger_gripper_control": enable_trigger_gripper_control,
|
|
||||||
"trigger_close_threshold": trigger_close_threshold,
|
|
||||||
"configure_peripheral_on_connect": configure_peripheral_on_connect,
|
|
||||||
"peripheral_config_file": _config_file("peripherals_rm75.yaml"),
|
"peripheral_config_file": _config_file("peripherals_rm75.yaml"),
|
||||||
"peripheral_arm": "left",
|
"peripheral_arm": "left",
|
||||||
"tool_command_topic": "/xr_rm/left_rm75/tool_enable",
|
"tool_command_topic": "/xr_rm/left_rm75/tool_enable",
|
||||||
@@ -167,18 +119,7 @@ def _dual_arm_nodes(
|
|||||||
config_file,
|
config_file,
|
||||||
{
|
{
|
||||||
"use_mock": use_mock,
|
"use_mock": use_mock,
|
||||||
"robot_ip": LaunchConfiguration("right_robot_ip"),
|
|
||||||
"robot_port": LaunchConfiguration("robot_port"),
|
|
||||||
"avoid_singularity": right_avoid_singularity,
|
|
||||||
"robot_urdf_path": _rm75_urdf(),
|
"robot_urdf_path": _rm75_urdf(),
|
||||||
"control_rate_hz": control_rate_hz,
|
|
||||||
"follow": follow,
|
|
||||||
"configure_safety_limits": configure_safety_limits,
|
|
||||||
**_initial_pose_override(move_to_initial_pose),
|
|
||||||
"enable_tool_control": enable_tool_control,
|
|
||||||
"enable_trigger_gripper_control": enable_trigger_gripper_control,
|
|
||||||
"trigger_close_threshold": trigger_close_threshold,
|
|
||||||
"configure_peripheral_on_connect": configure_peripheral_on_connect,
|
|
||||||
"peripheral_config_file": _config_file("peripherals_rm75.yaml"),
|
"peripheral_config_file": _config_file("peripherals_rm75.yaml"),
|
||||||
"peripheral_arm": "right",
|
"peripheral_arm": "right",
|
||||||
"tool_command_topic": "/xr_rm/right_rm75/tool_enable",
|
"tool_command_topic": "/xr_rm/right_rm75/tool_enable",
|
||||||
@@ -198,71 +139,15 @@ def _launch_setup(context, *args, **kwargs):
|
|||||||
)
|
)
|
||||||
arm = LaunchConfiguration("arm").perform(context).strip().lower()
|
arm = LaunchConfiguration("arm").perform(context).strip().lower()
|
||||||
use_mock = _as_bool(LaunchConfiguration("use_mock").perform(context))
|
use_mock = _as_bool(LaunchConfiguration("use_mock").perform(context))
|
||||||
move_to_initial_pose = LaunchConfiguration(
|
|
||||||
"move_to_initial_pose_on_connect"
|
|
||||||
).perform(context).strip().lower()
|
|
||||||
avoid_override = LaunchConfiguration("avoid_singularity").perform(context).strip()
|
|
||||||
left_avoid_singularity = int(
|
|
||||||
avoid_override or LaunchConfiguration("left_avoid_singularity").perform(context)
|
|
||||||
)
|
|
||||||
right_avoid_singularity = int(
|
|
||||||
avoid_override or LaunchConfiguration("right_avoid_singularity").perform(context)
|
|
||||||
)
|
|
||||||
control_rate_hz = float(LaunchConfiguration("control_rate_hz").perform(context))
|
|
||||||
follow = _as_bool(LaunchConfiguration("follow").perform(context))
|
|
||||||
configure_safety_limits = _as_bool(
|
|
||||||
LaunchConfiguration("configure_safety_limits").perform(context)
|
|
||||||
)
|
|
||||||
configure_peripheral_on_connect = _as_bool(
|
|
||||||
LaunchConfiguration("configure_peripheral_on_connect").perform(context)
|
|
||||||
)
|
|
||||||
enable_tool_control = _as_bool(
|
|
||||||
LaunchConfiguration("enable_tool_control").perform(context)
|
|
||||||
)
|
|
||||||
enable_trigger_gripper_control = _as_bool(
|
|
||||||
LaunchConfiguration("enable_trigger_gripper_control").perform(context)
|
|
||||||
)
|
|
||||||
trigger_close_threshold = float(
|
|
||||||
LaunchConfiguration("trigger_close_threshold").perform(context)
|
|
||||||
)
|
|
||||||
|
|
||||||
if arm not in ("left", "right", "both"):
|
if arm not in ("left", "right", "both"):
|
||||||
raise ValueError("arm must be one of: left, right, both")
|
raise ValueError("arm must be one of: left, right, both")
|
||||||
|
|
||||||
nodes = [_udp_receiver_node()]
|
nodes = [_udp_receiver_node()]
|
||||||
if arm == "both":
|
if arm == "both":
|
||||||
nodes.extend(
|
nodes.extend(_dual_arm_nodes(use_mock))
|
||||||
_dual_arm_nodes(
|
|
||||||
use_mock,
|
|
||||||
move_to_initial_pose,
|
|
||||||
left_avoid_singularity,
|
|
||||||
right_avoid_singularity,
|
|
||||||
control_rate_hz,
|
|
||||||
follow,
|
|
||||||
configure_safety_limits,
|
|
||||||
enable_tool_control,
|
|
||||||
enable_trigger_gripper_control,
|
|
||||||
trigger_close_threshold,
|
|
||||||
configure_peripheral_on_connect,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
avoid_singularity = left_avoid_singularity if arm == "left" else right_avoid_singularity
|
nodes.append(_single_arm_node(arm, use_mock))
|
||||||
nodes.append(
|
|
||||||
_single_arm_node(
|
|
||||||
arm,
|
|
||||||
use_mock,
|
|
||||||
move_to_initial_pose,
|
|
||||||
avoid_singularity,
|
|
||||||
control_rate_hz,
|
|
||||||
follow,
|
|
||||||
configure_safety_limits,
|
|
||||||
enable_tool_control,
|
|
||||||
enable_trigger_gripper_control,
|
|
||||||
trigger_close_threshold,
|
|
||||||
configure_peripheral_on_connect,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return nodes
|
return nodes
|
||||||
|
|
||||||
|
|
||||||
@@ -277,29 +162,6 @@ def generate_launch_description() -> LaunchDescription:
|
|||||||
DeclareLaunchArgument("udp_port", default_value="15000"),
|
DeclareLaunchArgument("udp_port", default_value="15000"),
|
||||||
# UDP receiver 轮询频率高于 PICO 发送频率,减少 socket 中等待时间。
|
# UDP receiver 轮询频率高于 PICO 发送频率,减少 socket 中等待时间。
|
||||||
DeclareLaunchArgument("udp_timer_hz", default_value="200.0"),
|
DeclareLaunchArgument("udp_timer_hz", default_value="200.0"),
|
||||||
# 左右 RM75 默认 IP,可在命令行中按现场网络覆盖。
|
|
||||||
DeclareLaunchArgument("left_robot_ip", default_value="192.168.192.18"),
|
|
||||||
DeclareLaunchArgument("right_robot_ip", default_value="192.168.192.19"),
|
|
||||||
DeclareLaunchArgument("robot_port", default_value="8080"),
|
|
||||||
# 真机位姿透传与安全配置参数。
|
|
||||||
DeclareLaunchArgument("left_avoid_singularity", default_value="0"),
|
|
||||||
DeclareLaunchArgument("right_avoid_singularity", default_value="1"),
|
|
||||||
# 非空时作为左右臂全局覆盖,例如 avoid_singularity:=0。
|
|
||||||
DeclareLaunchArgument("avoid_singularity", default_value=""),
|
|
||||||
# 每周期同步一次实际关节反馈、执行一次 Placo QP,再发送 rm_movej_canfd。
|
|
||||||
DeclareLaunchArgument("control_rate_hz", default_value="125.0"),
|
|
||||||
# 默认低跟随;高跟随请确认控制器和网络能稳定满足厂商周期要求后再打开。
|
|
||||||
DeclareLaunchArgument("follow", default_value="false"),
|
|
||||||
DeclareLaunchArgument("configure_safety_limits", default_value="true"),
|
|
||||||
# 工具控制通过遥操作节点复用同一个 RealMan 连接,避免两个进程抢同一机械臂连接。
|
|
||||||
DeclareLaunchArgument("enable_tool_control", default_value="true"),
|
|
||||||
# trigger 上升沿切换夹爪开/关;grip 仍只控制机械臂运动。
|
|
||||||
DeclareLaunchArgument("enable_trigger_gripper_control", default_value="true"),
|
|
||||||
DeclareLaunchArgument("trigger_close_threshold", default_value="0.95"),
|
|
||||||
# 连接成功后是否配置外设;关闭后仅订阅开合话题,但开合前需要另行完成外设配置。
|
|
||||||
DeclareLaunchArgument("configure_peripheral_on_connect", default_value="true"),
|
|
||||||
# auto 时由单/双臂 YAML 决定;也可显式传 true/false 覆盖。
|
|
||||||
DeclareLaunchArgument("move_to_initial_pose_on_connect", default_value="auto"),
|
|
||||||
# OpaqueFunction 允许根据 arm/use_mock 等运行时参数动态生成节点。
|
# OpaqueFunction 允许根据 arm/use_mock 等运行时参数动态生成节点。
|
||||||
OpaqueFunction(function=_launch_setup),
|
OpaqueFunction(function=_launch_setup),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import importlib.util
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
|
||||||
|
MODULE_PATH = Path(__file__).parents[1] / "tools" / "launcher_ui.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("launcher_ui", MODULE_PATH)
|
||||||
|
launcher_ui = importlib.util.module_from_spec(SPEC)
|
||||||
|
assert SPEC.loader is not None
|
||||||
|
SPEC.loader.exec_module(launcher_ui)
|
||||||
|
|
||||||
|
|
||||||
|
class LauncherCleanupTest(unittest.TestCase):
|
||||||
|
def test_xrobotoolkit_cleanup_policy_only_stops_service_on_window_close(self) -> None:
|
||||||
|
stop_all_patterns = set(
|
||||||
|
launcher_ui._xrobotoolkit_cleanup_patterns(stop_pc_service=False)
|
||||||
|
)
|
||||||
|
window_close_patterns = set(
|
||||||
|
launcher_ui._xrobotoolkit_cleanup_patterns(stop_pc_service=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertLessEqual(
|
||||||
|
{"RobotLinuxDemo.x86_64", "PXREAClientUnity"},
|
||||||
|
stop_all_patterns,
|
||||||
|
)
|
||||||
|
self.assertNotIn("RoboticsServiceProcess", stop_all_patterns)
|
||||||
|
self.assertIn("RoboticsServiceProcess", window_close_patterns)
|
||||||
|
|
||||||
|
def test_close_and_stop_all_select_different_pc_service_policies(self) -> None:
|
||||||
|
app = object.__new__(launcher_ui.LauncherApp)
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
class Root:
|
||||||
|
destroyed = False
|
||||||
|
|
||||||
|
def destroy(self) -> None:
|
||||||
|
self.destroyed = True
|
||||||
|
|
||||||
|
app.root = Root()
|
||||||
|
app.stop_launched_processes = lambda **kwargs: calls.append(kwargs) or True
|
||||||
|
|
||||||
|
app.kill_launched_processes()
|
||||||
|
app.on_close_requested()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
calls,
|
||||||
|
[
|
||||||
|
{"confirm": True, "notify": True, "stop_pc_service": False},
|
||||||
|
{"confirm": True, "notify": False, "stop_pc_service": True},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertTrue(app.root.destroyed)
|
||||||
|
|
||||||
|
def test_stop_all_keeps_oldest_pc_service_and_stops_duplicates(self) -> None:
|
||||||
|
app = object.__new__(launcher_ui.LauncherApp)
|
||||||
|
app.status = mock.Mock()
|
||||||
|
app.close_related_terminal_windows = lambda: 0
|
||||||
|
|
||||||
|
def fake_check_output(command, **_kwargs):
|
||||||
|
if command == ["pgrep", "-o", "-f", "RoboticsServiceProcess"]:
|
||||||
|
return "101\n"
|
||||||
|
if command == ["pgrep", "-f", "RoboticsServiceProcess"]:
|
||||||
|
return "101\n202\n303\n"
|
||||||
|
raise subprocess.CalledProcessError(1, command)
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch.object(
|
||||||
|
launcher_ui.subprocess,
|
||||||
|
"check_output",
|
||||||
|
side_effect=fake_check_output,
|
||||||
|
),
|
||||||
|
mock.patch.object(launcher_ui.os, "kill") as kill,
|
||||||
|
mock.patch.object(launcher_ui.time, "sleep"),
|
||||||
|
):
|
||||||
|
app.stop_launched_processes(
|
||||||
|
confirm=False,
|
||||||
|
notify=False,
|
||||||
|
stop_pc_service=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
kill.call_args_list,
|
||||||
|
[
|
||||||
|
mock.call(202, signal.SIGTERM),
|
||||||
|
mock.call(303, signal.SIGTERM),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -121,6 +121,17 @@ def _xrobotoolkit_bridge_command() -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _xrobotoolkit_cleanup_patterns(*, stop_pc_service: bool) -> tuple[str, ...]:
|
||||||
|
patterns = (
|
||||||
|
XROBOTOOLKIT_BRIDGE_EXECUTABLE,
|
||||||
|
"RobotLinuxDemo.x86_64",
|
||||||
|
"PXREAClientUnity",
|
||||||
|
)
|
||||||
|
if stop_pc_service:
|
||||||
|
return (*patterns, "RoboticsServiceProcess")
|
||||||
|
return patterns
|
||||||
|
|
||||||
|
|
||||||
def _tool_command(arm: str, open_tool: bool) -> str:
|
def _tool_command(arm: str, open_tool: bool) -> str:
|
||||||
arm_name = "left_rm75" if arm == "left" else "right_rm75"
|
arm_name = "left_rm75" if arm == "left" else "right_rm75"
|
||||||
value = "true" if open_tool else "false"
|
value = "true" if open_tool else "false"
|
||||||
@@ -289,8 +300,7 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
|||||||
("Ping Left RM75", f"ping -c 4 {DEFAULT_LEFT_IP}"),
|
("Ping Left RM75", f"ping -c 4 {DEFAULT_LEFT_IP}"),
|
||||||
(
|
(
|
||||||
"Left Arm RealMan Launch",
|
"Left Arm RealMan Launch",
|
||||||
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=left use_mock:=false "
|
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=left use_mock:=false",
|
||||||
f"left_robot_ip:={DEFAULT_LEFT_IP}",
|
|
||||||
),
|
),
|
||||||
("XRobotoolkit UDP Bridge (90 Hz)", _xrobotoolkit_bridge_command()),
|
("XRobotoolkit UDP Bridge (90 Hz)", _xrobotoolkit_bridge_command()),
|
||||||
("Left Tool Open", _tool_command("left", True)),
|
("Left Tool Open", _tool_command("left", True)),
|
||||||
@@ -306,8 +316,7 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
|||||||
("Ping Right RM75", f"ping -c 4 {DEFAULT_RIGHT_IP}"),
|
("Ping Right RM75", f"ping -c 4 {DEFAULT_RIGHT_IP}"),
|
||||||
(
|
(
|
||||||
"Right Arm RealMan Launch",
|
"Right Arm RealMan Launch",
|
||||||
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=false "
|
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=right use_mock:=false",
|
||||||
f"right_robot_ip:={DEFAULT_RIGHT_IP}",
|
|
||||||
),
|
),
|
||||||
("XRobotoolkit UDP Bridge (90 Hz)", _xrobotoolkit_bridge_command()),
|
("XRobotoolkit UDP Bridge (90 Hz)", _xrobotoolkit_bridge_command()),
|
||||||
("Right Tool Open", _tool_command("right", True)),
|
("Right Tool Open", _tool_command("right", True)),
|
||||||
@@ -324,9 +333,7 @@ def build_commands_by_mode(mode: str) -> list[tuple[str, str]]:
|
|||||||
("Ping Right RM75", f"ping -c 4 {DEFAULT_RIGHT_IP}"),
|
("Ping Right RM75", f"ping -c 4 {DEFAULT_RIGHT_IP}"),
|
||||||
(
|
(
|
||||||
"Dual Arm RealMan Launch",
|
"Dual Arm RealMan Launch",
|
||||||
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false "
|
"ros2 launch xr_rm_bringup arm_debug.launch.py arm:=both use_mock:=false",
|
||||||
f"left_robot_ip:={DEFAULT_LEFT_IP} right_robot_ip:={DEFAULT_RIGHT_IP} "
|
|
||||||
"move_to_initial_pose_on_connect:=false",
|
|
||||||
),
|
),
|
||||||
("XRobotoolkit UDP Bridge (90 Hz)", _xrobotoolkit_bridge_command()),
|
("XRobotoolkit UDP Bridge (90 Hz)", _xrobotoolkit_bridge_command()),
|
||||||
(
|
(
|
||||||
@@ -1283,13 +1290,27 @@ class LauncherApp:
|
|||||||
return closed
|
return closed
|
||||||
|
|
||||||
def on_close_requested(self) -> None:
|
def on_close_requested(self) -> None:
|
||||||
if self.stop_launched_processes(confirm=True, notify=False):
|
if self.stop_launched_processes(
|
||||||
|
confirm=True,
|
||||||
|
notify=False,
|
||||||
|
stop_pc_service=True,
|
||||||
|
):
|
||||||
self.root.destroy()
|
self.root.destroy()
|
||||||
|
|
||||||
def kill_launched_processes(self) -> None:
|
def kill_launched_processes(self) -> None:
|
||||||
self.stop_launched_processes(confirm=True, notify=True)
|
self.stop_launched_processes(
|
||||||
|
confirm=True,
|
||||||
|
notify=True,
|
||||||
|
stop_pc_service=False,
|
||||||
|
)
|
||||||
|
|
||||||
def stop_launched_processes(self, *, confirm: bool, notify: bool) -> bool:
|
def stop_launched_processes(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
confirm: bool,
|
||||||
|
notify: bool,
|
||||||
|
stop_pc_service: bool,
|
||||||
|
) -> bool:
|
||||||
if confirm and not messagebox.askyesno(
|
if confirm and not messagebox.askyesno(
|
||||||
"Confirm Stop",
|
"Confirm Stop",
|
||||||
"Stop XR-RM launcher terminals, topic monitors, ROS nodes, and bridge processes started from this workspace?",
|
"Stop XR-RM launcher terminals, topic monitors, ROS nodes, and bridge processes started from this workspace?",
|
||||||
@@ -1302,7 +1323,7 @@ class LauncherApp:
|
|||||||
"ros2 run xr_rm_input",
|
"ros2 run xr_rm_input",
|
||||||
"ros2 run xr_rm_teleop",
|
"ros2 run xr_rm_teleop",
|
||||||
"XR_RM_LAUNCHER_SESSION=1",
|
"XR_RM_LAUNCHER_SESSION=1",
|
||||||
XROBOTOOLKIT_BRIDGE_EXECUTABLE,
|
*_xrobotoolkit_cleanup_patterns(stop_pc_service=stop_pc_service),
|
||||||
TERMINAL_TITLE_PREFIX,
|
TERMINAL_TITLE_PREFIX,
|
||||||
TOPIC_MONITOR_TITLE,
|
TOPIC_MONITOR_TITLE,
|
||||||
CMD_VEL_MONITOR_TITLE,
|
CMD_VEL_MONITOR_TITLE,
|
||||||
@@ -1326,6 +1347,20 @@ class LauncherApp:
|
|||||||
"ros2 topic list",
|
"ros2 topic list",
|
||||||
"ros2 node list",
|
"ros2 node list",
|
||||||
]
|
]
|
||||||
|
pc_service_keep_pid: int | None = None
|
||||||
|
if not stop_pc_service:
|
||||||
|
try:
|
||||||
|
output = subprocess.check_output(
|
||||||
|
["pgrep", "-o", "-f", "RoboticsServiceProcess"],
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
pc_service_keep_pid = int(output.strip())
|
||||||
|
patterns.append("RoboticsServiceProcess")
|
||||||
|
except (subprocess.CalledProcessError, ValueError):
|
||||||
|
pass
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"Failed to find the oldest RoboticsServiceProcess: {exc}")
|
||||||
|
|
||||||
protected = {os.getpid(), os.getppid()}
|
protected = {os.getpid(), os.getppid()}
|
||||||
killed: set[int] = set()
|
killed: set[int] = set()
|
||||||
|
|
||||||
@@ -1345,6 +1380,8 @@ class LauncherApp:
|
|||||||
continue
|
continue
|
||||||
if pid in protected or pid in killed:
|
if pid in protected or pid in killed:
|
||||||
continue
|
continue
|
||||||
|
if pattern == "RoboticsServiceProcess" and pid == pc_service_keep_pid:
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
os.kill(pid, signal.SIGTERM)
|
os.kill(pid, signal.SIGTERM)
|
||||||
killed.add(pid)
|
killed.add(pid)
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ def main() -> None:
|
|||||||
drift_degrees = float(
|
drift_degrees = float(
|
||||||
np.max(np.abs(np.rad2deg(np.asarray(joints) - initial_joints)))
|
np.max(np.abs(np.rad2deg(np.asarray(joints) - initial_joints)))
|
||||||
)
|
)
|
||||||
|
assert drift_degrees <= 0.05, (
|
||||||
|
f"{arm} stationary target drifted {drift_degrees:.3f}deg"
|
||||||
|
)
|
||||||
|
|
||||||
solver = PlacoIkSolver(str(urdf_path), 1.0 / 125.0)
|
solver = PlacoIkSolver(str(urdf_path), 1.0 / 125.0)
|
||||||
joints = initial_joints.tolist()
|
joints = initial_joints.tolist()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import math
|
import math
|
||||||
|
import sys
|
||||||
|
from types import ModuleType, SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -18,7 +20,14 @@ def test_initial_pose_uses_joint_move_only() -> None:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
joints = [-167.21, 28.48, 28.21, 61.35, -14.40, 84.49, -124.51]
|
joints = [-167.21, 28.48, 28.21, 61.35, -14.40, 84.49, -124.51]
|
||||||
adapter = RealManAdapter("127.0.0.1", 8080, 0, 1, initial_joint_pose=joints)
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"127.0.0.1",
|
||||||
|
8090,
|
||||||
|
initial_joint_pose=joints,
|
||||||
|
)
|
||||||
adapter._arm = FakeArm()
|
adapter._arm = FakeArm()
|
||||||
|
|
||||||
adapter._move_to_initial_pose()
|
adapter._move_to_initial_pose()
|
||||||
@@ -111,80 +120,299 @@ def test_tool_frame_sdk_failures_are_reported(existing, failure, operation) -> N
|
|||||||
_configure_tool_frame(FakeArm(), object(), "omnipic")
|
_configure_tool_frame(FakeArm(), object(), "omnipic")
|
||||||
|
|
||||||
|
|
||||||
def test_joint_feedback_is_cached_in_radians(monkeypatch) -> None:
|
def _udp_state(
|
||||||
class FakeArm:
|
*,
|
||||||
def rm_get_joint_degree(self):
|
robot_ip: str = "127.0.0.1",
|
||||||
return 0, [0.0, 10.0, -20.0, 30.0, -40.0, 50.0, -60.0]
|
joints=None,
|
||||||
|
error_code: int = 0,
|
||||||
perf_counter_ns = iter(
|
joint_enabled=None,
|
||||||
[1_000_000_000, 1_002_000_000, 2_000_000_000, 2_003_000_000]
|
joint_error_codes=None,
|
||||||
)
|
arm_error_codes=None,
|
||||||
monotonic = iter([10.0, 10.011])
|
arm_current_status: int = 0,
|
||||||
monkeypatch.setattr(
|
):
|
||||||
realman_adapter,
|
if joints is None:
|
||||||
"time",
|
joints = [0.0, 10.0, -20.0, 30.0, -40.0, 50.0, -60.0]
|
||||||
type(
|
if joint_enabled is None:
|
||||||
"FakeTime",
|
joint_enabled = [True] * 7
|
||||||
(),
|
if joint_error_codes is None:
|
||||||
{
|
joint_error_codes = [0] * 7
|
||||||
"perf_counter_ns": staticmethod(lambda: next(perf_counter_ns)),
|
if arm_error_codes is None:
|
||||||
"monotonic": staticmethod(lambda: next(monotonic)),
|
arm_error_codes = []
|
||||||
},
|
return SimpleNamespace(
|
||||||
|
errCode=error_code,
|
||||||
|
arm_ip=robot_ip.encode(),
|
||||||
|
joint_status=SimpleNamespace(
|
||||||
|
joint_position=joints,
|
||||||
|
joint_en_flag=joint_enabled,
|
||||||
|
joint_err_code=joint_error_codes,
|
||||||
),
|
),
|
||||||
|
err=SimpleNamespace(
|
||||||
|
err_len=len(arm_error_codes),
|
||||||
|
err=list(arm_error_codes),
|
||||||
|
),
|
||||||
|
arm_current_status=arm_current_status,
|
||||||
)
|
)
|
||||||
adapter = RealManAdapter("127.0.0.1", 8080, 0, 0.01)
|
|
||||||
adapter._arm = FakeArm()
|
|
||||||
|
|
||||||
adapter._read_joint_state_once()
|
|
||||||
|
def _install_fake_sdk(monkeypatch, *, push_return=0, send_feedback=True):
|
||||||
|
class FakeThreadMode:
|
||||||
|
RM_TRIPLE_MODE_E = 2
|
||||||
|
|
||||||
|
class FakePushConfig:
|
||||||
|
def __init__(self, *args):
|
||||||
|
self.args = args
|
||||||
|
|
||||||
|
class FakeArm:
|
||||||
|
instance = None
|
||||||
|
|
||||||
|
def __init__(self, mode):
|
||||||
|
self.mode = mode
|
||||||
|
self.callback = None
|
||||||
|
self.config = None
|
||||||
|
self.delete_calls = 0
|
||||||
|
FakeArm.instance = self
|
||||||
|
|
||||||
|
def rm_create_robot_arm(self, robot_ip, robot_port):
|
||||||
|
self.robot_ip = robot_ip
|
||||||
|
self.robot_port = robot_port
|
||||||
|
return SimpleNamespace(id=1)
|
||||||
|
|
||||||
|
def rm_realtime_arm_state_call_back(self, callback):
|
||||||
|
self.callback = callback
|
||||||
|
|
||||||
|
def rm_set_realtime_push(self, config):
|
||||||
|
self.config = config
|
||||||
|
if push_return == 0 and send_feedback:
|
||||||
|
self.callback(_udp_state())
|
||||||
|
return push_return
|
||||||
|
|
||||||
|
def rm_delete_robot_arm(self):
|
||||||
|
self.delete_calls += 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sdk = ModuleType("Robotic_Arm.rm_robot_interface")
|
||||||
|
sdk.RoboticArm = FakeArm
|
||||||
|
sdk.rm_thread_mode_e = FakeThreadMode
|
||||||
|
sdk.rm_realtime_push_config_t = FakePushConfig
|
||||||
|
sdk.rm_realtime_arm_state_callback_ptr = lambda callback: callback
|
||||||
|
package = ModuleType("Robotic_Arm")
|
||||||
|
package.rm_robot_interface = sdk
|
||||||
|
monkeypatch.setitem(sys.modules, "Robotic_Arm", package)
|
||||||
|
monkeypatch.setitem(sys.modules, "Robotic_Arm.rm_robot_interface", sdk)
|
||||||
|
return SimpleNamespace(RoboticArm=FakeArm)
|
||||||
|
|
||||||
|
|
||||||
|
def test_udp_feedback_is_cached_in_radians(monkeypatch) -> None:
|
||||||
|
monotonic = iter([10.0, 10.005])
|
||||||
|
monkeypatch.setattr(realman_adapter.time, "monotonic", lambda: next(monotonic))
|
||||||
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"127.0.0.1",
|
||||||
|
8090,
|
||||||
|
)
|
||||||
|
adapter._accept_realtime_feedback = True
|
||||||
|
|
||||||
|
adapter._on_realtime_arm_state(_udp_state())
|
||||||
first = adapter.get_latest_joint_state()
|
first = adapter.get_latest_joint_state()
|
||||||
adapter._read_joint_state_once()
|
adapter._on_realtime_arm_state(_udp_state())
|
||||||
second = adapter.get_latest_joint_state()
|
second = adapter.get_latest_joint_state()
|
||||||
|
|
||||||
assert first is not None
|
assert first is not None
|
||||||
assert first.positions == pytest.approx(
|
assert first.positions == pytest.approx(
|
||||||
[math.radians(value) for value in [0, 10, -20, 30, -40, 50, -60]]
|
[math.radians(value) for value in [0, 10, -20, 30, -40, 50, -60]]
|
||||||
)
|
)
|
||||||
assert first.read_duration_ms == pytest.approx(2.0)
|
assert first.read_duration_ms is None
|
||||||
assert first.update_interval_ms is None
|
assert first.update_interval_ms is None
|
||||||
assert second is not None
|
assert second is not None
|
||||||
assert second.read_duration_ms == pytest.approx(3.0)
|
assert second.read_duration_ms is None
|
||||||
assert second.update_interval_ms == pytest.approx(11.0)
|
assert second.update_interval_ms == pytest.approx(5.0)
|
||||||
|
|
||||||
|
|
||||||
def test_feedback_loop_uses_absolute_schedule_without_catch_up(monkeypatch) -> None:
|
@pytest.mark.parametrize(
|
||||||
class FakeStopEvent:
|
"state",
|
||||||
def __init__(self) -> None:
|
[
|
||||||
self.checks = 0
|
_udp_state(error_code=-3),
|
||||||
self.waits = []
|
_udp_state(robot_ip="192.168.192.18"),
|
||||||
|
_udp_state(joints=[0.0] * 6),
|
||||||
def is_set(self):
|
_udp_state(joints=[0.0, 0.0, 0.0, math.nan, 0.0, 0.0, 0.0]),
|
||||||
self.checks += 1
|
],
|
||||||
return self.checks > 3
|
)
|
||||||
|
def test_invalid_udp_feedback_does_not_replace_snapshot(state) -> None:
|
||||||
def wait(self, timeout):
|
adapter = RealManAdapter(
|
||||||
self.waits.append(timeout)
|
"127.0.0.1",
|
||||||
return False
|
8080,
|
||||||
|
0,
|
||||||
monotonic = iter([0.0, 0.005, 0.018, 0.018, 0.023])
|
"127.0.0.1",
|
||||||
monkeypatch.setattr(
|
8090,
|
||||||
realman_adapter,
|
|
||||||
"time",
|
|
||||||
type(
|
|
||||||
"FakeTime",
|
|
||||||
(),
|
|
||||||
{"monotonic": staticmethod(lambda: next(monotonic))},
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
adapter = RealManAdapter("127.0.0.1", 8080, 0, 0.008)
|
adapter._accept_realtime_feedback = True
|
||||||
stop_event = FakeStopEvent()
|
adapter._on_realtime_arm_state(_udp_state(joints=[1.0] * 7))
|
||||||
reads = []
|
before = adapter.get_latest_joint_state()
|
||||||
adapter._feedback_stop = stop_event
|
|
||||||
adapter._read_joint_state_once = lambda: reads.append(None)
|
|
||||||
|
|
||||||
adapter._feedback_loop()
|
adapter._on_realtime_arm_state(state)
|
||||||
|
|
||||||
assert len(reads) == 3
|
assert adapter.get_latest_joint_state() == before
|
||||||
assert stop_event.waits == pytest.approx([0.003, 0.003])
|
|
||||||
|
|
||||||
|
def test_udp_joint_fault_marks_snapshot_unready() -> None:
|
||||||
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"127.0.0.1",
|
||||||
|
8090,
|
||||||
|
)
|
||||||
|
adapter._accept_realtime_feedback = True
|
||||||
|
|
||||||
|
adapter._on_realtime_arm_state(
|
||||||
|
_udp_state(
|
||||||
|
joint_enabled=[True, True, False, True, True, True, True],
|
||||||
|
joint_error_codes=[0, 0, 17, 0, 0, 0, 0],
|
||||||
|
arm_error_codes=[42],
|
||||||
|
arm_current_status=9,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
snapshot = adapter.get_latest_joint_state()
|
||||||
|
assert snapshot is not None
|
||||||
|
assert snapshot.motion_ready is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_udp_stop_status_marks_snapshot_unready_without_joint_error() -> None:
|
||||||
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"127.0.0.1",
|
||||||
|
8090,
|
||||||
|
)
|
||||||
|
adapter._accept_realtime_feedback = True
|
||||||
|
|
||||||
|
adapter._on_realtime_arm_state(_udp_state(arm_current_status=9))
|
||||||
|
|
||||||
|
snapshot = adapter.get_latest_joint_state()
|
||||||
|
assert snapshot is not None
|
||||||
|
assert snapshot.motion_ready is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_udp_zero_arm_error_code_is_motion_ready() -> None:
|
||||||
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"127.0.0.1",
|
||||||
|
8090,
|
||||||
|
)
|
||||||
|
adapter._accept_realtime_feedback = True
|
||||||
|
|
||||||
|
adapter._on_realtime_arm_state(_udp_state(arm_error_codes=[0]))
|
||||||
|
|
||||||
|
snapshot = adapter.get_latest_joint_state()
|
||||||
|
assert snapshot is not None
|
||||||
|
assert snapshot.motion_ready is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_udp_fault_and_recovery_are_logged_once_per_transition() -> None:
|
||||||
|
class FakeLogger:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.infos = []
|
||||||
|
self.warnings = []
|
||||||
|
|
||||||
|
def info(self, message):
|
||||||
|
self.infos.append(message)
|
||||||
|
|
||||||
|
def warn(self, message):
|
||||||
|
self.warnings.append(message)
|
||||||
|
|
||||||
|
logger = FakeLogger()
|
||||||
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"127.0.0.1",
|
||||||
|
8090,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
|
adapter._accept_realtime_feedback = True
|
||||||
|
adapter._on_realtime_arm_state(_udp_state())
|
||||||
|
fault = _udp_state(
|
||||||
|
joint_enabled=[False] * 7,
|
||||||
|
joint_error_codes=[17, 0, 0, 0, 0, 0, 0],
|
||||||
|
arm_error_codes=[42],
|
||||||
|
arm_current_status=9,
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter._on_realtime_arm_state(fault)
|
||||||
|
adapter._on_realtime_arm_state(fault)
|
||||||
|
adapter._on_realtime_arm_state(_udp_state())
|
||||||
|
|
||||||
|
assert len(logger.warnings) == 1
|
||||||
|
assert "joint_errors=[17, 0, 0, 0, 0, 0, 0]" in logger.warnings[0]
|
||||||
|
assert len([message for message in logger.infos if "恢复正常" in message]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_connect_configures_udp_feedback_and_waits_for_first_frame(monkeypatch) -> None:
|
||||||
|
fake_sdk = _install_fake_sdk(monkeypatch)
|
||||||
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"192.168.192.148",
|
||||||
|
8090,
|
||||||
|
configure_safety_limits=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter.connect()
|
||||||
|
|
||||||
|
arm = fake_sdk.RoboticArm.instance
|
||||||
|
assert arm is not None
|
||||||
|
assert arm.config.args == (5, True, 8090, 0, "192.168.192.148")
|
||||||
|
assert arm.callback is adapter._realtime_callback
|
||||||
|
assert adapter.get_latest_joint_state() is not None
|
||||||
|
assert not hasattr(adapter, "_feedback_thread")
|
||||||
|
|
||||||
|
|
||||||
|
def test_udp_configuration_failure_cleans_up_robot_handle(monkeypatch) -> None:
|
||||||
|
fake_sdk = _install_fake_sdk(monkeypatch, push_return=1)
|
||||||
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"192.168.192.148",
|
||||||
|
8090,
|
||||||
|
configure_safety_limits=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="rm_set_realtime_push"):
|
||||||
|
adapter.connect()
|
||||||
|
|
||||||
|
assert fake_sdk.RoboticArm.instance.delete_calls == 1
|
||||||
|
assert adapter._arm is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_udp_first_frame_timeout_cleans_up_robot_handle(monkeypatch) -> None:
|
||||||
|
fake_sdk = _install_fake_sdk(monkeypatch, send_feedback=False)
|
||||||
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"192.168.192.148",
|
||||||
|
8090,
|
||||||
|
configure_safety_limits=False,
|
||||||
|
)
|
||||||
|
adapter._feedback_ready = SimpleNamespace(
|
||||||
|
clear=lambda: None,
|
||||||
|
set=lambda: None,
|
||||||
|
wait=lambda timeout: False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="within 2 seconds"):
|
||||||
|
adapter.connect()
|
||||||
|
|
||||||
|
assert fake_sdk.RoboticArm.instance.delete_calls == 1
|
||||||
|
assert adapter._arm is None
|
||||||
|
|
||||||
|
|
||||||
def test_joint_target_uses_movej_canfd_in_degrees() -> None:
|
def test_joint_target_uses_movej_canfd_in_degrees() -> None:
|
||||||
@@ -196,7 +424,13 @@ def test_joint_target_uses_movej_canfd_in_degrees() -> None:
|
|||||||
self.calls.append(args)
|
self.calls.append(args)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
adapter = RealManAdapter("127.0.0.1", 8080, 0, 0.01)
|
adapter = RealManAdapter(
|
||||||
|
"127.0.0.1",
|
||||||
|
8080,
|
||||||
|
0,
|
||||||
|
"127.0.0.1",
|
||||||
|
8090,
|
||||||
|
)
|
||||||
adapter._arm = FakeArm()
|
adapter._arm = FakeArm()
|
||||||
target = [math.radians(value) for value in [1, 2, 3, 4, 5, 6, 7]]
|
target = [math.radians(value) for value in [1, 2, 3, 4, 5, 6, 7]]
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import math
|
||||||
import time
|
import time
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
@@ -45,6 +46,85 @@ def test_missing_or_stale_feedback_does_not_enable_qp() -> None:
|
|||||||
assert teleop._fresh_joint_state() is None
|
assert teleop._fresh_joint_state() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_joint_feedback_does_not_enable_qp() -> None:
|
||||||
|
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||||
|
teleop._command_timeout_sec = 0.12
|
||||||
|
teleop._adapter = SimpleNamespace(
|
||||||
|
get_latest_joint_state=lambda: JointStateSnapshot(
|
||||||
|
[0.0] * 7,
|
||||||
|
time.monotonic(),
|
||||||
|
motion_ready=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert teleop._fresh_joint_state() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_joint_command_step_limits_acceleration_from_rest() -> None:
|
||||||
|
dt = 1.0 / 125.0
|
||||||
|
target, velocity = SingleArmVelocityTeleop._limit_joint_command_step(
|
||||||
|
target=[0.2] * 7,
|
||||||
|
previous_target=[0.0] * 7,
|
||||||
|
previous_velocity=[0.0] * 7,
|
||||||
|
max_speed=math.radians(180.0),
|
||||||
|
max_acceleration=math.radians(300.0),
|
||||||
|
dt=dt,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert velocity == pytest.approx([math.radians(2.4)] * 7)
|
||||||
|
assert target == pytest.approx([math.radians(0.0192)] * 7)
|
||||||
|
|
||||||
|
|
||||||
|
def test_feedback_fault_blocks_grip_until_release() -> None:
|
||||||
|
class FakeClock:
|
||||||
|
def now(self):
|
||||||
|
return FakeTime()
|
||||||
|
|
||||||
|
teleop = object.__new__(SingleArmVelocityTeleop)
|
||||||
|
teleop._adapter = SimpleNamespace(
|
||||||
|
get_latest_joint_state=lambda: JointStateSnapshot(
|
||||||
|
[0.1] * 7,
|
||||||
|
time.monotonic(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
teleop._command_timeout_sec = 0.12
|
||||||
|
teleop._joint_feedback_ready = True
|
||||||
|
teleop._arm_name = "right_rm75"
|
||||||
|
teleop._last_msg = SimpleNamespace(
|
||||||
|
grip=True,
|
||||||
|
pose=SimpleNamespace(
|
||||||
|
position=SimpleNamespace(x=0.0, y=0.0, z=0.0),
|
||||||
|
orientation=SimpleNamespace(x=0.0, y=0.0, z=0.0, w=1.0),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
teleop._last_msg_time = FakeTime()
|
||||||
|
teleop._active = False
|
||||||
|
teleop._enable_orientation_control = False
|
||||||
|
teleop._last_valid_joint_target = None
|
||||||
|
teleop._last_current_pose = None
|
||||||
|
teleop._ik_solver = SimpleNamespace(
|
||||||
|
update_joint_state=lambda joints: np.eye(4)
|
||||||
|
)
|
||||||
|
teleop._grip_rearm_required = True
|
||||||
|
teleop.get_clock = lambda: FakeClock()
|
||||||
|
teleop.get_logger = lambda: FakeLogger()
|
||||||
|
stopped = []
|
||||||
|
entered = []
|
||||||
|
teleop._safe_stop = lambda reset_active: stopped.append(reset_active)
|
||||||
|
teleop._enter_active_control = lambda *args: entered.append(args)
|
||||||
|
|
||||||
|
teleop._control_tick()
|
||||||
|
assert entered == []
|
||||||
|
|
||||||
|
teleop._last_msg.grip = False
|
||||||
|
teleop._control_tick()
|
||||||
|
assert teleop._grip_rearm_required is False
|
||||||
|
|
||||||
|
teleop._last_msg.grip = True
|
||||||
|
teleop._control_tick()
|
||||||
|
assert len(entered) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_stale_feedback_stops_before_active_control() -> None:
|
def test_stale_feedback_stops_before_active_control() -> None:
|
||||||
stopped = []
|
stopped = []
|
||||||
entered = []
|
entered = []
|
||||||
@@ -256,6 +336,11 @@ def test_joint_send_failure_requests_slow_stop_and_resets_control() -> None:
|
|||||||
teleop._follow = False
|
teleop._follow = False
|
||||||
teleop._arm_name = "left_rm75"
|
teleop._arm_name = "left_rm75"
|
||||||
teleop._stop_sent = False
|
teleop._stop_sent = False
|
||||||
|
teleop._last_joint_command_target = [0.0] * 7
|
||||||
|
teleop._last_joint_command_velocity = [0.0] * 7
|
||||||
|
teleop._joint_command_max_speed = math.radians(180.0)
|
||||||
|
teleop._joint_command_max_acceleration = math.radians(300.0)
|
||||||
|
teleop._dt = 1.0 / 125.0
|
||||||
teleop.get_logger = lambda: FakeLogger()
|
teleop.get_logger = lambda: FakeLogger()
|
||||||
teleop._safe_stop = lambda reset_active: reset_calls.append(reset_active)
|
teleop._safe_stop = lambda reset_active: reset_calls.append(reset_active)
|
||||||
|
|
||||||
|
|||||||
@@ -99,12 +99,6 @@ class PlacoIkSolver:
|
|||||||
np.eye(4),
|
np.eye(4),
|
||||||
)
|
)
|
||||||
self._frame_task.configure("rm75_frame", "soft", 1.0)
|
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)
|
self._solver.add_kinetic_energy_regularization_task(1e-6)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ipaddress
|
||||||
import math
|
import math
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -32,6 +33,7 @@ class JointStateSnapshot:
|
|||||||
received_at: float
|
received_at: float
|
||||||
read_duration_ms: float | None = None
|
read_duration_ms: float | None = None
|
||||||
update_interval_ms: float | None = None
|
update_interval_ms: float | None = None
|
||||||
|
motion_ready: bool = True
|
||||||
|
|
||||||
|
|
||||||
class MockRealManAdapter:
|
class MockRealManAdapter:
|
||||||
@@ -85,7 +87,9 @@ class RealManAdapter:
|
|||||||
robot_ip: str,
|
robot_ip: str,
|
||||||
robot_port: int,
|
robot_port: int,
|
||||||
avoid_singularity: int,
|
avoid_singularity: int,
|
||||||
feedback_period: float,
|
realtime_push_host_ip: str,
|
||||||
|
realtime_push_port: int,
|
||||||
|
realtime_push_cycle_ms: int = 5,
|
||||||
logger: Any | None = None,
|
logger: Any | None = None,
|
||||||
configure_safety_limits: bool = True,
|
configure_safety_limits: bool = True,
|
||||||
max_line_speed: float = 1.0,
|
max_line_speed: float = 1.0,
|
||||||
@@ -103,9 +107,22 @@ class RealManAdapter:
|
|||||||
self._robot_ip = robot_ip
|
self._robot_ip = robot_ip
|
||||||
self._robot_port = robot_port
|
self._robot_port = robot_port
|
||||||
self._avoid_singularity = avoid_singularity
|
self._avoid_singularity = avoid_singularity
|
||||||
if feedback_period <= 0.0:
|
try:
|
||||||
raise ValueError("feedback_period must be positive")
|
self._realtime_push_host_ip = str(
|
||||||
self._feedback_period = feedback_period
|
ipaddress.IPv4Address(realtime_push_host_ip)
|
||||||
|
)
|
||||||
|
except ipaddress.AddressValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"realtime_push_host_ip must be a valid IPv4 address"
|
||||||
|
) from exc
|
||||||
|
if not 1 <= realtime_push_port <= 65535:
|
||||||
|
raise ValueError("realtime_push_port must be between 1 and 65535")
|
||||||
|
if realtime_push_cycle_ms <= 0 or realtime_push_cycle_ms % 5 != 0:
|
||||||
|
raise ValueError(
|
||||||
|
"realtime_push_cycle_ms must be a positive multiple of 5"
|
||||||
|
)
|
||||||
|
self._realtime_push_port = realtime_push_port
|
||||||
|
self._realtime_push_cycle_ms = realtime_push_cycle_ms
|
||||||
self._logger = logger
|
self._logger = logger
|
||||||
self._configure_safety_limits = configure_safety_limits
|
self._configure_safety_limits = configure_safety_limits
|
||||||
self._max_line_speed = max_line_speed
|
self._max_line_speed = max_line_speed
|
||||||
@@ -123,20 +140,31 @@ class RealManAdapter:
|
|||||||
self._arm: Any | None = None
|
self._arm: Any | None = None
|
||||||
self._joint_state_lock = threading.Lock()
|
self._joint_state_lock = threading.Lock()
|
||||||
self._latest_joint_state: JointStateSnapshot | None = None
|
self._latest_joint_state: JointStateSnapshot | None = None
|
||||||
self._feedback_stop = threading.Event()
|
self._feedback_ready = threading.Event()
|
||||||
self._feedback_thread: threading.Thread | None = None
|
self._realtime_callback: Any | None = None
|
||||||
|
self._accept_realtime_feedback = False
|
||||||
self._feedback_fault_logged = False
|
self._feedback_fault_logged = False
|
||||||
|
self._last_motion_status: tuple[Any, ...] | None = None
|
||||||
|
|
||||||
def connect(self) -> None:
|
def connect(self) -> None:
|
||||||
try:
|
try:
|
||||||
from Robotic_Arm.rm_robot_interface import RoboticArm, rm_thread_mode_e
|
from Robotic_Arm.rm_robot_interface import (
|
||||||
|
RoboticArm,
|
||||||
|
rm_realtime_arm_state_callback_ptr,
|
||||||
|
rm_realtime_push_config_t,
|
||||||
|
rm_thread_mode_e,
|
||||||
|
)
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
"未安装睿尔曼 Python API2。请安装厂商 SDK,或用 use_mock:=true 先跑模拟模式。"
|
"未安装睿尔曼 Python API2。请安装厂商 SDK,或用 use_mock:=true 先跑模拟模式。"
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
self._arm = RoboticArm(rm_thread_mode_e.RM_TRIPLE_MODE_E)
|
self._arm = RoboticArm(rm_thread_mode_e.RM_TRIPLE_MODE_E)
|
||||||
handle = self._arm.rm_create_robot_arm(self._robot_ip, self._robot_port)
|
try:
|
||||||
|
handle = self._arm.rm_create_robot_arm(
|
||||||
|
self._robot_ip,
|
||||||
|
self._robot_port,
|
||||||
|
)
|
||||||
self._check_robot_handle(handle)
|
self._check_robot_handle(handle)
|
||||||
self._log_info(
|
self._log_info(
|
||||||
"RealMan connected: "
|
"RealMan connected: "
|
||||||
@@ -148,13 +176,45 @@ class RealManAdapter:
|
|||||||
self._apply_safety_limits()
|
self._apply_safety_limits()
|
||||||
if self._move_to_initial_pose_on_connect:
|
if self._move_to_initial_pose_on_connect:
|
||||||
self._move_to_initial_pose()
|
self._move_to_initial_pose()
|
||||||
self._feedback_stop.clear()
|
self._feedback_ready.clear()
|
||||||
self._feedback_thread = threading.Thread(
|
self._accept_realtime_feedback = True
|
||||||
target=self._feedback_loop,
|
self._realtime_callback = rm_realtime_arm_state_callback_ptr(
|
||||||
name=f"rm75_feedback_{self._robot_ip}",
|
self._on_realtime_arm_state
|
||||||
daemon=True,
|
|
||||||
)
|
)
|
||||||
self._feedback_thread.start()
|
self._arm.rm_realtime_arm_state_call_back(
|
||||||
|
self._realtime_callback
|
||||||
|
)
|
||||||
|
config = rm_realtime_push_config_t(
|
||||||
|
self._realtime_push_cycle_ms,
|
||||||
|
True,
|
||||||
|
self._realtime_push_port,
|
||||||
|
0,
|
||||||
|
self._realtime_push_host_ip,
|
||||||
|
)
|
||||||
|
self._check_return(
|
||||||
|
self._arm.rm_set_realtime_push(config),
|
||||||
|
"rm_set_realtime_push",
|
||||||
|
)
|
||||||
|
if not self._feedback_ready.wait(timeout=2.0):
|
||||||
|
raise RuntimeError(
|
||||||
|
"RealMan UDP realtime feedback did not receive a valid "
|
||||||
|
"frame within 2 seconds"
|
||||||
|
)
|
||||||
|
self._log_info(
|
||||||
|
"RealMan UDP realtime feedback ready: "
|
||||||
|
f"host={self._realtime_push_host_ip}:"
|
||||||
|
f"{self._realtime_push_port}, "
|
||||||
|
f"cycle={self._realtime_push_cycle_ms} ms"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._accept_realtime_feedback = False
|
||||||
|
try:
|
||||||
|
self._arm.rm_delete_robot_arm()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._arm = None
|
||||||
|
self._realtime_callback = None
|
||||||
|
raise
|
||||||
|
|
||||||
def get_latest_joint_state(self) -> JointStateSnapshot | None:
|
def get_latest_joint_state(self) -> JointStateSnapshot | None:
|
||||||
with self._joint_state_lock:
|
with self._joint_state_lock:
|
||||||
@@ -165,6 +225,7 @@ class RealManAdapter:
|
|||||||
self._latest_joint_state.received_at,
|
self._latest_joint_state.received_at,
|
||||||
self._latest_joint_state.read_duration_ms,
|
self._latest_joint_state.read_duration_ms,
|
||||||
self._latest_joint_state.update_interval_ms,
|
self._latest_joint_state.update_interval_ms,
|
||||||
|
self._latest_joint_state.motion_ready,
|
||||||
)
|
)
|
||||||
|
|
||||||
def send_joint_target(self, joints: list[float], follow: bool) -> None:
|
def send_joint_target(self, joints: list[float], follow: bool) -> None:
|
||||||
@@ -219,70 +280,118 @@ class RealManAdapter:
|
|||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
if self._arm is None:
|
if self._arm is None:
|
||||||
return
|
return
|
||||||
|
self._accept_realtime_feedback = False
|
||||||
self.stop()
|
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:
|
try:
|
||||||
self._arm.rm_delete_robot_arm()
|
self._arm.rm_delete_robot_arm()
|
||||||
finally:
|
finally:
|
||||||
self._arm = None
|
self._arm = None
|
||||||
|
self._realtime_callback = None
|
||||||
|
|
||||||
def _require_arm(self) -> None:
|
def _require_arm(self) -> None:
|
||||||
if self._arm is None:
|
if self._arm is None:
|
||||||
raise RuntimeError("睿尔曼机械臂尚未连接")
|
raise RuntimeError("睿尔曼机械臂尚未连接")
|
||||||
|
|
||||||
def _feedback_loop(self) -> None:
|
def _on_realtime_arm_state(self, data: Any) -> None:
|
||||||
next_read_at = time.monotonic()
|
if not self._accept_realtime_feedback:
|
||||||
while not self._feedback_stop.is_set():
|
return
|
||||||
try:
|
try:
|
||||||
self._read_joint_state_once()
|
if data is None or int(data.errCode) != 0:
|
||||||
self._feedback_fault_logged = False
|
raise ValueError("invalid realtime feedback error code")
|
||||||
except Exception as exc:
|
arm_ip = data.arm_ip
|
||||||
if not self._feedback_fault_logged:
|
if isinstance(arm_ip, bytes):
|
||||||
self._log_warn(f"RealMan 关节反馈读取失败:{exc}")
|
arm_ip = arm_ip.decode("utf-8").split("\x00", 1)[0]
|
||||||
self._feedback_fault_logged = True
|
if str(arm_ip) != self._robot_ip:
|
||||||
next_read_at += self._feedback_period
|
raise ValueError(
|
||||||
remaining = next_read_at - time.monotonic()
|
f"unexpected realtime feedback source: {arm_ip}"
|
||||||
if remaining <= 0.0:
|
)
|
||||||
next_read_at = time.monotonic()
|
degrees = list(data.joint_status.joint_position)
|
||||||
continue
|
|
||||||
self._feedback_stop.wait(remaining)
|
|
||||||
|
|
||||||
def _read_joint_state_once(self) -> None:
|
|
||||||
self._require_arm()
|
|
||||||
read_started_ns = time.perf_counter_ns()
|
|
||||||
result = self._arm.rm_get_joint_degree()
|
|
||||||
read_duration_ms = (time.perf_counter_ns() - read_started_ns) * 1e-6
|
|
||||||
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 (
|
if (
|
||||||
not isinstance(degrees, (list, tuple))
|
len(degrees) != 7
|
||||||
or len(degrees) != 7
|
|
||||||
or not all(isinstance(value, Number) for value in degrees)
|
or not all(isinstance(value, Number) for value in degrees)
|
||||||
):
|
):
|
||||||
raise RuntimeError(f"RM75 关节反馈必须包含 7 个数值:{degrees!r}")
|
raise ValueError(
|
||||||
|
"RM75 UDP feedback must contain 7 numeric joints"
|
||||||
|
)
|
||||||
positions = [math.radians(float(value)) for value in degrees]
|
positions = [math.radians(float(value)) for value in degrees]
|
||||||
if not all(math.isfinite(value) for value in positions):
|
if not all(math.isfinite(value) for value in positions):
|
||||||
raise RuntimeError("RM75 关节反馈包含 NaN/Inf")
|
raise ValueError("RM75 UDP feedback contains NaN/Inf")
|
||||||
|
joint_enabled = [
|
||||||
|
bool(value) for value in data.joint_status.joint_en_flag
|
||||||
|
]
|
||||||
|
joint_errors = [
|
||||||
|
int(value) for value in data.joint_status.joint_err_code
|
||||||
|
]
|
||||||
|
if len(joint_enabled) != 7 or len(joint_errors) != 7:
|
||||||
|
raise ValueError(
|
||||||
|
"RM75 UDP feedback must contain 7 joint states"
|
||||||
|
)
|
||||||
|
arm_error_count = int(data.err.err_len)
|
||||||
|
arm_errors = [
|
||||||
|
int(value) for value in list(data.err.err)[:arm_error_count]
|
||||||
|
]
|
||||||
|
arm_errors = [code for code in arm_errors if code != 0]
|
||||||
|
arm_current_status = int(data.arm_current_status)
|
||||||
|
motion_ready = (
|
||||||
|
0 <= arm_current_status <= 8
|
||||||
|
and all(joint_enabled)
|
||||||
|
and not any(joint_errors)
|
||||||
|
and not arm_errors
|
||||||
|
)
|
||||||
|
motion_status = (
|
||||||
|
arm_current_status,
|
||||||
|
tuple(joint_enabled),
|
||||||
|
tuple(joint_errors),
|
||||||
|
tuple(arm_errors),
|
||||||
|
motion_ready,
|
||||||
|
)
|
||||||
received_at = time.monotonic()
|
received_at = time.monotonic()
|
||||||
with self._joint_state_lock:
|
with self._joint_state_lock:
|
||||||
update_interval_ms = (
|
update_interval_ms = (
|
||||||
None
|
None
|
||||||
if self._latest_joint_state is None
|
if self._latest_joint_state is None
|
||||||
else (received_at - self._latest_joint_state.received_at) * 1000.0
|
else (
|
||||||
|
received_at
|
||||||
|
- self._latest_joint_state.received_at
|
||||||
|
)
|
||||||
|
* 1000.0
|
||||||
)
|
)
|
||||||
self._latest_joint_state = JointStateSnapshot(
|
self._latest_joint_state = JointStateSnapshot(
|
||||||
positions,
|
positions,
|
||||||
received_at,
|
received_at,
|
||||||
read_duration_ms,
|
None,
|
||||||
update_interval_ms,
|
update_interval_ms,
|
||||||
|
motion_ready,
|
||||||
)
|
)
|
||||||
|
self._log_motion_status_transition(motion_status)
|
||||||
|
self._feedback_fault_logged = False
|
||||||
|
self._feedback_ready.set()
|
||||||
|
except Exception as exc:
|
||||||
|
if not self._feedback_fault_logged:
|
||||||
|
self._log_warn(
|
||||||
|
f"RealMan UDP realtime feedback invalid: {exc}"
|
||||||
|
)
|
||||||
|
self._feedback_fault_logged = True
|
||||||
|
|
||||||
|
def _log_motion_status_transition(
|
||||||
|
self,
|
||||||
|
status: tuple[Any, ...],
|
||||||
|
) -> None:
|
||||||
|
previous = self._last_motion_status
|
||||||
|
if status == previous:
|
||||||
|
return
|
||||||
|
self._last_motion_status = status
|
||||||
|
arm_status, joint_enabled, joint_errors, arm_errors, ready = status
|
||||||
|
details = (
|
||||||
|
f"arm_status={arm_status}, "
|
||||||
|
f"joint_enabled={list(joint_enabled)}, "
|
||||||
|
f"joint_errors={list(joint_errors)}, "
|
||||||
|
f"arm_errors={list(arm_errors)}"
|
||||||
|
)
|
||||||
|
if not ready:
|
||||||
|
self._log_warn(f"RealMan UDP 报警或掉使能:{details}")
|
||||||
|
elif previous is not None and not previous[-1]:
|
||||||
|
self._log_info(f"RealMan UDP 运动状态恢复正常:{details}")
|
||||||
|
|
||||||
def _apply_safety_limits(self) -> None:
|
def _apply_safety_limits(self) -> None:
|
||||||
# 真机安全限幅尽量下发到控制器;不支持的 SDK 接口会在 _try_call 中降级为警告。
|
# 真机安全限幅尽量下发到控制器;不支持的 SDK 接口会在 _try_call 中降级为警告。
|
||||||
|
|||||||
@@ -201,6 +201,9 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
self.declare_parameter("robot_urdf_path", "")
|
self.declare_parameter("robot_urdf_path", "")
|
||||||
self.declare_parameter("robot_ip", "192.168.1.18")
|
self.declare_parameter("robot_ip", "192.168.1.18")
|
||||||
self.declare_parameter("robot_port", 8080)
|
self.declare_parameter("robot_port", 8080)
|
||||||
|
self.declare_parameter("realtime_push_host_ip", "")
|
||||||
|
self.declare_parameter("realtime_push_port", 0)
|
||||||
|
self.declare_parameter("realtime_push_cycle_ms", 5)
|
||||||
self.declare_parameter("avoid_singularity", 1)
|
self.declare_parameter("avoid_singularity", 1)
|
||||||
self.declare_parameter("follow", False)
|
self.declare_parameter("follow", False)
|
||||||
self.declare_parameter("configure_safety_limits", True)
|
self.declare_parameter("configure_safety_limits", True)
|
||||||
@@ -255,6 +258,12 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
self._enable_tool_control = self._bool_parameter("enable_tool_control")
|
self._enable_tool_control = self._bool_parameter("enable_tool_control")
|
||||||
self._enable_trigger_gripper_control = self._bool_parameter("enable_trigger_gripper_control")
|
self._enable_trigger_gripper_control = self._bool_parameter("enable_trigger_gripper_control")
|
||||||
self._trigger_close_threshold = float(self.get_parameter("trigger_close_threshold").value)
|
self._trigger_close_threshold = float(self.get_parameter("trigger_close_threshold").value)
|
||||||
|
self._joint_command_max_speed = math.radians(
|
||||||
|
float(self.get_parameter("joint_max_speed").value)
|
||||||
|
)
|
||||||
|
self._joint_command_max_acceleration = math.radians(
|
||||||
|
float(self.get_parameter("joint_max_acc").value)
|
||||||
|
)
|
||||||
self._debug_topic_prefix = str(self.get_parameter("debug_topic_prefix").value).rstrip("/")
|
self._debug_topic_prefix = str(self.get_parameter("debug_topic_prefix").value).rstrip("/")
|
||||||
if not self._debug_topic_prefix:
|
if not self._debug_topic_prefix:
|
||||||
self._debug_topic_prefix = "/xr_rm"
|
self._debug_topic_prefix = "/xr_rm"
|
||||||
@@ -273,7 +282,11 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
self._last_command_time: Time | None = None
|
self._last_command_time: Time | None = None
|
||||||
self._last_current_pose: np.ndarray | None = None
|
self._last_current_pose: np.ndarray | None = None
|
||||||
self._last_valid_joint_target: list[float] | None = None
|
self._last_valid_joint_target: list[float] | None = None
|
||||||
|
self._latest_joint_positions: list[float] | None = None
|
||||||
|
self._last_joint_command_target: list[float] | None = None
|
||||||
|
self._last_joint_command_velocity: list[float] | None = None
|
||||||
self._joint_feedback_ready = False
|
self._joint_feedback_ready = False
|
||||||
|
self._grip_rearm_required = False
|
||||||
self._stop_sent = True
|
self._stop_sent = True
|
||||||
self._trigger_tool_open = True
|
self._trigger_tool_open = True
|
||||||
self._last_trigger_pressed: bool | None = None
|
self._last_trigger_pressed: bool | None = None
|
||||||
@@ -337,7 +350,15 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
robot_ip=self.get_parameter("robot_ip").value,
|
robot_ip=self.get_parameter("robot_ip").value,
|
||||||
robot_port=int(self.get_parameter("robot_port").value),
|
robot_port=int(self.get_parameter("robot_port").value),
|
||||||
avoid_singularity=int(self.get_parameter("avoid_singularity").value),
|
avoid_singularity=int(self.get_parameter("avoid_singularity").value),
|
||||||
feedback_period=self._dt,
|
realtime_push_host_ip=str(
|
||||||
|
self.get_parameter("realtime_push_host_ip").value
|
||||||
|
),
|
||||||
|
realtime_push_port=int(
|
||||||
|
self.get_parameter("realtime_push_port").value
|
||||||
|
),
|
||||||
|
realtime_push_cycle_ms=int(
|
||||||
|
self.get_parameter("realtime_push_cycle_ms").value
|
||||||
|
),
|
||||||
logger=self.get_logger(),
|
logger=self.get_logger(),
|
||||||
configure_safety_limits=self._bool_parameter("configure_safety_limits"),
|
configure_safety_limits=self._bool_parameter("configure_safety_limits"),
|
||||||
max_line_speed=float(self.get_parameter("max_line_speed").value),
|
max_line_speed=float(self.get_parameter("max_line_speed").value),
|
||||||
@@ -496,9 +517,10 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
now = self.get_clock().now()
|
now = self.get_clock().now()
|
||||||
snapshot = self._fresh_joint_state()
|
snapshot = self._fresh_joint_state()
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
|
self._grip_rearm_required = True
|
||||||
if self._joint_feedback_ready:
|
if self._joint_feedback_ready:
|
||||||
self.get_logger().warn(
|
self.get_logger().warn(
|
||||||
f"{self._arm_name} 关节反馈缺失或过期,机械臂停止。",
|
f"{self._arm_name} 关节反馈缺失、过期或机械臂未就绪,机械臂停止。",
|
||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
self._joint_feedback_ready = False
|
self._joint_feedback_ready = False
|
||||||
@@ -512,6 +534,7 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
throttle_duration_sec=1.0,
|
throttle_duration_sec=1.0,
|
||||||
)
|
)
|
||||||
self._joint_feedback_ready = False
|
self._joint_feedback_ready = False
|
||||||
|
self._grip_rearm_required = True
|
||||||
self._safe_stop(reset_active=True)
|
self._safe_stop(reset_active=True)
|
||||||
return
|
return
|
||||||
if not self._joint_feedback_ready:
|
if not self._joint_feedback_ready:
|
||||||
@@ -537,10 +560,18 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
return
|
return
|
||||||
|
|
||||||
if not self._last_msg.grip:
|
if not self._last_msg.grip:
|
||||||
|
self._grip_rearm_required = False
|
||||||
if self._active:
|
if self._active:
|
||||||
self.get_logger().info(f"{self._arm_name} Grip 松开,退出相对位姿遥操。")
|
self.get_logger().info(f"{self._arm_name} Grip 松开,退出相对位姿遥操。")
|
||||||
self._safe_stop(reset_active=True)
|
self._safe_stop(reset_active=True)
|
||||||
return
|
return
|
||||||
|
if getattr(self, "_grip_rearm_required", False):
|
||||||
|
self.get_logger().warn(
|
||||||
|
f"{self._arm_name} 反馈故障后等待 Grip 松开,禁止自动恢复运动。",
|
||||||
|
throttle_duration_sec=1.0,
|
||||||
|
)
|
||||||
|
self._safe_stop(reset_active=True)
|
||||||
|
return
|
||||||
|
|
||||||
controller_now = self._controller_xyz(self._last_msg)
|
controller_now = self._controller_xyz(self._last_msg)
|
||||||
try:
|
try:
|
||||||
@@ -957,6 +988,7 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
if (
|
if (
|
||||||
len(snapshot.positions) != 7
|
len(snapshot.positions) != 7
|
||||||
or not all(math.isfinite(value) for value in snapshot.positions)
|
or not all(math.isfinite(value) for value in snapshot.positions)
|
||||||
|
or not snapshot.motion_ready
|
||||||
):
|
):
|
||||||
return None
|
return None
|
||||||
return snapshot
|
return snapshot
|
||||||
@@ -968,6 +1000,7 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
current_pose = self._ik_solver.update_joint_state(
|
current_pose = self._ik_solver.update_joint_state(
|
||||||
snapshot.positions
|
snapshot.positions
|
||||||
)
|
)
|
||||||
|
self._latest_joint_positions = list(snapshot.positions)
|
||||||
self._last_current_pose = current_pose
|
self._last_current_pose = current_pose
|
||||||
if not self._active or self._last_valid_joint_target is None:
|
if not self._active or self._last_valid_joint_target is None:
|
||||||
self._last_valid_joint_target = list(snapshot.positions)
|
self._last_valid_joint_target = list(snapshot.positions)
|
||||||
@@ -1000,6 +1033,8 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
self._last_sent_target = None
|
self._last_sent_target = None
|
||||||
self._last_sent_orientation = None
|
self._last_sent_orientation = None
|
||||||
self._last_command_time = None
|
self._last_command_time = None
|
||||||
|
self._last_joint_command_target = None
|
||||||
|
self._last_joint_command_velocity = None
|
||||||
self._publish_stop_debug()
|
self._publish_stop_debug()
|
||||||
|
|
||||||
def _send_stop_once(self) -> None:
|
def _send_stop_once(self) -> None:
|
||||||
@@ -1034,8 +1069,22 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def _send_joint_target(self, joints: list[float]) -> bool:
|
def _send_joint_target(self, joints: list[float]) -> bool:
|
||||||
|
previous_target = self._last_joint_command_target
|
||||||
|
if previous_target is None:
|
||||||
|
previous_target = self._latest_joint_positions
|
||||||
|
if previous_target is None:
|
||||||
|
raise RuntimeError("valid joint feedback has not been initialized")
|
||||||
|
previous_velocity = self._last_joint_command_velocity or [0.0] * 7
|
||||||
|
limited_target, limited_velocity = self._limit_joint_command_step(
|
||||||
|
target=joints,
|
||||||
|
previous_target=previous_target,
|
||||||
|
previous_velocity=previous_velocity,
|
||||||
|
max_speed=self._joint_command_max_speed,
|
||||||
|
max_acceleration=self._joint_command_max_acceleration,
|
||||||
|
dt=self._dt,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
self._adapter.send_joint_target(joints, self._follow)
|
self._adapter.send_joint_target(limited_target, self._follow)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.get_logger().error(
|
self.get_logger().error(
|
||||||
f"{self._arm_name} 发送关节透传命令失败:{exc}",
|
f"{self._arm_name} 发送关节透传命令失败:{exc}",
|
||||||
@@ -1044,8 +1093,43 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
self._send_stop_once()
|
self._send_stop_once()
|
||||||
self._safe_stop(reset_active=True)
|
self._safe_stop(reset_active=True)
|
||||||
return False
|
return False
|
||||||
|
self._last_joint_command_target = limited_target
|
||||||
|
self._last_joint_command_velocity = limited_velocity
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _limit_joint_command_step(
|
||||||
|
target: list[float],
|
||||||
|
previous_target: list[float],
|
||||||
|
previous_velocity: list[float],
|
||||||
|
max_speed: float,
|
||||||
|
max_acceleration: float,
|
||||||
|
dt: float,
|
||||||
|
) -> tuple[list[float], list[float]]:
|
||||||
|
if (
|
||||||
|
len(target) != 7
|
||||||
|
or len(previous_target) != 7
|
||||||
|
or len(previous_velocity) != 7
|
||||||
|
):
|
||||||
|
raise ValueError("joint command state must contain 7 values")
|
||||||
|
if max_speed <= 0.0 or max_acceleration <= 0.0 or dt <= 0.0:
|
||||||
|
raise ValueError("joint command limits and dt must be positive")
|
||||||
|
desired_velocity = np.clip(
|
||||||
|
(np.asarray(target) - np.asarray(previous_target)) / dt,
|
||||||
|
-max_speed,
|
||||||
|
max_speed,
|
||||||
|
)
|
||||||
|
velocity_step = max_acceleration * dt
|
||||||
|
velocity = np.clip(
|
||||||
|
desired_velocity,
|
||||||
|
np.asarray(previous_velocity) - velocity_step,
|
||||||
|
np.asarray(previous_velocity) + velocity_step,
|
||||||
|
)
|
||||||
|
limited_target = np.asarray(previous_target) + velocity * dt
|
||||||
|
if not np.isfinite(limited_target).all():
|
||||||
|
raise ValueError("joint command contains NaN/Inf")
|
||||||
|
return limited_target.tolist(), velocity.tolist()
|
||||||
|
|
||||||
def _publish_debug(
|
def _publish_debug(
|
||||||
self,
|
self,
|
||||||
raw_target_pose: np.ndarray,
|
raw_target_pose: np.ndarray,
|
||||||
@@ -1151,6 +1235,10 @@ class SingleArmVelocityTeleop(Node):
|
|||||||
raise ValueError("cyl_radius_limit[1] must be > cyl_radius_limit[0]")
|
raise ValueError("cyl_radius_limit[1] must be > cyl_radius_limit[0]")
|
||||||
if self._low_z_min_radius < 0.0:
|
if self._low_z_min_radius < 0.0:
|
||||||
raise ValueError("low_z_min_radius must be >= 0")
|
raise ValueError("low_z_min_radius must be >= 0")
|
||||||
|
if self._joint_command_max_speed <= 0.0:
|
||||||
|
raise ValueError("joint_max_speed must be > 0")
|
||||||
|
if self._joint_command_max_acceleration <= 0.0:
|
||||||
|
raise ValueError("joint_max_acc must be > 0")
|
||||||
|
|
||||||
def _shutdown_tool_worker(self) -> None:
|
def _shutdown_tool_worker(self) -> None:
|
||||||
if self._tool_worker_thread is None or self._tool_command_queue is None:
|
if self._tool_worker_thread is None or self._tool_command_queue is None:
|
||||||
|
|||||||
Reference in New Issue
Block a user