crisp-python 1.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crisp_py/__init__.py +9 -0
- crisp_py/camera/__init__.py +6 -0
- crisp_py/camera/camera.py +188 -0
- crisp_py/camera/camera_config.py +31 -0
- crisp_py/control/__init__.py +1 -0
- crisp_py/control/controller_switcher.py +174 -0
- crisp_py/control/joint_trajectory_controller_client.py +111 -0
- crisp_py/control/parameters_client.py +207 -0
- crisp_py/gripper/__init__.py +5 -0
- crisp_py/gripper/gripper.py +344 -0
- crisp_py/robot.py +540 -0
- crisp_py/robot_config.py +186 -0
- crisp_py/sensors/sensor.py +164 -0
- crisp_py/sensors/sensor_config.py +31 -0
- crisp_py/utils/__init__.py +5 -0
- crisp_py/utils/callback_monitor.py +151 -0
- crisp_py/utils/diagnostics_viewer.py +119 -0
- crisp_python-1.2.1.dist-info/METADATA +27 -0
- crisp_python-1.2.1.dist-info/RECORD +28 -0
- crisp_python-1.2.1.dist-info/WHEEL +5 -0
- crisp_python-1.2.1.dist-info/top_level.txt +2 -0
- tests/unit/__init__.py +1 -0
- tests/unit/test_camera.py +638 -0
- tests/unit/test_gripper.py +781 -0
- tests/unit/test_pose.py +147 -0
- tests/unit/test_robot.py +359 -0
- tests/unit/test_robot_config.py +278 -0
- tests/unit/test_sensor.py +238 -0
crisp_py/__init__.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Class defining a camera object."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from typing import Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import cv2
|
|
7
|
+
import numpy as np
|
|
8
|
+
import rclpy
|
|
9
|
+
import rclpy.executors
|
|
10
|
+
from cv_bridge import CvBridge
|
|
11
|
+
from rclpy.callback_groups import ReentrantCallbackGroup
|
|
12
|
+
from rclpy.node import Node
|
|
13
|
+
from rclpy.qos import qos_profile_sensor_data, qos_profile_system_default
|
|
14
|
+
from sensor_msgs.msg import CameraInfo, CompressedImage, Image
|
|
15
|
+
|
|
16
|
+
from crisp_py.camera.camera_config import CameraConfig
|
|
17
|
+
from crisp_py.utils.callback_monitor import CallbackMonitor
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Camera:
|
|
21
|
+
"""High level interface for managing cameras in ROS2.
|
|
22
|
+
|
|
23
|
+
Images are stored in RGB8 format as a numpy array of shape (H, W, 3).
|
|
24
|
+
If resolution is set in the camera configuration, the image will be resized while maintaining the aspect ratio.
|
|
25
|
+
(Note: The image will be cropped to fit the target resolution if necessary.)
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
THREADS_REQUIRED = 2
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
node: Optional[Node] = None,
|
|
33
|
+
namespace: str = "",
|
|
34
|
+
config: Optional[CameraConfig] = None,
|
|
35
|
+
spin_node: bool = True,
|
|
36
|
+
):
|
|
37
|
+
"""Initialize the camera.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
node (Node, optional): ROS2 node to use. If None, creates a new node.
|
|
41
|
+
namespace (str, optional): ROS2 namespace for the camera.
|
|
42
|
+
config (CameraConfig): Camera configuration.
|
|
43
|
+
spin_node (bool, optional): Whether to spin the node in a separate thread.
|
|
44
|
+
"""
|
|
45
|
+
self.config = config if config else CameraConfig()
|
|
46
|
+
|
|
47
|
+
if node is None:
|
|
48
|
+
if not rclpy.ok():
|
|
49
|
+
rclpy.init()
|
|
50
|
+
self.node = rclpy.create_node(self.config.camera_name, namespace=namespace)
|
|
51
|
+
else:
|
|
52
|
+
self.node = node
|
|
53
|
+
|
|
54
|
+
self._current_image: Optional[np.ndarray] = None
|
|
55
|
+
self._namespace = namespace
|
|
56
|
+
self._callback_monitor = CallbackMonitor(
|
|
57
|
+
self.node,
|
|
58
|
+
stale_threshold=self.config.max_image_delay,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
self.cv_bridge = CvBridge()
|
|
62
|
+
|
|
63
|
+
# self.node.create_subscription(
|
|
64
|
+
# Image,
|
|
65
|
+
# self.config.camera_color_image_topic,
|
|
66
|
+
# self._callback_current_color_image,
|
|
67
|
+
# qos_profile_system_default,
|
|
68
|
+
# callback_group=ReentrantCallbackGroup(),
|
|
69
|
+
# )
|
|
70
|
+
self.node.create_subscription(
|
|
71
|
+
CompressedImage,
|
|
72
|
+
f"{self.config.camera_color_image_topic}/compressed",
|
|
73
|
+
self._callback_monitor.monitor(
|
|
74
|
+
f"{self._namespace.capitalize()} Camera {self.config.camera_name} Image".strip(),
|
|
75
|
+
self._callback_current_color_image,
|
|
76
|
+
),
|
|
77
|
+
qos_profile_sensor_data,
|
|
78
|
+
callback_group=ReentrantCallbackGroup(),
|
|
79
|
+
)
|
|
80
|
+
self.node.create_subscription(
|
|
81
|
+
CameraInfo,
|
|
82
|
+
self.config.camera_color_info_topic,
|
|
83
|
+
self._callback_monitor.monitor(
|
|
84
|
+
f"{self._namespace.capitalize()} Camera {self.config.camera_name} Info".strip(),
|
|
85
|
+
self._callback_current_color_info,
|
|
86
|
+
),
|
|
87
|
+
qos_profile_system_default,
|
|
88
|
+
callback_group=ReentrantCallbackGroup(),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if spin_node:
|
|
92
|
+
threading.Thread(target=self._spin_node, daemon=True).start()
|
|
93
|
+
|
|
94
|
+
def _spin_node(self):
|
|
95
|
+
if not rclpy.ok():
|
|
96
|
+
rclpy.init()
|
|
97
|
+
executor = rclpy.executors.MultiThreadedExecutor(num_threads=self.THREADS_REQUIRED)
|
|
98
|
+
executor.add_node(self.node)
|
|
99
|
+
while rclpy.ok():
|
|
100
|
+
executor.spin_once(timeout_sec=0.1)
|
|
101
|
+
|
|
102
|
+
def _uncompress(self, compressed_image: CompressedImage) -> Image:
|
|
103
|
+
"""Uncompress a CompressedImage message to an Image message."""
|
|
104
|
+
return np.asarray(
|
|
105
|
+
self.cv_bridge.compressed_imgmsg_to_cv2(compressed_image, desired_encoding="rgb8")
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def current_image(self) -> np.ndarray:
|
|
110
|
+
"""Get the current color image."""
|
|
111
|
+
if self._current_image is None:
|
|
112
|
+
raise RuntimeError(
|
|
113
|
+
f"We have not received any images of camera {self.config.camera_name}. Call wait_until_ready to be sure that the camera is available!."
|
|
114
|
+
)
|
|
115
|
+
# Check if image callback is stale
|
|
116
|
+
try:
|
|
117
|
+
# Try to find the callback - need to handle namespace properly
|
|
118
|
+
for callback_name in self._callback_monitor.callbacks.keys():
|
|
119
|
+
if (
|
|
120
|
+
"Camera" in callback_name
|
|
121
|
+
and self.config.camera_name in callback_name
|
|
122
|
+
and "Image" in callback_name
|
|
123
|
+
):
|
|
124
|
+
image_callback_data = self._callback_monitor.get_callback_data(callback_name)
|
|
125
|
+
if image_callback_data and image_callback_data.is_stale:
|
|
126
|
+
self.node.get_logger().warn(
|
|
127
|
+
f"Camera {self.config.camera_name} image data is stale"
|
|
128
|
+
)
|
|
129
|
+
break
|
|
130
|
+
except ValueError:
|
|
131
|
+
# Callback not found, which is expected if no data has been received yet
|
|
132
|
+
pass
|
|
133
|
+
return self._current_image
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def resolution(self) -> Tuple[int, int]:
|
|
137
|
+
"""Get the current camera info."""
|
|
138
|
+
return self.config.resolution if self.config.resolution is not None else (0, 0)
|
|
139
|
+
|
|
140
|
+
def is_ready(self) -> bool:
|
|
141
|
+
"""Returns True if camera image and resolution are available."""
|
|
142
|
+
return self._current_image is not None and self.config.resolution is not None
|
|
143
|
+
|
|
144
|
+
def wait_until_ready(self, timeout: float = 10.0, check_frequency: float = 10.0):
|
|
145
|
+
"""Wait until camera image and resolution are available."""
|
|
146
|
+
rate = self.node.create_rate(check_frequency)
|
|
147
|
+
while not self.is_ready():
|
|
148
|
+
rate.sleep()
|
|
149
|
+
timeout -= 1.0 / check_frequency
|
|
150
|
+
if timeout <= 0:
|
|
151
|
+
raise TimeoutError(
|
|
152
|
+
f"Timeout waiting for camera ({self.config.camera_name}) to become ready."
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def _callback_current_color_image(self, msg: Image):
|
|
156
|
+
"""Receive and store the current image."""
|
|
157
|
+
# raw_image = self._image_to_array(msg)
|
|
158
|
+
# if self.config.resolution is not None:
|
|
159
|
+
# raw_image = self._resize_with_aspect_ratio(raw_image, self.config.resolution)
|
|
160
|
+
self._current_image = self._resize_with_aspect_ratio(
|
|
161
|
+
self._uncompress(msg), target_res=self.config.resolution
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _callback_current_color_info(self, msg: CameraInfo):
|
|
165
|
+
"""Receive and store the current camera info."""
|
|
166
|
+
if self.config.resolution is None:
|
|
167
|
+
self.config.resolution = (msg.height, msg.width)
|
|
168
|
+
|
|
169
|
+
def _image_to_array(self, msg: Image) -> np.ndarray:
|
|
170
|
+
"""Converts an Image message to a numpy array."""
|
|
171
|
+
return np.asarray(self.cv_bridge.imgmsg_to_cv2(msg, desired_encoding="rgb8"))
|
|
172
|
+
|
|
173
|
+
def _resize_with_aspect_ratio(self, image: np.ndarray, target_res: tuple) -> np.ndarray:
|
|
174
|
+
"""Resize an image to fit within a target resolution while maintaining aspect ratio, cropping if necessary."""
|
|
175
|
+
h, w = image.shape[:2]
|
|
176
|
+
target_h, target_w = target_res
|
|
177
|
+
|
|
178
|
+
scale = max(target_w / w, target_h / h)
|
|
179
|
+
new_w, new_h = int(w * scale), int(h * scale)
|
|
180
|
+
|
|
181
|
+
resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
|
182
|
+
|
|
183
|
+
start_x = (new_w - target_w) // 2
|
|
184
|
+
start_y = (new_h - target_h) // 2
|
|
185
|
+
|
|
186
|
+
cropped_image = resized[start_y : start_y + target_h, start_x : start_x + target_w]
|
|
187
|
+
|
|
188
|
+
return cropped_image
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Camera configuration class."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class CameraConfig:
|
|
8
|
+
"""Default camera configuration."""
|
|
9
|
+
|
|
10
|
+
camera_name: str = "camera"
|
|
11
|
+
camera_frame: str = "camera_link"
|
|
12
|
+
|
|
13
|
+
resolution: tuple[int, int] | None = None
|
|
14
|
+
|
|
15
|
+
camera_color_image_topic: str | None = None
|
|
16
|
+
camera_color_info_topic: str | None = None
|
|
17
|
+
|
|
18
|
+
max_image_delay: float = 1.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class FrankaCameraConfig(CameraConfig):
|
|
23
|
+
"""Example camera configuration used with Franka."""
|
|
24
|
+
|
|
25
|
+
camera_name: str = "franka"
|
|
26
|
+
camera_frame: str = "franka_link"
|
|
27
|
+
|
|
28
|
+
resolution: tuple[int, int] | None = (256, 256)
|
|
29
|
+
|
|
30
|
+
camera_color_image_topic: str | None = "franka/color/image_raw"
|
|
31
|
+
camera_color_info_topic: str | None = "franka/color/camera_info"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Initialize the control module."""
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Script to switch to a different ros2_controller."""
|
|
2
|
+
|
|
3
|
+
import rclpy
|
|
4
|
+
from controller_manager_msgs.srv import (
|
|
5
|
+
ConfigureController,
|
|
6
|
+
ListControllers,
|
|
7
|
+
LoadController,
|
|
8
|
+
SwitchController,
|
|
9
|
+
)
|
|
10
|
+
from rclpy.callback_groups import ReentrantCallbackGroup
|
|
11
|
+
from rclpy.node import Node
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ControllerSwitcherClient:
|
|
15
|
+
"""ControllerSwitcher class allows user to communicate with the controller_manager and manage controllers in an easy way."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self, node: Node, always_active: list[str] = ["joint_state_broadcaster", "pose_broadcaster"]
|
|
19
|
+
):
|
|
20
|
+
"""Initialize the ControllerSwitcher.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
node (Node): Node used for the communication with the controller_manager.
|
|
24
|
+
always_active (list[str], optional): List of controllers that are always active. Defaults to ["joint_state_broadcaster", "franka_robot_state_broadcaster"].
|
|
25
|
+
"""
|
|
26
|
+
self.node = node
|
|
27
|
+
|
|
28
|
+
self.always_active = always_active
|
|
29
|
+
|
|
30
|
+
self.load_client = node.create_client(
|
|
31
|
+
LoadController,
|
|
32
|
+
"controller_manager/load_controller",
|
|
33
|
+
callback_group=ReentrantCallbackGroup(),
|
|
34
|
+
)
|
|
35
|
+
self.configure_client = node.create_client(
|
|
36
|
+
ConfigureController,
|
|
37
|
+
"controller_manager/configure_controller",
|
|
38
|
+
callback_group=ReentrantCallbackGroup(),
|
|
39
|
+
)
|
|
40
|
+
self.list_client = node.create_client(
|
|
41
|
+
ListControllers,
|
|
42
|
+
"controller_manager/list_controllers",
|
|
43
|
+
callback_group=ReentrantCallbackGroup(),
|
|
44
|
+
)
|
|
45
|
+
self.switch_client = node.create_client(
|
|
46
|
+
SwitchController,
|
|
47
|
+
"controller_manager/switch_controller",
|
|
48
|
+
callback_group=ReentrantCallbackGroup(),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def is_server_ready(self) -> bool:
|
|
52
|
+
"""Return True if all services are ready."""
|
|
53
|
+
return (
|
|
54
|
+
self.load_client.wait_for_service(timeout_sec=5.0)
|
|
55
|
+
and self.configure_client.wait_for_service(timeout_sec=5.0)
|
|
56
|
+
and self.list_client.wait_for_service(timeout_sec=5.0)
|
|
57
|
+
and self.switch_client.wait_for_service(timeout_sec=5.0)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def get_controller_list(self) -> list:
|
|
61
|
+
"""Get a list of all controllers using a service."""
|
|
62
|
+
if not self.list_client.wait_for_service(timeout_sec=5.0):
|
|
63
|
+
raise RuntimeError("Timed out waiting for controller list service.")
|
|
64
|
+
else:
|
|
65
|
+
self.node.get_logger().debug("Got controller list service.")
|
|
66
|
+
|
|
67
|
+
future = self.list_client.call_async(ListControllers.Request())
|
|
68
|
+
|
|
69
|
+
while not future.done():
|
|
70
|
+
self.node.get_logger().debug(
|
|
71
|
+
"Waiting for controller list...", throttle_duration_sec=1.0
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
response = future.result()
|
|
75
|
+
|
|
76
|
+
return response.controller
|
|
77
|
+
|
|
78
|
+
def load_controller(self, controller_name: str) -> bool:
|
|
79
|
+
"""Load a controller using a service."""
|
|
80
|
+
request = LoadController.Request()
|
|
81
|
+
request.name = controller_name
|
|
82
|
+
future = self.load_client.call_async(request)
|
|
83
|
+
|
|
84
|
+
while not future.done():
|
|
85
|
+
self.node.get_logger().debug(
|
|
86
|
+
"Waiting for load controller answer...", throttle_duration_sec=1.0
|
|
87
|
+
)
|
|
88
|
+
response = future.result()
|
|
89
|
+
|
|
90
|
+
return response.ok
|
|
91
|
+
|
|
92
|
+
def configure_controller(self, controller_name: str) -> bool:
|
|
93
|
+
"""Configure a controller using a service."""
|
|
94
|
+
request = ConfigureController.Request()
|
|
95
|
+
request.name = controller_name
|
|
96
|
+
future = self.configure_client.call_async(request)
|
|
97
|
+
|
|
98
|
+
while not future.done():
|
|
99
|
+
self.node.get_logger().debug(
|
|
100
|
+
"Waiting for configure controller answer...", throttle_duration_sec=1.0
|
|
101
|
+
)
|
|
102
|
+
response = future.result()
|
|
103
|
+
|
|
104
|
+
return response.ok
|
|
105
|
+
|
|
106
|
+
def _switch_controller(self, to_deactivate: list[str], to_activate: list[str]) -> bool:
|
|
107
|
+
"""Switch to a different ros2_controller using a service."""
|
|
108
|
+
request = SwitchController.Request()
|
|
109
|
+
request.deactivate_controllers = to_deactivate
|
|
110
|
+
request.activate_controllers = to_activate
|
|
111
|
+
request.strictness = SwitchController.Request.BEST_EFFORT
|
|
112
|
+
request.activate_asap = True
|
|
113
|
+
request.timeout = rclpy.duration.Duration(seconds=5).to_msg()
|
|
114
|
+
|
|
115
|
+
future = self.switch_client.call_async(request)
|
|
116
|
+
|
|
117
|
+
while not future.done():
|
|
118
|
+
self.node.get_logger().debug(
|
|
119
|
+
"Waiting for switch controller answer...", throttle_duration_sec=1.0
|
|
120
|
+
)
|
|
121
|
+
response = future.result()
|
|
122
|
+
|
|
123
|
+
return response.ok
|
|
124
|
+
|
|
125
|
+
def switch_controller(self, controller_name: str) -> bool | None:
|
|
126
|
+
"""Switch to a different ros2_controller that is already loaded using a service.
|
|
127
|
+
|
|
128
|
+
First we request a list of current controllers.
|
|
129
|
+
If the desired controller is not loaded, then we request to load it and configure it.
|
|
130
|
+
Finally, we request to switch to the desired controller.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
controller_name (str): Name of the controller to switch to.
|
|
134
|
+
"""
|
|
135
|
+
controllers = self.get_controller_list()
|
|
136
|
+
|
|
137
|
+
active_controllers = [
|
|
138
|
+
controller.name for controller in controllers if controller.state == "active"
|
|
139
|
+
]
|
|
140
|
+
inactive_controllers = [
|
|
141
|
+
controller.name for controller in controllers if controller.state == "inactive"
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
if controller_name in active_controllers:
|
|
145
|
+
self.node.get_logger().debug(f"Controller {controller_name} is already active.")
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
if controller_name not in inactive_controllers:
|
|
149
|
+
ok = self.load_controller(controller_name)
|
|
150
|
+
if not ok:
|
|
151
|
+
self.node.get_logger().error(
|
|
152
|
+
f"Failed to load controller {controller_name}. Are you sure the controller exists?"
|
|
153
|
+
)
|
|
154
|
+
raise RuntimeError(f"Failed to load controller {controller_name}.")
|
|
155
|
+
|
|
156
|
+
ok = self.configure_controller(controller_name)
|
|
157
|
+
if not ok:
|
|
158
|
+
self.node.get_logger().error(f"Failed to configure controller {controller_name}.")
|
|
159
|
+
raise RuntimeError(f"Failed to configure controller {controller_name}.")
|
|
160
|
+
|
|
161
|
+
to_deactivate = []
|
|
162
|
+
for active_controller in active_controllers:
|
|
163
|
+
if active_controller not in self.always_active:
|
|
164
|
+
to_deactivate.append(active_controller)
|
|
165
|
+
|
|
166
|
+
to_activate = [controller_name]
|
|
167
|
+
|
|
168
|
+
ok = self._switch_controller(to_deactivate, to_activate)
|
|
169
|
+
|
|
170
|
+
if not ok:
|
|
171
|
+
self.node.get_logger().error(f"Failed to switch to controller {controller_name}.")
|
|
172
|
+
raise RuntimeError(f"Failed to switch to controller {controller_name}.")
|
|
173
|
+
|
|
174
|
+
return True
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""A ROS2 Action Client for controlling robot joints through trajectory messages.
|
|
2
|
+
|
|
3
|
+
This module provides a JointTrajectoryControllerClient class that simplifies sending joint
|
|
4
|
+
trajectories to a ROS2-controlled robot. It handles the communication with the robot's
|
|
5
|
+
joint trajectory controller through ROS2 actions.
|
|
6
|
+
|
|
7
|
+
The client supports:
|
|
8
|
+
- Sending joint configurations with specified execution time
|
|
9
|
+
- Blocking and non-blocking execution modes
|
|
10
|
+
- Automatic namespace handling for multi-robot setups
|
|
11
|
+
|
|
12
|
+
Example:
|
|
13
|
+
```python
|
|
14
|
+
import rclpy
|
|
15
|
+
from rclpy.node import Node
|
|
16
|
+
from joint_trajectory_controller_client import JointTrajectoryControllerClient
|
|
17
|
+
|
|
18
|
+
node = Node("my_control_node")
|
|
19
|
+
client = JointTrajectoryControllerClient(node)
|
|
20
|
+
|
|
21
|
+
# Send a joint configuration
|
|
22
|
+
joint_names = ["joint1", "joint2", "joint3"]
|
|
23
|
+
joint_positions = [0.5, -0.3, 1.0]
|
|
24
|
+
client.send_joint_config(joint_names, joint_positions, time_to_goal=3.0)
|
|
25
|
+
```
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
from control_msgs.action import FollowJointTrajectory
|
|
31
|
+
from rclpy.action import ActionClient
|
|
32
|
+
from rclpy.duration import Duration
|
|
33
|
+
from rclpy.node import Node
|
|
34
|
+
from trajectory_msgs.msg import JointTrajectoryPoint
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class JointTrajectoryControllerClient(ActionClient):
|
|
38
|
+
"""A ROS2 Action Client for controlling robot joints through trajectory messages.
|
|
39
|
+
|
|
40
|
+
This class provides a high-level interface for sending joint trajectories to a ROS2-controlled
|
|
41
|
+
robot. It inherits from ActionClient and handles the communication with the robot's joint
|
|
42
|
+
trajectory controller through ROS2 actions.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
node (Node): The ROS2 node that will own this client.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, node: Node) -> None:
|
|
49
|
+
"""Initialize the JointTrajectoryControllerClient.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
node (Node): The ROS2 node that will own this client.
|
|
53
|
+
"""
|
|
54
|
+
self.node = node
|
|
55
|
+
super().__init__(
|
|
56
|
+
node, FollowJointTrajectory, "joint_trajectory_controller/follow_joint_trajectory"
|
|
57
|
+
)
|
|
58
|
+
self._goal = FollowJointTrajectory.Goal()
|
|
59
|
+
namespace = self.node.get_namespace().strip("/")
|
|
60
|
+
self._prefix = f"{namespace}_" if namespace else ""
|
|
61
|
+
|
|
62
|
+
def send_joint_config(
|
|
63
|
+
self,
|
|
64
|
+
joint_names: list[str],
|
|
65
|
+
joint_config: list[float],
|
|
66
|
+
time_to_goal: float = 5.0,
|
|
67
|
+
blocking: bool = True,
|
|
68
|
+
) -> Optional[FollowJointTrajectory.Result]:
|
|
69
|
+
"""Send a joint configuration to the robot.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
joint_names (list[str]): List of joint names to control.
|
|
73
|
+
joint_config (list[float]): List of joint positions corresponding to joint_names.
|
|
74
|
+
time_to_goal (float, optional): Time in seconds to reach the goal position. Defaults to 5.0.
|
|
75
|
+
blocking (bool, optional): Whether to wait for the movement to complete. Defaults to True.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
Optional[FollowJointTrajectory.Result]: If blocking is True, returns the result of the
|
|
79
|
+
trajectory execution. If blocking is False, returns None.
|
|
80
|
+
"""
|
|
81
|
+
self._goal.trajectory.joint_names = [
|
|
82
|
+
self._prefix + joint_name for joint_name in joint_names
|
|
83
|
+
]
|
|
84
|
+
self._goal.trajectory.header.stamp = self.node.get_clock().now().to_msg()
|
|
85
|
+
self._goal.trajectory.points = []
|
|
86
|
+
self._goal.trajectory.points.append(
|
|
87
|
+
JointTrajectoryPoint(
|
|
88
|
+
positions=joint_config,
|
|
89
|
+
velocities=len(joint_config) * [0.0],
|
|
90
|
+
accelerations=len(joint_config) * [0.0],
|
|
91
|
+
time_from_start=Duration(seconds=int(time_to_goal), nanoseconds=0).to_msg(),
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
future = self.send_goal_async(self._goal)
|
|
95
|
+
|
|
96
|
+
if blocking:
|
|
97
|
+
while not future.done():
|
|
98
|
+
self.node.get_logger().debug(
|
|
99
|
+
"Waiting for goal answer...", throttle_duration_sec=1.0
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
goal_handle = future.result()
|
|
103
|
+
|
|
104
|
+
future = goal_handle.get_result_async()
|
|
105
|
+
while not future.done():
|
|
106
|
+
self.node.get_logger().debug(
|
|
107
|
+
"Waiting for goal result...", throttle_duration_sec=1.0
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self.node.get_logger().debug(f"Goal result: {future.result()}")
|
|
111
|
+
return future.result()
|