feat: Implement UDP feedback for RM75 robot arms
This commit is contained in:
@@ -50,6 +50,9 @@ def main() -> None:
|
||||
drift_degrees = float(
|
||||
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)
|
||||
joints = initial_joints.tolist()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import math
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -18,7 +20,14 @@ def test_initial_pose_uses_joint_move_only() -> None:
|
||||
return 0
|
||||
|
||||
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._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")
|
||||
|
||||
|
||||
def test_joint_feedback_is_cached_in_radians(monkeypatch) -> None:
|
||||
class FakeArm:
|
||||
def rm_get_joint_degree(self):
|
||||
return 0, [0.0, 10.0, -20.0, 30.0, -40.0, 50.0, -60.0]
|
||||
|
||||
perf_counter_ns = iter(
|
||||
[1_000_000_000, 1_002_000_000, 2_000_000_000, 2_003_000_000]
|
||||
)
|
||||
monotonic = iter([10.0, 10.011])
|
||||
monkeypatch.setattr(
|
||||
realman_adapter,
|
||||
"time",
|
||||
type(
|
||||
"FakeTime",
|
||||
(),
|
||||
{
|
||||
"perf_counter_ns": staticmethod(lambda: next(perf_counter_ns)),
|
||||
"monotonic": staticmethod(lambda: next(monotonic)),
|
||||
},
|
||||
def _udp_state(
|
||||
*,
|
||||
robot_ip: str = "127.0.0.1",
|
||||
joints=None,
|
||||
error_code: int = 0,
|
||||
joint_enabled=None,
|
||||
joint_error_codes=None,
|
||||
arm_error_codes=None,
|
||||
arm_current_status: int = 0,
|
||||
):
|
||||
if joints is None:
|
||||
joints = [0.0, 10.0, -20.0, 30.0, -40.0, 50.0, -60.0]
|
||||
if joint_enabled is None:
|
||||
joint_enabled = [True] * 7
|
||||
if joint_error_codes is None:
|
||||
joint_error_codes = [0] * 7
|
||||
if arm_error_codes is None:
|
||||
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()
|
||||
adapter._read_joint_state_once()
|
||||
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 == pytest.approx(2.0)
|
||||
assert first.read_duration_ms is None
|
||||
assert first.update_interval_ms is None
|
||||
assert second is not None
|
||||
assert second.read_duration_ms == pytest.approx(3.0)
|
||||
assert second.update_interval_ms == pytest.approx(11.0)
|
||||
assert second.read_duration_ms is None
|
||||
assert second.update_interval_ms == pytest.approx(5.0)
|
||||
|
||||
|
||||
def test_feedback_loop_uses_absolute_schedule_without_catch_up(monkeypatch) -> None:
|
||||
class FakeStopEvent:
|
||||
def __init__(self) -> None:
|
||||
self.checks = 0
|
||||
self.waits = []
|
||||
|
||||
def is_set(self):
|
||||
self.checks += 1
|
||||
return self.checks > 3
|
||||
|
||||
def wait(self, timeout):
|
||||
self.waits.append(timeout)
|
||||
return False
|
||||
|
||||
monotonic = iter([0.0, 0.005, 0.018, 0.018, 0.023])
|
||||
monkeypatch.setattr(
|
||||
realman_adapter,
|
||||
"time",
|
||||
type(
|
||||
"FakeTime",
|
||||
(),
|
||||
{"monotonic": staticmethod(lambda: next(monotonic))},
|
||||
),
|
||||
@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 = RealManAdapter("127.0.0.1", 8080, 0, 0.008)
|
||||
stop_event = FakeStopEvent()
|
||||
reads = []
|
||||
adapter._feedback_stop = stop_event
|
||||
adapter._read_joint_state_once = lambda: reads.append(None)
|
||||
adapter._accept_realtime_feedback = True
|
||||
adapter._on_realtime_arm_state(_udp_state(joints=[1.0] * 7))
|
||||
before = adapter.get_latest_joint_state()
|
||||
|
||||
adapter._feedback_loop()
|
||||
adapter._on_realtime_arm_state(state)
|
||||
|
||||
assert len(reads) == 3
|
||||
assert stop_event.waits == pytest.approx([0.003, 0.003])
|
||||
assert adapter.get_latest_joint_state() == before
|
||||
|
||||
|
||||
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:
|
||||
@@ -196,7 +424,13 @@ def test_joint_target_uses_movej_canfd_in_degrees() -> None:
|
||||
self.calls.append(args)
|
||||
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()
|
||||
target = [math.radians(value) for value in [1, 2, 3, 4, 5, 6, 7]]
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import math
|
||||
import time
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
stopped = []
|
||||
entered = []
|
||||
@@ -256,6 +336,11 @@ def test_joint_send_failure_requests_slow_stop_and_resets_control() -> None:
|
||||
teleop._follow = False
|
||||
teleop._arm_name = "left_rm75"
|
||||
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._safe_stop = lambda reset_active: reset_calls.append(reset_active)
|
||||
|
||||
|
||||
@@ -99,12 +99,6 @@ class PlacoIkSolver:
|
||||
np.eye(4),
|
||||
)
|
||||
self._frame_task.configure("rm75_frame", "soft", 1.0)
|
||||
manipulability = self._solver.add_manipulability_task(
|
||||
"link_7",
|
||||
"both",
|
||||
1.0,
|
||||
)
|
||||
manipulability.configure("rm75_manipulability", "soft", 5e-2)
|
||||
self._solver.add_kinetic_energy_regularization_task(1e-6)
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
@@ -32,6 +33,7 @@ class JointStateSnapshot:
|
||||
received_at: float
|
||||
read_duration_ms: float | None = None
|
||||
update_interval_ms: float | None = None
|
||||
motion_ready: bool = True
|
||||
|
||||
|
||||
class MockRealManAdapter:
|
||||
@@ -85,7 +87,9 @@ class RealManAdapter:
|
||||
robot_ip: str,
|
||||
robot_port: 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,
|
||||
configure_safety_limits: bool = True,
|
||||
max_line_speed: float = 1.0,
|
||||
@@ -103,9 +107,22 @@ class RealManAdapter:
|
||||
self._robot_ip = robot_ip
|
||||
self._robot_port = robot_port
|
||||
self._avoid_singularity = avoid_singularity
|
||||
if feedback_period <= 0.0:
|
||||
raise ValueError("feedback_period must be positive")
|
||||
self._feedback_period = feedback_period
|
||||
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"
|
||||
)
|
||||
self._realtime_push_port = realtime_push_port
|
||||
self._realtime_push_cycle_ms = realtime_push_cycle_ms
|
||||
self._logger = logger
|
||||
self._configure_safety_limits = configure_safety_limits
|
||||
self._max_line_speed = max_line_speed
|
||||
@@ -123,38 +140,81 @@ class RealManAdapter:
|
||||
self._arm: Any | None = None
|
||||
self._joint_state_lock = threading.Lock()
|
||||
self._latest_joint_state: JointStateSnapshot | None = None
|
||||
self._feedback_stop = threading.Event()
|
||||
self._feedback_thread: threading.Thread | None = None
|
||||
self._feedback_ready = threading.Event()
|
||||
self._realtime_callback: Any | None = None
|
||||
self._accept_realtime_feedback = False
|
||||
self._feedback_fault_logged = False
|
||||
self._last_motion_status: tuple[Any, ...] | None = None
|
||||
|
||||
def connect(self) -> None:
|
||||
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:
|
||||
raise RuntimeError(
|
||||
"未安装睿尔曼 Python API2。请安装厂商 SDK,或用 use_mock:=true 先跑模拟模式。"
|
||||
) from exc
|
||||
|
||||
self._arm = RoboticArm(rm_thread_mode_e.RM_TRIPLE_MODE_E)
|
||||
handle = self._arm.rm_create_robot_arm(self._robot_ip, self._robot_port)
|
||||
self._check_robot_handle(handle)
|
||||
self._log_info(
|
||||
"RealMan connected: "
|
||||
f"ip={self._robot_ip}, port={self._robot_port}, "
|
||||
f"avoid_singularity={self._avoid_singularity}, "
|
||||
"command=rm_movej_canfd"
|
||||
)
|
||||
if self._configure_safety_limits:
|
||||
self._apply_safety_limits()
|
||||
if self._move_to_initial_pose_on_connect:
|
||||
self._move_to_initial_pose()
|
||||
self._feedback_stop.clear()
|
||||
self._feedback_thread = threading.Thread(
|
||||
target=self._feedback_loop,
|
||||
name=f"rm75_feedback_{self._robot_ip}",
|
||||
daemon=True,
|
||||
)
|
||||
self._feedback_thread.start()
|
||||
try:
|
||||
handle = self._arm.rm_create_robot_arm(
|
||||
self._robot_ip,
|
||||
self._robot_port,
|
||||
)
|
||||
self._check_robot_handle(handle)
|
||||
self._log_info(
|
||||
"RealMan connected: "
|
||||
f"ip={self._robot_ip}, port={self._robot_port}, "
|
||||
f"avoid_singularity={self._avoid_singularity}, "
|
||||
"command=rm_movej_canfd"
|
||||
)
|
||||
if self._configure_safety_limits:
|
||||
self._apply_safety_limits()
|
||||
if self._move_to_initial_pose_on_connect:
|
||||
self._move_to_initial_pose()
|
||||
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"
|
||||
)
|
||||
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:
|
||||
with self._joint_state_lock:
|
||||
@@ -165,6 +225,7 @@ class RealManAdapter:
|
||||
self._latest_joint_state.received_at,
|
||||
self._latest_joint_state.read_duration_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:
|
||||
@@ -219,70 +280,118 @@ class RealManAdapter:
|
||||
def close(self) -> None:
|
||||
if self._arm is None:
|
||||
return
|
||||
self._accept_realtime_feedback = False
|
||||
self.stop()
|
||||
self._feedback_stop.set()
|
||||
if self._feedback_thread is not None:
|
||||
self._feedback_thread.join(timeout=3.0)
|
||||
if self._feedback_thread.is_alive():
|
||||
self._log_warn("RealMan 关节反馈线程未在 3 秒内退出。")
|
||||
self._feedback_thread = None
|
||||
try:
|
||||
self._arm.rm_delete_robot_arm()
|
||||
finally:
|
||||
self._arm = None
|
||||
self._realtime_callback = None
|
||||
|
||||
def _require_arm(self) -> None:
|
||||
if self._arm is None:
|
||||
raise RuntimeError("睿尔曼机械臂尚未连接")
|
||||
|
||||
def _feedback_loop(self) -> None:
|
||||
next_read_at = time.monotonic()
|
||||
while not self._feedback_stop.is_set():
|
||||
try:
|
||||
self._read_joint_state_once()
|
||||
self._feedback_fault_logged = False
|
||||
except Exception as exc:
|
||||
if not self._feedback_fault_logged:
|
||||
self._log_warn(f"RealMan 关节反馈读取失败:{exc}")
|
||||
self._feedback_fault_logged = True
|
||||
next_read_at += self._feedback_period
|
||||
remaining = next_read_at - time.monotonic()
|
||||
if remaining <= 0.0:
|
||||
next_read_at = time.monotonic()
|
||||
continue
|
||||
self._feedback_stop.wait(remaining)
|
||||
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")
|
||||
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()
|
||||
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,
|
||||
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 _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 (
|
||||
not isinstance(degrees, (list, tuple))
|
||||
or len(degrees) != 7
|
||||
or not all(isinstance(value, Number) for value in degrees)
|
||||
):
|
||||
raise RuntimeError(f"RM75 关节反馈必须包含 7 个数值:{degrees!r}")
|
||||
positions = [math.radians(float(value)) for value in degrees]
|
||||
if not all(math.isfinite(value) for value in positions):
|
||||
raise RuntimeError("RM75 关节反馈包含 NaN/Inf")
|
||||
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,
|
||||
read_duration_ms,
|
||||
update_interval_ms,
|
||||
)
|
||||
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:
|
||||
# 真机安全限幅尽量下发到控制器;不支持的 SDK 接口会在 _try_call 中降级为警告。
|
||||
|
||||
@@ -201,6 +201,9 @@ class SingleArmVelocityTeleop(Node):
|
||||
self.declare_parameter("robot_urdf_path", "")
|
||||
self.declare_parameter("robot_ip", "192.168.1.18")
|
||||
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("follow", False)
|
||||
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_trigger_gripper_control = self._bool_parameter("enable_trigger_gripper_control")
|
||||
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("/")
|
||||
if not self._debug_topic_prefix:
|
||||
self._debug_topic_prefix = "/xr_rm"
|
||||
@@ -273,7 +282,11 @@ class SingleArmVelocityTeleop(Node):
|
||||
self._last_command_time: Time | None = None
|
||||
self._last_current_pose: np.ndarray | 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._grip_rearm_required = False
|
||||
self._stop_sent = True
|
||||
self._trigger_tool_open = True
|
||||
self._last_trigger_pressed: bool | None = None
|
||||
@@ -337,7 +350,15 @@ class SingleArmVelocityTeleop(Node):
|
||||
robot_ip=self.get_parameter("robot_ip").value,
|
||||
robot_port=int(self.get_parameter("robot_port").value),
|
||||
avoid_singularity=int(self.get_parameter("avoid_singularity").value),
|
||||
feedback_period=self._dt,
|
||||
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(),
|
||||
configure_safety_limits=self._bool_parameter("configure_safety_limits"),
|
||||
max_line_speed=float(self.get_parameter("max_line_speed").value),
|
||||
@@ -496,9 +517,10 @@ class SingleArmVelocityTeleop(Node):
|
||||
now = self.get_clock().now()
|
||||
snapshot = self._fresh_joint_state()
|
||||
if snapshot is None:
|
||||
self._grip_rearm_required = True
|
||||
if self._joint_feedback_ready:
|
||||
self.get_logger().warn(
|
||||
f"{self._arm_name} 关节反馈缺失或过期,机械臂停止。",
|
||||
f"{self._arm_name} 关节反馈缺失、过期或机械臂未就绪,机械臂停止。",
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
self._joint_feedback_ready = False
|
||||
@@ -512,6 +534,7 @@ class SingleArmVelocityTeleop(Node):
|
||||
throttle_duration_sec=1.0,
|
||||
)
|
||||
self._joint_feedback_ready = False
|
||||
self._grip_rearm_required = True
|
||||
self._safe_stop(reset_active=True)
|
||||
return
|
||||
if not self._joint_feedback_ready:
|
||||
@@ -537,10 +560,18 @@ class SingleArmVelocityTeleop(Node):
|
||||
return
|
||||
|
||||
if not self._last_msg.grip:
|
||||
self._grip_rearm_required = False
|
||||
if self._active:
|
||||
self.get_logger().info(f"{self._arm_name} Grip 松开,退出相对位姿遥操。")
|
||||
self._safe_stop(reset_active=True)
|
||||
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)
|
||||
try:
|
||||
@@ -957,6 +988,7 @@ class SingleArmVelocityTeleop(Node):
|
||||
if (
|
||||
len(snapshot.positions) != 7
|
||||
or not all(math.isfinite(value) for value in snapshot.positions)
|
||||
or not snapshot.motion_ready
|
||||
):
|
||||
return None
|
||||
return snapshot
|
||||
@@ -968,6 +1000,7 @@ class SingleArmVelocityTeleop(Node):
|
||||
current_pose = self._ik_solver.update_joint_state(
|
||||
snapshot.positions
|
||||
)
|
||||
self._latest_joint_positions = list(snapshot.positions)
|
||||
self._last_current_pose = current_pose
|
||||
if not self._active or self._last_valid_joint_target is None:
|
||||
self._last_valid_joint_target = list(snapshot.positions)
|
||||
@@ -1000,6 +1033,8 @@ class SingleArmVelocityTeleop(Node):
|
||||
self._last_sent_target = None
|
||||
self._last_sent_orientation = None
|
||||
self._last_command_time = None
|
||||
self._last_joint_command_target = None
|
||||
self._last_joint_command_velocity = None
|
||||
self._publish_stop_debug()
|
||||
|
||||
def _send_stop_once(self) -> None:
|
||||
@@ -1034,8 +1069,22 @@ class SingleArmVelocityTeleop(Node):
|
||||
return None
|
||||
|
||||
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:
|
||||
self._adapter.send_joint_target(joints, self._follow)
|
||||
self._adapter.send_joint_target(limited_target, self._follow)
|
||||
except Exception as exc:
|
||||
self.get_logger().error(
|
||||
f"{self._arm_name} 发送关节透传命令失败:{exc}",
|
||||
@@ -1044,8 +1093,43 @@ class SingleArmVelocityTeleop(Node):
|
||||
self._send_stop_once()
|
||||
self._safe_stop(reset_active=True)
|
||||
return False
|
||||
self._last_joint_command_target = limited_target
|
||||
self._last_joint_command_velocity = limited_velocity
|
||||
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(
|
||||
self,
|
||||
raw_target_pose: np.ndarray,
|
||||
@@ -1151,6 +1235,10 @@ class SingleArmVelocityTeleop(Node):
|
||||
raise ValueError("cyl_radius_limit[1] must be > cyl_radius_limit[0]")
|
||||
if self._low_z_min_radius < 0.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:
|
||||
if self._tool_worker_thread is None or self._tool_command_queue is None:
|
||||
|
||||
Reference in New Issue
Block a user