Skip to content

Commit bec360b

Browse files
committed
Parameterise PandaROS2RobotInterface for multi-arm setups
PandaROS2RobotInterface previously hard-coded the controller name (panda_arm_controller), the gripper action namespace (franka_gripper) and the limb attribute (self.robot.rarm). That made it unusable for setups where the same skrobot model drives two Pandas (e.g. JSK dual-Panda with arm_id rarm and larm) or where the controller name is non-default. Add four new keyword arguments to __init__: - arm_id (default panda) drives the default controller name when controller_name is not given (f"{arm_id}_arm_controller"). - controller_name overrides the action / state topic prefix directly when needed (e.g. a unified dual_panda_joint_trajectory_controller spanning 14 joints). - gripper_action_prefix replaces the franka_gripper hard-coded namespace. - limb_attr (default rarm) selects which attribute on the robot model contributes the joint_list. For dual-Panda setups instantiate the class twice with limb_attr=rarm and larm. - load_gripper (default True) skips creating the gripper ActionClients entirely. Useful for sim setups (mock_components) where franka_gripper is not running, and for unit tests where ActionClient.wait_for_server would otherwise block. The legacy rarm_controller property is preserved as an alias of the new arm_controller property for backward compatibility. Six unit tests cover the default behaviour, arm_id-driven controller name, explicit controller_name override, the rarm_controller alias, and the load_gripper=False path. The ros2-tests CI job now runs the whole tests/skrobot_tests/interfaces_tests/ros2/ directory so test_panda.py is exercised alongside test_base.py.
1 parent 0b2df59 commit bec360b

3 files changed

Lines changed: 208 additions & 26 deletions

File tree

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,5 +310,5 @@ jobs:
310310
run: |
311311
source /opt/ros/${{ matrix.ros-distribution }}/setup.bash
312312
pytest -v -p pytest_timeout --timeout=60 \
313-
tests/skrobot_tests/interfaces_tests/ros2/test_base.py
313+
tests/skrobot_tests/interfaces_tests/ros2/
314314

skrobot/interfaces/ros2/panda.py

Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,37 +16,96 @@
1616

1717

1818
class PandaROS2RobotInterface(ROS2RobotInterfaceBase):
19+
"""ROS 2 interface for a single Franka Panda arm.
20+
21+
Parameters
22+
----------
23+
robot : skrobot.model.RobotModel
24+
Robot model. Must expose a limb attribute (see ``limb_attr``)
25+
with a 7-joint arm.
26+
arm_id : str, default 'panda'
27+
Arm namespace from the URDF. Used to derive ``controller_name``
28+
when that argument is not given. Two arms on the same machine —
29+
e.g. a dual-Panda with arm_id 'right_arm' and 'left_arm' — can be
30+
controlled by instantiating this class twice with different
31+
arm_ids.
32+
controller_name : str or None
33+
Joint trajectory controller name. Defaults to
34+
``f'{arm_id}_arm_controller'``. The action namespace is then
35+
``/{controller_name}/follow_joint_trajectory``.
36+
gripper_action_prefix : str or None
37+
Prefix for the franka_gripper action namespaces. Defaults to
38+
``'franka_gripper'``. For multi-arm setups pass e.g.
39+
``'right_arm/franka_gripper'`` so move / stop go to per-arm
40+
action servers.
41+
limb_attr : str, default 'rarm'
42+
Attribute on ``robot`` whose ``joint_list`` defines the controlled
43+
joints. The default matches skrobot's ``Panda`` model, which
44+
exposes its 7-joint chain as ``robot.rarm``. For a multi-arm
45+
custom model whose limbs are named ``right_arm`` / ``left_arm``,
46+
instantiate the interface twice with the matching ``limb_attr``.
47+
load_gripper : bool, default True
48+
If False, skip creating gripper ActionClients entirely. Useful
49+
for sim setups (mock_components) where ``franka_gripper`` is not
50+
running, and for unit tests where blocking on
51+
``wait_for_server`` is not desired.
52+
"""
53+
54+
def __init__(self, *args,
55+
arm_id='panda',
56+
controller_name=None,
57+
gripper_action_prefix=None,
58+
limb_attr='rarm',
59+
load_gripper=True,
60+
**kwargs):
61+
self._arm_id = arm_id
62+
self._controller_name = controller_name or '{}_arm_controller'.format(arm_id)
63+
self._gripper_action_prefix = gripper_action_prefix or 'franka_gripper'
64+
self._limb_attr = limb_attr
1965

