From 1b4abbc925b4f338257bbf525f6a7f57b24e51b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 08:42:52 +0000 Subject: [PATCH 01/16] Initial plan From bff41ef32305917023fe0ba2cb388a893309a84f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 08:51:48 +0000 Subject: [PATCH 02/16] Add ROS2 action client tests and fix KeyError for missing status field Co-authored-by: gonzalocasas <933277+gonzalocasas@users.noreply.github.com> --- src/roslibpy/comm/comm.py | 15 +++- tests/ros2/test_action_client.py | 146 +++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 tests/ros2/test_action_client.py diff --git a/src/roslibpy/comm/comm.py b/src/roslibpy/comm/comm.py index 8a33453..c24c573 100644 --- a/src/roslibpy/comm/comm.py +++ b/src/roslibpy/comm/comm.py @@ -163,9 +163,18 @@ def _handle_action_result(self, message): resultback, _ , errback = action_handlers del self._pending_action_requests[request_id] - LOGGER.debug("Received Action result with status: %s", message["status"]) - - results = {"status": ActionGoalStatus(message["status"]).name, "values": message["values"]} + # Handle different message formats for status field + # ROS2 rosbridge may send status at top level or inside values + status = message.get("status") + if status is None and "values" in message: + status = message["values"].get("status") + if status is None: + # Default to UNKNOWN if status is not found + status = ActionGoalStatus.UNKNOWN.value + + LOGGER.debug("Received Action result with status: %s", status) + + results = {"status": ActionGoalStatus(status).name, "values": message.get("values", {})} if "result" in message and message["result"] is False: if errback: diff --git a/tests/ros2/test_action_client.py b/tests/ros2/test_action_client.py new file mode 100644 index 0000000..3fe1383 --- /dev/null +++ b/tests/ros2/test_action_client.py @@ -0,0 +1,146 @@ +"""Tests for ROS2 ActionClient message handling.""" +from unittest.mock import Mock + +from roslibpy.comm.comm import RosBridgeProtocol +from roslibpy.core import ActionResult, ActionGoalStatus + + +def test_action_result_with_status_at_top_level(): + """Test handling of action results with status at the top level of the message.""" + protocol = RosBridgeProtocol() + protocol.factory = Mock() + protocol.send_message = Mock() + + result_callback = Mock() + feedback_callback = Mock() + error_callback = Mock() + + request_id = "send_action_goal:/test_action:1" + protocol._pending_action_requests[request_id] = (result_callback, feedback_callback, error_callback) + + # ROS2 rosbridge message format with status at top level + message = { + "op": "action_result", + "action": "/test_action", + "id": request_id, + "status": 4, # SUCCEEDED + "values": { + "result": {"success": True, "message": "Action completed"} + }, + "result": True + } + + protocol._handle_action_result(message) + + assert result_callback.called + result = result_callback.call_args[0][0] + assert isinstance(result, ActionResult) + assert result["status"] == ActionGoalStatus.SUCCEEDED.name + assert result["values"] == message["values"] + + +def test_action_result_with_status_in_values(): + """Test handling of action results with status inside the values field. + + This reproduces the KeyError issue reported in GitHub issue. + """ + protocol = RosBridgeProtocol() + protocol.factory = Mock() + protocol.send_message = Mock() + + result_callback = Mock() + feedback_callback = Mock() + error_callback = Mock() + + request_id = "send_action_goal:/test_action:2" + protocol._pending_action_requests[request_id] = (result_callback, feedback_callback, error_callback) + + # Alternative message format with status inside values + message = { + "op": "action_result", + "action": "/test_action", + "id": request_id, + "values": { + "status": 4, # SUCCEEDED - status is here instead + "result": {"success": True, "message": "Action completed"} + }, + "result": True + } + + # This should not raise KeyError + protocol._handle_action_result(message) + + assert result_callback.called + result = result_callback.call_args[0][0] + assert isinstance(result, ActionResult) + assert result["status"] == ActionGoalStatus.SUCCEEDED.name + + +def test_action_result_failure_with_status_at_top_level(): + """Test handling of failed action results.""" + protocol = RosBridgeProtocol() + protocol.factory = Mock() + protocol.send_message = Mock() + + result_callback = Mock() + feedback_callback = Mock() + error_callback = Mock() + + request_id = "send_action_goal:/test_action:3" + protocol._pending_action_requests[request_id] = (result_callback, feedback_callback, error_callback) + + message = { + "op": "action_result", + "action": "/test_action", + "id": request_id, + "status": 6, # ABORTED + "values": { + "result": {"success": False, "message": "Action failed"} + }, + "result": False + } + + protocol._handle_action_result(message) + + assert error_callback.called + result = error_callback.call_args[0][0] + assert result["status"] == ActionGoalStatus.ABORTED.name + assert result["values"] == message["values"] + + +def test_action_result_without_status_field(): + """Test handling of action results when status field is missing entirely. + + This is a defensive test for cases where neither top-level nor values contain status. + In such cases, we should use a default status. + """ + protocol = RosBridgeProtocol() + protocol.factory = Mock() + protocol.send_message = Mock() + + result_callback = Mock() + feedback_callback = Mock() + error_callback = Mock() + + request_id = "send_action_goal:/test_action:4" + protocol._pending_action_requests[request_id] = (result_callback, feedback_callback, error_callback) + + # Message without status anywhere + message = { + "op": "action_result", + "action": "/test_action", + "id": request_id, + "values": { + "result": {"success": True} + }, + "result": True + } + + # Should not raise KeyError, should use default status + protocol._handle_action_result(message) + + assert result_callback.called + result = result_callback.call_args[0][0] + assert isinstance(result, ActionResult) + # Should have some status value (likely UNKNOWN) + assert "status" in result From 8de6ea7ce79833a9375e6281639a21150dccd8bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 08:54:27 +0000 Subject: [PATCH 03/16] Update CHANGELOG with fix for ROS2 ActionClient KeyError Co-authored-by: gonzalocasas <933277+gonzalocasas@users.noreply.github.com> --- CHANGELOG.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 62913fe..16eafd5 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -16,6 +16,7 @@ Unreleased **Changed** **Fixed** +* Fixed KeyError in ROS2 ActionClient when receiving action results with different message formats from rosbridge. **Deprecated** From 631d5676492ab89d55fee9c62b006a47676f1569 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 30 Sep 2025 11:19:59 +0000 Subject: [PATCH 04/16] Fix linter errors (black and isort formatting) Co-authored-by: gonzalocasas <933277+gonzalocasas@users.noreply.github.com> --- src/roslibpy/comm/comm.py | 6 +++--- tests/ros2/test_action_client.py | 25 ++++++++++--------------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/src/roslibpy/comm/comm.py b/src/roslibpy/comm/comm.py index c24c573..0ba45a7 100644 --- a/src/roslibpy/comm/comm.py +++ b/src/roslibpy/comm/comm.py @@ -138,12 +138,12 @@ def send_ros_action_goal(self, message, resultback, feedback, errback): def _handle_action_request(self, message): if "action" not in message: raise ValueError("Expected action name missing in action request") - raise RosBridgeException('Action server capabilities not yet implemented') + raise RosBridgeException("Action server capabilities not yet implemented") def _handle_action_cancel(self, message): if "action" not in message: raise ValueError("Expected action name missing in action request") - raise RosBridgeException('Action server capabilities not yet implemented') + raise RosBridgeException("Action server capabilities not yet implemented") def _handle_action_feedback(self, message): if "action" not in message: @@ -160,7 +160,7 @@ def _handle_action_result(self, message): if not action_handlers: raise RosBridgeException('No handler registered for action request ID: "%s"' % request_id) - resultback, _ , errback = action_handlers + resultback, _, errback = action_handlers del self._pending_action_requests[request_id] # Handle different message formats for status field diff --git a/tests/ros2/test_action_client.py b/tests/ros2/test_action_client.py index 3fe1383..bb35f17 100644 --- a/tests/ros2/test_action_client.py +++ b/tests/ros2/test_action_client.py @@ -1,8 +1,9 @@ """Tests for ROS2 ActionClient message handling.""" + from unittest.mock import Mock from roslibpy.comm.comm import RosBridgeProtocol -from roslibpy.core import ActionResult, ActionGoalStatus +from roslibpy.core import ActionGoalStatus, ActionResult def test_action_result_with_status_at_top_level(): @@ -24,10 +25,8 @@ def test_action_result_with_status_at_top_level(): "action": "/test_action", "id": request_id, "status": 4, # SUCCEEDED - "values": { - "result": {"success": True, "message": "Action completed"} - }, - "result": True + "values": {"result": {"success": True, "message": "Action completed"}}, + "result": True, } protocol._handle_action_result(message) @@ -62,9 +61,9 @@ def test_action_result_with_status_in_values(): "id": request_id, "values": { "status": 4, # SUCCEEDED - status is here instead - "result": {"success": True, "message": "Action completed"} + "result": {"success": True, "message": "Action completed"}, }, - "result": True + "result": True, } # This should not raise KeyError @@ -94,10 +93,8 @@ def test_action_result_failure_with_status_at_top_level(): "action": "/test_action", "id": request_id, "status": 6, # ABORTED - "values": { - "result": {"success": False, "message": "Action failed"} - }, - "result": False + "values": {"result": {"success": False, "message": "Action failed"}}, + "result": False, } protocol._handle_action_result(message) @@ -130,10 +127,8 @@ def test_action_result_without_status_field(): "op": "action_result", "action": "/test_action", "id": request_id, - "values": { - "result": {"success": True} - }, - "result": True + "values": {"result": {"success": True}}, + "result": True, } # Should not raise KeyError, should use default status From a2c7682ae4ea6f0eaf6d0b369fa8da47bc30a8fb Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Thu, 2 Oct 2025 00:43:39 +0200 Subject: [PATCH 05/16] Use python launch files for ROS2 and add a Fibonacci test action server to the image --- .github/workflows/test-ros2.yml | 2 +- docker/ros2/Dockerfile | 8 +- docker/ros2/fibonacci_server.py | 202 ++++++++++++++++++++++++ docker/ros2/integration-tests-launch.py | 22 +++ docker/ros2/integration-tests.launch | 4 - 5 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 docker/ros2/fibonacci_server.py create mode 100644 docker/ros2/integration-tests-launch.py delete mode 100644 docker/ros2/integration-tests.launch diff --git a/.github/workflows/test-ros2.yml b/.github/workflows/test-ros2.yml index 05715e7..377d5a2 100644 --- a/.github/workflows/test-ros2.yml +++ b/.github/workflows/test-ros2.yml @@ -50,7 +50,7 @@ jobs: - name: Set up docker containers run: | docker build -t gramaziokohler/rosbridge:integration_tests_ros2 ./docker/ros2 - docker run -d -p 9090:9090 --name rosbridge gramaziokohler/rosbridge:integration_tests_ros2 /bin/bash -c "ros2 launch /integration-tests.launch" + docker run -d -p 9090:9090 --name rosbridge gramaziokohler/rosbridge:integration_tests_ros2 /bin/bash -c "ros2 launch /integration-tests-launch.py" docker ps -a - name: Run linter run: | diff --git a/docker/ros2/Dockerfile b/docker/ros2/Dockerfile index 92c8acd..f147109 100644 --- a/docker/ros2/Dockerfile +++ b/docker/ros2/Dockerfile @@ -8,13 +8,15 @@ RUN apt-get update && apt-get install -y \ ros-${ROS_DISTRO}-rosbridge-suite \ # ros-${ROS_DISTRO}-tf2-web-republisher \ # ros-${ROS_DISTRO}-ros-tutorials \ - # ros-${ROS_DISTRO}-actionlib-tutorials \ + ros-${ROS_DISTRO}-demo-nodes-py \ + ros-${ROS_DISTRO}-example-interfaces \ --no-install-recommends \ # Clear apt-cache to reduce image size && rm -rf /var/lib/apt/lists/* -# Copy launch -COPY ./integration-tests.launch / +# Copy launch and example action server +COPY ./integration-tests-launch.py / +COPY ./fibonacci_server.py / EXPOSE 9090 diff --git a/docker/ros2/fibonacci_server.py b/docker/ros2/fibonacci_server.py new file mode 100644 index 0000000..8dbb21e --- /dev/null +++ b/docker/ros2/fibonacci_server.py @@ -0,0 +1,202 @@ +# Copyright 2019 Open Source Robotics Foundation, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import threading +import time + +from example_interfaces.action import Fibonacci + +import rclpy +from rclpy.action import ActionServer, CancelResponse, GoalResponse +from rclpy.callback_groups import ReentrantCallbackGroup +from rclpy.executors import ExternalShutdownException +from rclpy.executors import MultiThreadedExecutor +from rclpy.node import Node + + +class MinimalActionServer(Node): + """Minimal action server that processes one goal at a time.""" + + def __init__(self): + super().__init__('minimal_action_server') + self._goal_handle = None + self._goal_lock = threading.Lock() + self._action_server = ActionServer( + self, + Fibonacci, + 'fibonacci', + execute_callback=self.execute_callback, + goal_callback=self.goal_callback, + handle_accepted_callback=self.handle_accepted_callback, + cancel_callback=self.cancel_callback, + callback_group=ReentrantCallbackGroup()) + self.get_logger().info('Starting fibonacci action server..') + + def destroy(self): + self._action_server.destroy() + super().destroy_node() + + def goal_callback(self, goal_request): + """Accept or reject a client request to begin an action.""" + self.get_logger().info('Received goal request') + return GoalResponse.ACCEPT + + def handle_accepted_callback(self, goal_handle): + with self._goal_lock: + # This server only allows one goal at a time + if self._goal_handle is not None and self._goal_handle.is_active: + self.get_logger().info('Aborting previous goal') + # Abort the existing goal + self._goal_handle.abort() + self._goal_handle = goal_handle + + goal_handle.execute() + + def cancel_callback(self, goal): + """Accept or reject a client request to cancel an action.""" + self.get_logger().info('Received cancel request') + return CancelResponse.ACCEPT + + def execute_callback(self, goal_handle): + """Execute the goal.""" + self.get_logger().info('Executing goal...') + + # Append the seeds for the Fibonacci sequence + feedback_msg = Fibonacci.Feedback() + feedback_msg.sequence = [0, 1] + + # Start executing the action + for i in range(1, goal_handle.request.order): + # If goal is flagged as no longer active (ie. another goal was accepted), + # then stop executing + if not goal_handle.is_active: + self.get_logger().info('Goal aborted') + return Fibonacci.Result() + + if goal_handle.is_cancel_requested: + goal_handle.canceled() + self.get_logger().info('Goal canceled') + return Fibonacci.Result() + + # Update Fibonacci sequence + feedback_msg.sequence.append(feedback_msg.sequence[i] + feedback_msg.sequence[i-1]) + + self.get_logger().info('Publishing feedback: {0}'.format(feedback_msg.sequence)) + + # Publish the feedback + goal_handle.publish_feedback(feedback_msg) + + # Sleep for demonstration purposes + time.sleep(1) + + with self._goal_lock: + if not goal_handle.is_active: + self.get_logger().info('Goal aborted') + return Fibonacci.Result() + + goal_handle.succeed() + + # Populate result message + result = Fibonacci.Result() + result.sequence = feedback_msg.sequence + + self.get_logger().info('Returning result: {0}'.format(result.sequence)) + + return result + + +def main(args=None): + try: + rclpy.init(args=args) + action_server = MinimalActionServer() + + # We use a MultiThreadedExecutor to handle incoming goal requests concurrently + executor = MultiThreadedExecutor() + rclpy.spin(action_server, executor=executor) + except (KeyboardInterrupt, ExternalShutdownException): + pass + + +if __name__ == '__main__': + main() + + +# import time + +# from example_interfaces.action import Fibonacci +# from rcl_interfaces.msg import SetParametersResult + +# import rclpy +# from rclpy.action import ActionServer, CancelResponse +# from rclpy.executors import ExternalShutdownException +# from rclpy.executors import MultiThreadedExecutor +# from rclpy.node import Node +# from rclpy.parameter import Parameter +# from rclpy.qos import qos_profile_system_default +# from rclpy.service_introspection import ServiceIntrospectionState + + +# class FibonacciActionServer(Node): + +# def __init__(self): +# super().__init__('fibonacci_action_server') +# self._action_server = ActionServer( +# self, +# Fibonacci, +# 'fibonacci', +# self.execute_callback, +# cancel_callback=self.cancel_callback) + +# def execute_callback(self, goal_handle): +# self.get_logger().info('Executing goal...') + +# feedback_msg = Fibonacci.Feedback() +# feedback_msg.sequence = [0, 1] + +# for i in range(1, goal_handle.request.order): +# if goal_handle.is_cancel_requested: +# goal_handle.canceled() +# self.get_logger().info('Goal canceled') +# return Fibonacci.Result() +# feedback_msg.sequence.append( +# feedback_msg.sequence[i] + feedback_msg.sequence[i-1]) +# self.get_logger().info('Feedback: {0}'.format(feedback_msg.sequence)) +# goal_handle.publish_feedback(feedback_msg) +# time.sleep(1) + +# goal_handle.succeed() + +# result = Fibonacci.Result() +# result.sequence = feedback_msg.sequence +# return result + +# def cancel_callback(self, goal_handle): +# self.get_logger().info('Canceling goal...') +# return CancelResponse.ACCEPT + + +# def main(args=None): +# try: +# rclpy.init(args=args) +# fibonacci_action_server = FibonacciActionServer() +# fibonacci_action_server.get_logger().info('Init...') +# executor = MultiThreadedExecutor() +# fibonacci_action_server.get_logger().info('Starting..') +# rclpy.spin(fibonacci_action_server, executor=executor) +# except (KeyboardInterrupt, ExternalShutdownException): +# pass + + +# if __name__ == '__main__': +# main() \ No newline at end of file diff --git a/docker/ros2/integration-tests-launch.py b/docker/ros2/integration-tests-launch.py new file mode 100644 index 0000000..549c195 --- /dev/null +++ b/docker/ros2/integration-tests-launch.py @@ -0,0 +1,22 @@ +from launch import LaunchDescription +from launch.substitutions import PathJoinSubstitution +from launch_ros.substitutions import FindPackageShare +from launch.actions import ExecuteProcess, IncludeLaunchDescription + +def generate_launch_description(): + return LaunchDescription([ + # Start rosbridge_websocket + IncludeLaunchDescription( + PathJoinSubstitution([ + FindPackageShare('rosbridge_server'), + 'launch', + 'rosbridge_websocket_launch.xml' + ]) + ), + + # Start fibonacci_server.py with python3 + ExecuteProcess( + cmd=['python3', "/fibonacci_server.py"], + output='screen' + ) + ]) diff --git a/docker/ros2/integration-tests.launch b/docker/ros2/integration-tests.launch deleted file mode 100644 index 1aedc6d..0000000 --- a/docker/ros2/integration-tests.launch +++ /dev/null @@ -1,4 +0,0 @@ - - - - From 79022588566f4981a8354c55f2798f3835db9019 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Thu, 2 Oct 2025 00:44:07 +0200 Subject: [PATCH 06/16] Fix small doc issues --- src/roslibpy/core.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/roslibpy/core.py b/src/roslibpy/core.py index a3de567..db26cca 100644 --- a/src/roslibpy/core.py +++ b/src/roslibpy/core.py @@ -542,7 +542,7 @@ class ActionClient(object): Args: ros (:class:`.Ros`): Instance of the ROS connection. name (:obj:`str`): Service name, e.g. ``/fibonacci``. - action_type (:obj:`str`): Action type, e.g. ``rospy_tutorials/fibonacci``. + action_type (:obj:`str`): Action type, e.g. ``example_interfaces/action/Fibonacci``. """ def __init__(self, ros, name, action_type, reconnect_on_close=True): @@ -555,18 +555,17 @@ def __init__(self, ros, name, action_type, reconnect_on_close=True): self.reconnect_on_close = reconnect_on_close def send_goal(self, goal, resultback, feedback, errback): - """ Start a service call. + """Start a call to an action. - Note: - The action client is non-blocking. + Note: The action client is non-blocking. - Args: + Args: request (:class:`.ServiceRequest`): Service request. resultback: Callback invoked on receiving action result. feedback: Callback invoked on receiving action feedback. errback: Callback invoked on error. - Returns: + Returns: object: goal ID if successfull, otherwise ``None``. """ if self._is_advertised: @@ -589,11 +588,12 @@ def send_goal(self, goal, resultback, feedback, errback): return action_goal_id def cancel_goal(self, goal_id): - """ Cancel an ongoing action. - NOTE: Async cancelation is not yet supported on rosbridge (rosbridge_suite issue #909) + """Cancel an ongoing action. + + NOTE: Async cancelation is not yet supported on rosbridge (rosbridge_suite issue #909) - Args: - goal_id: Goal ID returned from "send_goal()" + Args: + goal_id: Goal ID returned from ``send_goal()`` """ message = Message( { From e4a3fbea0611456cd44caa8cd45c92acd0aeba70 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Tue, 7 Oct 2025 00:32:33 +0200 Subject: [PATCH 07/16] doc fixes --- src/roslibpy/__init__.py | 2 +- src/roslibpy/core.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/roslibpy/__init__.py b/src/roslibpy/__init__.py index f498199..743780d 100644 --- a/src/roslibpy/__init__.py +++ b/src/roslibpy/__init__.py @@ -42,7 +42,7 @@ ================= ROS 1 vs ROS 2 ------------- +-------------- This library has been tested to work with ROS 1. ROS 2 should work, but it is still in the works. diff --git a/src/roslibpy/core.py b/src/roslibpy/core.py index db26cca..4b0ccc7 100644 --- a/src/roslibpy/core.py +++ b/src/roslibpy/core.py @@ -560,7 +560,7 @@ def send_goal(self, goal, resultback, feedback, errback): Note: The action client is non-blocking. Args: - request (:class:`.ServiceRequest`): Service request. + goal (:class:`.ActionGoal`): Action goal. resultback: Callback invoked on receiving action result. feedback: Callback invoked on receiving action feedback. errback: Callback invoked on error. From 2fbbc1a50db7bdbb081e603d97b0d3e4e15b2027 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Tue, 7 Oct 2025 00:32:43 +0200 Subject: [PATCH 08/16] Remove commented out code in example server --- docker/ros2/fibonacci_server.py | 70 --------------------------------- 1 file changed, 70 deletions(-) diff --git a/docker/ros2/fibonacci_server.py b/docker/ros2/fibonacci_server.py index 8dbb21e..28df745 100644 --- a/docker/ros2/fibonacci_server.py +++ b/docker/ros2/fibonacci_server.py @@ -130,73 +130,3 @@ def main(args=None): if __name__ == '__main__': main() - - -# import time - -# from example_interfaces.action import Fibonacci -# from rcl_interfaces.msg import SetParametersResult - -# import rclpy -# from rclpy.action import ActionServer, CancelResponse -# from rclpy.executors import ExternalShutdownException -# from rclpy.executors import MultiThreadedExecutor -# from rclpy.node import Node -# from rclpy.parameter import Parameter -# from rclpy.qos import qos_profile_system_default -# from rclpy.service_introspection import ServiceIntrospectionState - - -# class FibonacciActionServer(Node): - -# def __init__(self): -# super().__init__('fibonacci_action_server') -# self._action_server = ActionServer( -# self, -# Fibonacci, -# 'fibonacci', -# self.execute_callback, -# cancel_callback=self.cancel_callback) - -# def execute_callback(self, goal_handle): -# self.get_logger().info('Executing goal...') - -# feedback_msg = Fibonacci.Feedback() -# feedback_msg.sequence = [0, 1] - -# for i in range(1, goal_handle.request.order): -# if goal_handle.is_cancel_requested: -# goal_handle.canceled() -# self.get_logger().info('Goal canceled') -# return Fibonacci.Result() -# feedback_msg.sequence.append( -# feedback_msg.sequence[i] + feedback_msg.sequence[i-1]) -# self.get_logger().info('Feedback: {0}'.format(feedback_msg.sequence)) -# goal_handle.publish_feedback(feedback_msg) -# time.sleep(1) - -# goal_handle.succeed() - -# result = Fibonacci.Result() -# result.sequence = feedback_msg.sequence -# return result - -# def cancel_callback(self, goal_handle): -# self.get_logger().info('Canceling goal...') -# return CancelResponse.ACCEPT - - -# def main(args=None): -# try: -# rclpy.init(args=args) -# fibonacci_action_server = FibonacciActionServer() -# fibonacci_action_server.get_logger().info('Init...') -# executor = MultiThreadedExecutor() -# fibonacci_action_server.get_logger().info('Starting..') -# rclpy.spin(fibonacci_action_server, executor=executor) -# except (KeyboardInterrupt, ExternalShutdownException): -# pass - - -# if __name__ == '__main__': -# main() \ No newline at end of file From 59bc305815342820694a13b0993c89c9e0f9621d Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Tue, 7 Oct 2025 01:20:24 +0200 Subject: [PATCH 09/16] move actionlib into ros1 --- src/roslibpy/ros1/__init__.py | 0 src/roslibpy/{ => ros1}/actionlib.py | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) create mode 100644 src/roslibpy/ros1/__init__.py rename src/roslibpy/{ => ros1}/actionlib.py (99%) diff --git a/src/roslibpy/ros1/__init__.py b/src/roslibpy/ros1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/roslibpy/actionlib.py b/src/roslibpy/ros1/actionlib.py similarity index 99% rename from src/roslibpy/actionlib.py rename to src/roslibpy/ros1/actionlib.py index d3b5b19..4e2b666 100644 --- a/src/roslibpy/actionlib.py +++ b/src/roslibpy/ros1/actionlib.py @@ -28,9 +28,9 @@ import threading import time -from . import Message, Topic -from .core import RosTimeoutError -from .event_emitter import EventEmitterMixin +from roslibpy import Message, Topic +from roslibpy.core import RosTimeoutError +from roslibpy.event_emitter import EventEmitterMixin __all__ = ["Goal", "GoalStatus", "ActionClient", "SimpleActionServer"] From 94907b4847572f048d9760fb92cabf5a94252562 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Tue, 7 Oct 2025 01:20:47 +0200 Subject: [PATCH 10/16] update example --- docs/files/ros2-action-client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/files/ros2-action-client.py b/docs/files/ros2-action-client.py index 64d17d8..5b6d4dd 100644 --- a/docs/files/ros2-action-client.py +++ b/docs/files/ros2-action-client.py @@ -11,7 +11,7 @@ def result_callback(msg): print("Action result:", msg) def feedback_callback(msg): - print(f"Action feedback: {msg['partial_sequence']}") + print(f"Action feedback: {msg['sequence']}") def fail_callback(msg): print(f"Action failed: {msg}") @@ -23,7 +23,8 @@ def test_action_success(action_client): global result result = None - action_client.send_goal(roslibpy.ActionGoal({"order": 8}), + goal = roslibpy.ActionGoal({"order": 8}) + action_client.send_goal(goal, result_callback, feedback_callback, fail_callback) @@ -44,7 +45,8 @@ def test_action_cancel(action_client): global result result = None - goal_id = action_client.send_goal(roslibpy.ActionGoal({"order": 8}), + goal = roslibpy.ActionGoal({"order": 8}) + goal_id = action_client.send_goal(goal, result_callback, feedback_callback, fail_callback) @@ -66,7 +68,7 @@ def test_action_cancel(action_client): action_client = roslibpy.ActionClient(client, "/fibonacci", - "custom_action_interfaces/action/Fibonacci") + "example_interfaces/action/Fibonacci") print("\n** Starting action client test **") test_action_success(action_client) From 6ed5f20b8ca2f285ef1dfc78fe1158402c7dba13 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Tue, 7 Oct 2025 01:33:24 +0200 Subject: [PATCH 11/16] update docs and add tests --- docs/reference/index.rst | 2 +- src/roslibpy/__init__.py | 4 +- src/roslibpy/comm/comm.py | 2 +- src/roslibpy/core.py | 36 ++++++++++++++++- src/roslibpy/ros1/actionlib.py | 4 +- tests/ros1/test_actionlib.py | 2 +- tests/ros2/test_actions.py | 74 ++++++++++++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 8 deletions(-) create mode 100644 tests/ros2/test_actions.py diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 9556e53..9c887ac 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -8,6 +8,6 @@ API Reference from roslibpy import * .. automodule:: roslibpy -.. automodule:: roslibpy.actionlib .. automodule:: roslibpy.tf +.. automodule:: roslibpy.ros1.actionlib .. automodule:: roslibpy.ros2 diff --git a/src/roslibpy/__init__.py b/src/roslibpy/__init__.py index 743780d..7bf7f0d 100644 --- a/src/roslibpy/__init__.py +++ b/src/roslibpy/__init__.py @@ -82,8 +82,8 @@ class and are passed around via :class:`Topics ` using a **publish/subscr .. autoclass:: ServiceResponse :members: -Actions --------- +Actions (ROS 2) +--------------- An Action client for ROS 2 Actions can be used by managing goal/feedback/result messages via :class:`ActionClient `. diff --git a/src/roslibpy/comm/comm.py b/src/roslibpy/comm/comm.py index 0ba45a7..140985b 100644 --- a/src/roslibpy/comm/comm.py +++ b/src/roslibpy/comm/comm.py @@ -174,7 +174,7 @@ def _handle_action_result(self, message): LOGGER.debug("Received Action result with status: %s", status) - results = {"status": ActionGoalStatus(status).name, "values": message.get("values", {})} + results = {"status": ActionGoalStatus(status), "values": message.get("values", {})} if "result" in message and message["result"] is False: if errback: diff --git a/src/roslibpy/core.py b/src/roslibpy/core.py index 4b0ccc7..4651526 100644 --- a/src/roslibpy/core.py +++ b/src/roslibpy/core.py @@ -3,6 +3,7 @@ import json import logging import time +import threading from enum import Enum # Python 2/3 compatibility import list @@ -553,6 +554,7 @@ def __init__(self, ros, name, action_type, reconnect_on_close=True): self._service_callback = None self._is_advertised = False self.reconnect_on_close = reconnect_on_close + self.wait_results = {} def send_goal(self, goal, resultback, feedback, errback): """Start a call to an action. @@ -572,6 +574,7 @@ def send_goal(self, goal, resultback, feedback, errback): return action_goal_id = "send_action_goal:%s:%d" % (self.name, self.ros.id_counter) + self.wait_results[action_goal_id] = threading.Event() message = Message( { @@ -584,7 +587,19 @@ def send_goal(self, goal, resultback, feedback, errback): } ) - self.ros.call_async_action(message, resultback, feedback, errback) + def _internal_resultback(result): + wait_result_event = self.wait_results.get(action_goal_id) + if wait_result_event is not None: + wait_result_event.set() + resultback(result) + + def _internal_errback(err): + wait_result_event = self.wait_results.get(action_goal_id) + if wait_result_event is not None: + wait_result_event.set() + errback(err) + + self.ros.call_async_action(message, _internal_resultback, feedback, _internal_errback) return action_goal_id def cancel_goal(self, goal_id): @@ -603,9 +618,28 @@ def cancel_goal(self, goal_id): } ) self.ros.send_on_ready(message) + # Remove message_id from RosBridgeProtocol._pending_action_requests in comms.py? # Not needed since an action result is returned upon cancelation. + def wait_goal(self, goal_id, timeout=None): + """Block until the result is available. + + If ``timeout`` is ``None``, it will wait indefinitely. + + Args: + goal_id: Goal ID returned from ``send_goal()`` + timeout (:obj:`int`): Timeout to wait for the result expressed in seconds. + """ + + wait_result_event = self.wait_results.get(goal_id) + if wait_result_event is None: + raise ValueError("Unknown goal ID") + + if not wait_result_event.wait(timeout): + raise RosTimeoutError("Goal failed to receive result") + + self.wait_results.pop(goal_id, None) class Param(object): """A ROS parameter. diff --git a/src/roslibpy/ros1/actionlib.py b/src/roslibpy/ros1/actionlib.py index 4e2b666..41d284c 100644 --- a/src/roslibpy/ros1/actionlib.py +++ b/src/roslibpy/ros1/actionlib.py @@ -1,6 +1,6 @@ """ -Actionlib -========= +Actionlib (ROS 1) +================= Another way to interact with ROS is through the **actionlib** stack. Actions in ROS allow to execute preemptable tasks, i.e. tasks that can be interrupted by the client. diff --git a/tests/ros1/test_actionlib.py b/tests/ros1/test_actionlib.py index 344417b..54a88af 100644 --- a/tests/ros1/test_actionlib.py +++ b/tests/ros1/test_actionlib.py @@ -3,7 +3,7 @@ import time from roslibpy import Ros -from roslibpy.actionlib import ActionClient, Goal, GoalStatus, SimpleActionServer +from roslibpy.ros1.actionlib import ActionClient, Goal, GoalStatus, SimpleActionServer def test_action_success(): diff --git a/tests/ros2/test_actions.py b/tests/ros2/test_actions.py new file mode 100644 index 0000000..8f5c6a8 --- /dev/null +++ b/tests/ros2/test_actions.py @@ -0,0 +1,74 @@ +from __future__ import print_function + +import time + +from roslibpy import Ros +from roslibpy import ActionClient, ActionGoal, ActionGoalStatus + +def test_fibonacci(): + ros = Ros("127.0.0.1", 9090) + ros.run() + + action = ActionClient( + ros, + '/fibonacci', + 'example_interfaces/action/Fibonacci' + ) + + results = {} + + def on_result(result): + print(f"Result: {result}") + results["result"] = result + + def on_feedback(feedback): + print(f"Feedback: {feedback}") + + def on_error(error): + print(f"Error: {error}") + + goal = ActionGoal({"order": 4}) + goal_id = action.send_goal(goal, on_result, on_feedback, on_error) + action.wait_goal(goal_id) + time.sleep(0.2) + + assert results["result"]["values"]["sequence"] == [0, 1, 1, 2, 3] + assert results["result"]["status"] == ActionGoalStatus.SUCCEEDED + + ros.close() + +def test_cancel(): + ros = Ros("127.0.0.1", 9090) + ros.run() + + action = ActionClient( + ros, + '/fibonacci', + 'example_interfaces/action/Fibonacci' + ) + + results = {} + + def on_result(result): + print(f"Result: {result}") + results["result"] = result + + def on_feedback(feedback): + print(f"Feedback: {feedback}") + + def on_error(error): + print(f"Error: {error}") + + goal = ActionGoal({"order": 10}) + goal_id = action.send_goal(goal, on_result, on_feedback, on_error) + time.sleep(2) + action.cancel_goal(goal_id) + action.wait_goal(goal_id) + time.sleep(0.2) + + assert results["result"]["status"] == ActionGoalStatus.CANCELED + + ros.close() + +if __name__ == "__main__": + test_cancel() \ No newline at end of file From 243453c36a76effd5053d2d4bcb8d5c20f9e9d33 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Tue, 7 Oct 2025 01:35:31 +0200 Subject: [PATCH 12/16] Update CHANGELOG --- CHANGELOG.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 16eafd5..5347d62 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,11 +11,15 @@ Unreleased ---------- **Added** + * Added ROS 2 action client support. +* Added ``wait_goal`` method to ``ActionClient`` to block until a goal reaches a terminal state. +* Moved ``actionlib`` module to ``roslibpy.ros1`` namespace. **Changed** **Fixed** + * Fixed KeyError in ROS2 ActionClient when receiving action results with different message formats from rosbridge. **Deprecated** From c8a8ec52643c48e42a82da4c7afb195a3e398ce3 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Tue, 7 Oct 2025 01:42:22 +0200 Subject: [PATCH 13/16] fix unit tests --- tests/ros2/test_action_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/ros2/test_action_client.py b/tests/ros2/test_action_client.py index bb35f17..62abb0f 100644 --- a/tests/ros2/test_action_client.py +++ b/tests/ros2/test_action_client.py @@ -34,7 +34,7 @@ def test_action_result_with_status_at_top_level(): assert result_callback.called result = result_callback.call_args[0][0] assert isinstance(result, ActionResult) - assert result["status"] == ActionGoalStatus.SUCCEEDED.name + assert result["status"] == ActionGoalStatus.SUCCEEDED assert result["values"] == message["values"] @@ -72,7 +72,7 @@ def test_action_result_with_status_in_values(): assert result_callback.called result = result_callback.call_args[0][0] assert isinstance(result, ActionResult) - assert result["status"] == ActionGoalStatus.SUCCEEDED.name + assert result["status"] == ActionGoalStatus.SUCCEEDED def test_action_result_failure_with_status_at_top_level(): @@ -101,7 +101,7 @@ def test_action_result_failure_with_status_at_top_level(): assert error_callback.called result = error_callback.call_args[0][0] - assert result["status"] == ActionGoalStatus.ABORTED.name + assert result["status"] == ActionGoalStatus.ABORTED assert result["values"] == message["values"] From b0c176d343b0e617cb3bee936e08eb5f22f70ff9 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Tue, 7 Oct 2025 01:46:34 +0200 Subject: [PATCH 14/16] lint --- src/roslibpy/core.py | 5 +++-- tests/ros2/test_actions.py | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/roslibpy/core.py b/src/roslibpy/core.py index 4651526..e767af7 100644 --- a/src/roslibpy/core.py +++ b/src/roslibpy/core.py @@ -2,8 +2,8 @@ import json import logging -import time import threading +import time from enum import Enum # Python 2/3 compatibility import list @@ -604,7 +604,7 @@ def _internal_errback(err): def cancel_goal(self, goal_id): """Cancel an ongoing action. - + NOTE: Async cancelation is not yet supported on rosbridge (rosbridge_suite issue #909) Args: @@ -641,6 +641,7 @@ def wait_goal(self, goal_id, timeout=None): self.wait_results.pop(goal_id, None) + class Param(object): """A ROS parameter. diff --git a/tests/ros2/test_actions.py b/tests/ros2/test_actions.py index 8f5c6a8..51cbe21 100644 --- a/tests/ros2/test_actions.py +++ b/tests/ros2/test_actions.py @@ -2,8 +2,8 @@ import time -from roslibpy import Ros -from roslibpy import ActionClient, ActionGoal, ActionGoalStatus +from roslibpy import ActionClient, ActionGoal, ActionGoalStatus, Ros + def test_fibonacci(): ros = Ros("127.0.0.1", 9090) @@ -31,12 +31,13 @@ def on_error(error): goal_id = action.send_goal(goal, on_result, on_feedback, on_error) action.wait_goal(goal_id) time.sleep(0.2) - + assert results["result"]["values"]["sequence"] == [0, 1, 1, 2, 3] assert results["result"]["status"] == ActionGoalStatus.SUCCEEDED ros.close() + def test_cancel(): ros = Ros("127.0.0.1", 9090) ros.run() @@ -70,5 +71,6 @@ def on_error(error): ros.close() + if __name__ == "__main__": - test_cancel() \ No newline at end of file + test_cancel() From 2c1ee670d265476bc3ba6192b13e30b9a116d8e6 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Tue, 7 Oct 2025 07:29:28 +0200 Subject: [PATCH 15/16] fix ROS 2 styling --- CHANGELOG.rst | 2 +- src/roslibpy/comm/comm.py | 2 +- src/roslibpy/core.py | 2 +- tests/ros2/test_action_client.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5347d62..7e59570 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,7 +20,7 @@ Unreleased **Fixed** -* Fixed KeyError in ROS2 ActionClient when receiving action results with different message formats from rosbridge. +* Fixed KeyError in ROS 2 ActionClient when receiving action results with different message formats from rosbridge. **Deprecated** diff --git a/src/roslibpy/comm/comm.py b/src/roslibpy/comm/comm.py index 140985b..dba59cd 100644 --- a/src/roslibpy/comm/comm.py +++ b/src/roslibpy/comm/comm.py @@ -164,7 +164,7 @@ def _handle_action_result(self, message): del self._pending_action_requests[request_id] # Handle different message formats for status field - # ROS2 rosbridge may send status at top level or inside values + # ROS 2 rosbridge may send status at top level or inside values status = message.get("status") if status is None and "values" in message: status = message["values"].get("status") diff --git a/src/roslibpy/core.py b/src/roslibpy/core.py index e767af7..f750612 100644 --- a/src/roslibpy/core.py +++ b/src/roslibpy/core.py @@ -155,7 +155,7 @@ def __init__(self, values=None): class ActionGoalStatus(Enum): - """ ROS2 Action Goal statuses. + """ ROS 2 Action Goal statuses. Reference: https://docs.ros2.org/latest/api/action_msgs/msg/GoalStatus.html """ diff --git a/tests/ros2/test_action_client.py b/tests/ros2/test_action_client.py index 62abb0f..4f41806 100644 --- a/tests/ros2/test_action_client.py +++ b/tests/ros2/test_action_client.py @@ -1,4 +1,4 @@ -"""Tests for ROS2 ActionClient message handling.""" +"""Tests for ROS 2 ActionClient message handling.""" from unittest.mock import Mock @@ -19,7 +19,7 @@ def test_action_result_with_status_at_top_level(): request_id = "send_action_goal:/test_action:1" protocol._pending_action_requests[request_id] = (result_callback, feedback_callback, error_callback) - # ROS2 rosbridge message format with status at top level + # ROS 2 rosbridge message format with status at top level message = { "op": "action_result", "action": "/test_action", From 7c52a8d89a1c1ce2be79aef53d3055f932c90b75 Mon Sep 17 00:00:00 2001 From: Gonzalo Casas Date: Tue, 7 Oct 2025 14:38:38 +0200 Subject: [PATCH 16/16] Remove `Action` prefix from action goal, goal status, feedback and result --- docs/files/ros2-action-client.py | 4 ++-- src/roslibpy/__init__.py | 30 ++++++++++++++++-------------- src/roslibpy/comm/comm.py | 14 +++++++------- src/roslibpy/core.py | 25 +++++++++++++------------ tests/ros2/test_action_client.py | 14 +++++++------- tests/ros2/test_actions.py | 10 +++++----- 6 files changed, 50 insertions(+), 47 deletions(-) diff --git a/docs/files/ros2-action-client.py b/docs/files/ros2-action-client.py index 5b6d4dd..94fcbec 100644 --- a/docs/files/ros2-action-client.py +++ b/docs/files/ros2-action-client.py @@ -23,7 +23,7 @@ def test_action_success(action_client): global result result = None - goal = roslibpy.ActionGoal({"order": 8}) + goal = roslibpy.Goal({"order": 8}) action_client.send_goal(goal, result_callback, feedback_callback, @@ -45,7 +45,7 @@ def test_action_cancel(action_client): global result result = None - goal = roslibpy.ActionGoal({"order": 8}) + goal = roslibpy.Goal({"order": 8}) goal_id = action_client.send_goal(goal, result_callback, feedback_callback, diff --git a/src/roslibpy/__init__.py b/src/roslibpy/__init__.py index 7bf7f0d..4ccf076 100644 --- a/src/roslibpy/__init__.py +++ b/src/roslibpy/__init__.py @@ -90,11 +90,13 @@ class and are passed around via :class:`Topics ` using a **publish/subscr .. autoclass:: ActionClient :members: -.. autoclass:: ActionGoal +.. autoclass:: Goal :members: -.. autoclass:: ActionFeedback +.. autoclass:: GoalStatus :members: -.. autoclass:: ActionResult +.. autoclass:: Feedback + :members: +.. autoclass:: Result :members: Parameter server @@ -129,13 +131,13 @@ class and are passed around via :class:`Topics ` using a **publish/subscr ) from .core import ( ActionClient, - ActionFeedback, - ActionGoal, - ActionGoalStatus, - ActionResult, + Feedback, + Goal, + GoalStatus, Header, Message, Param, + Result, Service, ServiceRequest, ServiceResponse, @@ -153,19 +155,19 @@ class and are passed around via :class:`Topics ` using a **publish/subscr "__title__", "__url__", "__version__", + "ActionClient", + "Feedback", + "Goal", + "GoalStatus", "Header", "Message", "Param", + "Result", + "Ros", "Service", "ServiceRequest", "ServiceResponse", - "ActionClient", - "ActionGoal", - "ActionGoalStatus", - "ActionFeedback", - "ActionResult", + "set_rosapi_timeout", "Time", "Topic", - "set_rosapi_timeout", - "Ros", ] diff --git a/src/roslibpy/comm/comm.py b/src/roslibpy/comm/comm.py index dba59cd..2f3394b 100644 --- a/src/roslibpy/comm/comm.py +++ b/src/roslibpy/comm/comm.py @@ -4,11 +4,11 @@ import logging from roslibpy.core import ( - ActionFeedback, - ActionGoalStatus, - ActionResult, + Feedback, + GoalStatus, Message, MessageEncoder, + Result, ServiceResponse, ) @@ -151,7 +151,7 @@ def _handle_action_feedback(self, message): request_id = message["id"] _, feedback, _ = self._pending_action_requests.get(request_id, None) - feedback(ActionFeedback(message["values"])) + feedback(Feedback(message["values"])) def _handle_action_result(self, message): request_id = message["id"] @@ -170,15 +170,15 @@ def _handle_action_result(self, message): status = message["values"].get("status") if status is None: # Default to UNKNOWN if status is not found - status = ActionGoalStatus.UNKNOWN.value + status = GoalStatus.UNKNOWN.value LOGGER.debug("Received Action result with status: %s", status) - results = {"status": ActionGoalStatus(status), "values": message.get("values", {})} + results = {"status": GoalStatus(status), "values": message.get("values", {})} if "result" in message and message["result"] is False: if errback: errback(results) else: if resultback: - resultback(ActionResult(results)) + resultback(Result(results)) diff --git a/src/roslibpy/core.py b/src/roslibpy/core.py index f750612..2153dcc 100644 --- a/src/roslibpy/core.py +++ b/src/roslibpy/core.py @@ -22,9 +22,10 @@ "Service", "ServiceRequest", "ServiceResponse", - "ActionGoal", - "ActionFeedback", - "ActionResult", + "Goal", + "GoalStatus", + "Feedback", + "Result", "Time", "Topic", ] @@ -136,8 +137,8 @@ def __init__(self, values=None): self.update(values) -class ActionResult(UserDict): - """Result returned from a action call.""" +class Result(UserDict): + """Result returned from an action call.""" def __init__(self, values=None): self.data = {} @@ -145,8 +146,8 @@ def __init__(self, values=None): self.update(values) -class ActionFeedback(UserDict): - """Feedback returned from a action call.""" +class Feedback(UserDict): + """Feedback returned from an action call.""" def __init__(self, values=None): self.data = {} @@ -154,8 +155,8 @@ def __init__(self, values=None): self.update(values) -class ActionGoalStatus(Enum): - """ ROS 2 Action Goal statuses. +class GoalStatus(Enum): + """ ROS 2 goal statuses. Reference: https://docs.ros2.org/latest/api/action_msgs/msg/GoalStatus.html """ @@ -168,8 +169,8 @@ class ActionGoalStatus(Enum): ABORTED = 6 -class ActionGoal(UserDict): - """Action Goal for an action call.""" +class Goal(UserDict): + """Goal for an action call.""" def __init__(self, values=None): self.data = {} @@ -562,7 +563,7 @@ def send_goal(self, goal, resultback, feedback, errback): Note: The action client is non-blocking. Args: - goal (:class:`.ActionGoal`): Action goal. + goal (:class:`.Goal`): Action goal. resultback: Callback invoked on receiving action result. feedback: Callback invoked on receiving action feedback. errback: Callback invoked on error. diff --git a/tests/ros2/test_action_client.py b/tests/ros2/test_action_client.py index 4f41806..23f5d6f 100644 --- a/tests/ros2/test_action_client.py +++ b/tests/ros2/test_action_client.py @@ -3,7 +3,7 @@ from unittest.mock import Mock from roslibpy.comm.comm import RosBridgeProtocol -from roslibpy.core import ActionGoalStatus, ActionResult +from roslibpy.core import GoalStatus, Result def test_action_result_with_status_at_top_level(): @@ -33,8 +33,8 @@ def test_action_result_with_status_at_top_level(): assert result_callback.called result = result_callback.call_args[0][0] - assert isinstance(result, ActionResult) - assert result["status"] == ActionGoalStatus.SUCCEEDED + assert isinstance(result, Result) + assert result["status"] == GoalStatus.SUCCEEDED assert result["values"] == message["values"] @@ -71,8 +71,8 @@ def test_action_result_with_status_in_values(): assert result_callback.called result = result_callback.call_args[0][0] - assert isinstance(result, ActionResult) - assert result["status"] == ActionGoalStatus.SUCCEEDED + assert isinstance(result, Result) + assert result["status"] == GoalStatus.SUCCEEDED def test_action_result_failure_with_status_at_top_level(): @@ -101,7 +101,7 @@ def test_action_result_failure_with_status_at_top_level(): assert error_callback.called result = error_callback.call_args[0][0] - assert result["status"] == ActionGoalStatus.ABORTED + assert result["status"] == GoalStatus.ABORTED assert result["values"] == message["values"] @@ -136,6 +136,6 @@ def test_action_result_without_status_field(): assert result_callback.called result = result_callback.call_args[0][0] - assert isinstance(result, ActionResult) + assert isinstance(result, Result) # Should have some status value (likely UNKNOWN) assert "status" in result diff --git a/tests/ros2/test_actions.py b/tests/ros2/test_actions.py index 51cbe21..97ee779 100644 --- a/tests/ros2/test_actions.py +++ b/tests/ros2/test_actions.py @@ -2,7 +2,7 @@ import time -from roslibpy import ActionClient, ActionGoal, ActionGoalStatus, Ros +from roslibpy import ActionClient, Goal, GoalStatus, Ros def test_fibonacci(): @@ -27,13 +27,13 @@ def on_feedback(feedback): def on_error(error): print(f"Error: {error}") - goal = ActionGoal({"order": 4}) + goal = Goal({"order": 4}) goal_id = action.send_goal(goal, on_result, on_feedback, on_error) action.wait_goal(goal_id) time.sleep(0.2) assert results["result"]["values"]["sequence"] == [0, 1, 1, 2, 3] - assert results["result"]["status"] == ActionGoalStatus.SUCCEEDED + assert results["result"]["status"] == GoalStatus.SUCCEEDED ros.close() @@ -60,14 +60,14 @@ def on_feedback(feedback): def on_error(error): print(f"Error: {error}") - goal = ActionGoal({"order": 10}) + goal = Goal({"order": 10}) goal_id = action.send_goal(goal, on_result, on_feedback, on_error) time.sleep(2) action.cancel_goal(goal_id) action.wait_goal(goal_id) time.sleep(0.2) - assert results["result"]["status"] == ActionGoalStatus.CANCELED + assert results["result"]["status"] == GoalStatus.CANCELED ros.close()