crisp-python 1.2.1__tar.gz

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.
Files changed (33) hide show
  1. crisp_python-1.2.1/PKG-INFO +27 -0
  2. crisp_python-1.2.1/README.md +18 -0
  3. crisp_python-1.2.1/crisp_py/__init__.py +9 -0
  4. crisp_python-1.2.1/crisp_py/camera/__init__.py +6 -0
  5. crisp_python-1.2.1/crisp_py/camera/camera.py +188 -0
  6. crisp_python-1.2.1/crisp_py/camera/camera_config.py +31 -0
  7. crisp_python-1.2.1/crisp_py/control/__init__.py +1 -0
  8. crisp_python-1.2.1/crisp_py/control/controller_switcher.py +174 -0
  9. crisp_python-1.2.1/crisp_py/control/joint_trajectory_controller_client.py +111 -0
  10. crisp_python-1.2.1/crisp_py/control/parameters_client.py +207 -0
  11. crisp_python-1.2.1/crisp_py/gripper/__init__.py +5 -0
  12. crisp_python-1.2.1/crisp_py/gripper/gripper.py +344 -0
  13. crisp_python-1.2.1/crisp_py/robot.py +540 -0
  14. crisp_python-1.2.1/crisp_py/robot_config.py +186 -0
  15. crisp_python-1.2.1/crisp_py/sensors/sensor.py +164 -0
  16. crisp_python-1.2.1/crisp_py/sensors/sensor_config.py +31 -0
  17. crisp_python-1.2.1/crisp_py/utils/__init__.py +5 -0
  18. crisp_python-1.2.1/crisp_py/utils/callback_monitor.py +151 -0
  19. crisp_python-1.2.1/crisp_py/utils/diagnostics_viewer.py +119 -0
  20. crisp_python-1.2.1/crisp_python.egg-info/PKG-INFO +27 -0
  21. crisp_python-1.2.1/crisp_python.egg-info/SOURCES.txt +31 -0
  22. crisp_python-1.2.1/crisp_python.egg-info/dependency_links.txt +1 -0
  23. crisp_python-1.2.1/crisp_python.egg-info/requires.txt +2 -0
  24. crisp_python-1.2.1/crisp_python.egg-info/top_level.txt +4 -0
  25. crisp_python-1.2.1/pyproject.toml +65 -0
  26. crisp_python-1.2.1/setup.cfg +4 -0
  27. crisp_python-1.2.1/tests/unit/__init__.py +1 -0
  28. crisp_python-1.2.1/tests/unit/test_camera.py +638 -0
  29. crisp_python-1.2.1/tests/unit/test_gripper.py +781 -0
  30. crisp_python-1.2.1/tests/unit/test_pose.py +147 -0
  31. crisp_python-1.2.1/tests/unit/test_robot.py +359 -0
  32. crisp_python-1.2.1/tests/unit/test_robot_config.py +278 -0
  33. crisp_python-1.2.1/tests/unit/test_sensor.py +238 -0
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: crisp-python
3
+ Version: 1.2.1
4
+ Summary: Simple python interface to control robots that use crisp_controllers and other ROS2 components.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: scipy
8
+ Requires-Dist: numpy<=2.3
9
+
10
+
11
+ <img src="media/crisp_py_logo.webp" alt="CRISP PY Logo" />
12
+
13
+ To install the package use:
14
+ ```bash
15
+ pip install crisp-python
16
+ ```
17
+
18
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
19
+ ![MIT Badge](https://img.shields.io/badge/MIT-License-blue?style=flat)
20
+ <a href="https://utiasDSL.github.io/crisp_controllers/"><img alt="Static Badge" src="https://img.shields.io/badge/docs-passing-blue?style=flat&link=https%3A%2F%2FutiasDSL.github.io%2Fcrisp_controllers%2F"></a>
21
+ <a href="https://github.com/utiasDSL/crisp_py/actions/workflows/ruff_ci.yml"><img src="https://github.com/utiasDSL/crisp_py/actions/workflows/ruff_ci.yml/badge.svg"/></a>
22
+ <a href="https://github.com/utiasDSL/crisp_py/actions/workflows/pixi_ci.yml"><img src="https://github.com/utiasDSL/crisp_py/actions/workflows/pixi_ci.yml/badge.svg"/></a>
23
+ <a href="https://utiasDSL.github.io/crisp_controllers#citing"><img alt="Static Badge" src="https://img.shields.io/badge/arxiv-cite-b31b1b?style=flat"></a>
24
+
25
+ *CRISP_PY /krɪspi/*, a python package to interface with robots using [CRISP controllers](https://github.com/utiasDSL/crisp_controllers). Check the [project website](https://utiasdsl.github.io/crisp_controllers/) for further information!
26
+
27
+ ![crisp_py](https://github.com/user-attachments/assets/e4cbf5fd-6ba7-4d7c-917a-bbb78d79ab10)
@@ -0,0 +1,18 @@
1
+
2
+ <img src="media/crisp_py_logo.webp" alt="CRISP PY Logo" />
3
+
4
+ To install the package use:
5
+ ```bash
6
+ pip install crisp-python
7
+ ```
8
+
9
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
10
+ ![MIT Badge](https://img.shields.io/badge/MIT-License-blue?style=flat)
11
+ <a href="https://utiasDSL.github.io/crisp_controllers/"><img alt="Static Badge" src="https://img.shields.io/badge/docs-passing-blue?style=flat&link=https%3A%2F%2FutiasDSL.github.io%2Fcrisp_controllers%2F"></a>
12
+ <a href="https://github.com/utiasDSL/crisp_py/actions/workflows/ruff_ci.yml"><img src="https://github.com/utiasDSL/crisp_py/actions/workflows/ruff_ci.yml/badge.svg"/></a>
13
+ <a href="https://github.com/utiasDSL/crisp_py/actions/workflows/pixi_ci.yml"><img src="https://github.com/utiasDSL/crisp_py/actions/workflows/pixi_ci.yml/badge.svg"/></a>
14
+ <a href="https://utiasDSL.github.io/crisp_controllers#citing"><img alt="Static Badge" src="https://img.shields.io/badge/arxiv-cite-b31b1b?style=flat"></a>
15
+
16
+ *CRISP_PY /krɪspi/*, a python package to interface with robots using [CRISP controllers](https://github.com/utiasDSL/crisp_controllers). Check the [project website](https://utiasdsl.github.io/crisp_controllers/) for further information!
17
+
18
+ ![crisp_py](https://github.com/user-attachments/assets/e4cbf5fd-6ba7-4d7c-917a-bbb78d79ab10)
@@ -0,0 +1,9 @@
1
+ """Initialize crisp_py."""
2
+
3
+ try:
4
+ import rclpy # noqa: F401
5
+ except ImportError:
6
+ print("ROS2 should be installed and sourced!")
7
+
8
+
9
+ __version__ = "1.2.0"
@@ -0,0 +1,6 @@
1
+ """Initialize the camera module."""
2
+
3
+ from crisp_py.camera.camera import Camera
4
+ from crisp_py.camera.camera_config import CameraConfig, FrankaCameraConfig
5
+
6
+ __import__ = [Camera, CameraConfig, FrankaCameraConfig]
@@ -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()