20-
def __init__(self, *args, **kwargs):
2166
super(PandaROS2RobotInterface, self).__init__(*args, **kwargs)
2267

23-
if FRANKA_GRIPPER_AVAILABLE:
24-
self.gripper_move = ActionClient(
25-
self,
26-
franka_gripper.action.Move,
27-
'franka_gripper/move')
28-
self.gripper_move.wait_for_server()
29-
30-
self.gripper_stop = ActionClient(
31-
self,
32-
franka_gripper.action.Stop,
33-
'franka_gripper/stop')
34-
self.gripper_stop.wait_for_server()
35-
else:
36-
self.get_logger().warn("franka_gripper package not available. Gripper functions disabled.")
68+
self.gripper_move = None
69+
self.gripper_stop = None
70+
if load_gripper:
71+
if FRANKA_GRIPPER_AVAILABLE:
72+
self.gripper_move = ActionClient(
73+
self,
74+
franka_gripper.action.Move,
75+
'{}/move'.format(self._gripper_action_prefix))
76+
self.gripper_move.wait_for_server()
77+
78+
self.gripper_stop = ActionClient(
79+
self,
80+
franka_gripper.action.Stop,
81+
'{}/stop'.format(self._gripper_action_prefix))
82+
self.gripper_stop.wait_for_server()
83+
else:
84+
self.get_logger().warn(
85+
"franka_gripper package not available. "
86+
"Gripper functions disabled.")
3787

3888
@property
39-
def rarm_controller(self):
89+
def arm_controller(self):
90+
limb = getattr(self.robot, self._limb_attr)
4091
return dict(
41-
controller_type='rarm_controller',
42-
controller_action='/panda_arm_controller/follow_joint_trajectory',
43-
controller_state='/panda_arm_controller/state',
92+
controller_type='{}_controller'.format(self._limb_attr),
93+
controller_action='/{}/follow_joint_trajectory'.format(
94+
self._controller_name),
95+
controller_state='/{}/state'.format(self._controller_name),
4496
action_type=control_msgs.action.FollowJointTrajectory,
45-
joint_names=[j.name for j in self.robot.rarm.joint_list],
97+
joint_names=[j.name for j in limb.joint_list],
4698
)
4799

100+
@property
101+
def rarm_controller(self):
102+
# Backwards-compatible alias for the pre-parameterised API where
103+
# the only controller was always called rarm_controller. Prefer
104+
# `arm_controller` in new code.
105+
return self.arm_controller
106+
48107
def default_controller(self):
49-
return [self.rarm_controller]
108+
return [self.arm_controller]
50109

51110
def grasp(self, width=0, **kwargs):
52111
self.move_gripper(width=width, **kwargs)
@@ -55,8 +114,11 @@ def ungrasp(self, **kwargs):
55114
self.move_gripper(width=WIDTH_MAX, **kwargs)
56115

57116
def move_gripper(self, width, speed=WIDTH_MAX, wait=True):
58-
if not FRANKA_GRIPPER_AVAILABLE:
59-
self.get_logger().warn("franka_gripper package not available. Cannot move gripper.")
117+
if self.gripper_move is None:
118+
self.get_logger().warn(
119+
"Gripper ActionClient was not initialised "
120+
"(load_gripper=False or franka_gripper not available). "
121+
"Cannot move gripper.")
60122
return
61123

62124
goal = franka_gripper.action.Move.Goal()
@@ -75,8 +137,11 @@ def move_gripper(self, width, speed=WIDTH_MAX, wait=True):
75137
self.gripper_move.send_goal_async(goal)
76138

77139
def stop_gripper(self, wait=True):
78-
if not FRANKA_GRIPPER_AVAILABLE:
79-
self.get_logger().warn("franka_gripper package not available. Cannot stop gripper.")
140+
if self.gripper_stop is None:
141+
self.get_logger().warn(
142+
"Gripper ActionClient was not initialised "
143+
"(load_gripper=False or franka_gripper not available). "
144+
"Cannot stop gripper.")
80145
return
81146

82147
goal = franka_gripper.action.Stop.Goal()
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""Unit tests for the parameterised PandaROS2RobotInterface."""
2+
import unittest
3+
4+
import pytest
5+
6+
7+
try:
8+
import rclpy
9+
10+
from skrobot.interfaces.ros2.panda import PandaROS2RobotInterface
11+
from skrobot.models import Panda
12+
ROS2_AVAILABLE = True
13+
except ImportError:
14+
ROS2_AVAILABLE = False
15+
16+
17+
pytestmark = pytest.mark.skipif(
18+
not ROS2_AVAILABLE, reason="ROS 2 / rclpy not importable")
19+
20+
21+
def _make(node_name, load_gripper=False, **kwargs):
22+
"""Build an interface with controllers / gripper disabled for unit tests.
23+
24+
Defaults: ``controller_timeout=0.1`` so the no-action-server controller
25+
spawn fails fast instead of blocking 3s; ``load_gripper=False`` so the
26+
gripper ``ActionClient.wait_for_server`` does not hang waiting for a
27+
franka_gripper node that is not running. Tests can override either.
28+
"""
29+
return PandaROS2RobotInterface(
30+
robot=Panda(),
31+
node_name=node_name,
32+
controller_timeout=0.1,
33+
load_gripper=load_gripper,
34+
joint_states_topic='_test_panda_' + node_name,
35+
**kwargs,
36+
)
37+
38+
39+
class TestPandaInterfaceDefaults(unittest.TestCase):
40+
41+
@classmethod
42+
def setUpClass(cls):
43+
if not rclpy.ok():
44+
rclpy.init()
45+
46+
def test_default_controller_action_uses_panda_arm_controller(self):
47+
ri = _make('test_default_controller')
48+
try:
49+
spec = ri.arm_controller
50+
assert spec['controller_action'] == \
51+
'/panda_arm_controller/follow_joint_trajectory'
52+
assert spec['controller_state'] == '/panda_arm_controller/state'
53+
# Default limb_attr='rarm' on the Panda model gives 7 joints.
54+
assert len(spec['joint_names']) == 7
55+
finally:
56+
ri.destroy_node()
57+
58+
def test_rarm_controller_alias_matches_arm_controller(self):
59+
ri = _make('test_rarm_alias')
60+
try:
61+
assert ri.rarm_controller == ri.arm_controller
62+
finally:
63+
ri.destroy_node()
64+
65+
66+
class TestPandaInterfaceParameterisation(unittest.TestCase):
67+
68+
@classmethod
69+
def setUpClass(cls):
70+
if not rclpy.ok():
71+
rclpy.init()
72+
73+
def test_arm_id_drives_default_controller_name(self):
74+
ri = _make('test_arm_id_drives_name', arm_id='right_arm')
75+
try:
76+
spec = ri.arm_controller
77+
assert spec['controller_action'] == \
78+
'/right_arm_arm_controller/follow_joint_trajectory'
79+
finally:
80+
ri.destroy_node()
81+
82+
def test_explicit_controller_name_wins_over_arm_id(self):
83+
ri = _make('test_explicit_controller_name',
84+
arm_id='right_arm',
85+
controller_name='dual_panda_joint_trajectory_controller')
86+
try:
87+
spec = ri.arm_controller
88+
assert spec['controller_action'] == \
89+
'/dual_panda_joint_trajectory_controller/follow_joint_trajectory'
90+
finally:
91+
ri.destroy_node()
92+
93+
94+
class TestPandaInterfaceGripperDisable(unittest.TestCase):
95+
96+
@classmethod
97+
def setUpClass(cls):
98+
if not rclpy.ok():
99+
rclpy.init()
100+
101+
def test_load_gripper_false_skips_action_clients(self):
102+
ri = _make('test_load_gripper_false')
103+
try:
104+
assert ri.gripper_move is None
105+
assert ri.gripper_stop is None
106+
finally:
107+
ri.destroy_node()
108+
109+
def test_grasp_warns_when_gripper_disabled(self):
110+
ri = _make('test_grasp_warn')
111+
try:
112+
# Should be a no-op (no exception, no real ActionClient call).
113+
ri.grasp()
114+
ri.ungrasp()
115+
ri.stop_gripper()
116+
finally:
117+
ri.destroy_node()

0 commit comments

Comments
 (0)