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/CHANGELOG.rst b/CHANGELOG.rst index 62913fe..7e59570 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,12 +11,17 @@ 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 ROS 2 ActionClient when receiving action results with different message formats from rosbridge. + **Deprecated** **Removed** 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..28df745 --- /dev/null +++ b/docker/ros2/fibonacci_server.py @@ -0,0 +1,132 @@ +# 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() 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 @@ - - - - diff --git a/docs/files/ros2-action-client.py b/docs/files/ros2-action-client.py index 64d17d8..94fcbec 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.Goal({"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.Goal({"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) 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 f498199..4ccf076 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. @@ -82,19 +82,21 @@ 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 `. .. autoclass:: ActionClient :members: -.. autoclass:: ActionGoal +.. autoclass:: Goal + :members: +.. autoclass:: GoalStatus :members: -.. autoclass:: ActionFeedback +.. autoclass:: Feedback :members: -.. autoclass:: ActionResult +.. 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 8a33453..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, ) @@ -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: @@ -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"] @@ -160,16 +160,25 @@ 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] - LOGGER.debug("Received Action result with status: %s", message["status"]) + # Handle different message formats for status field + # 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") + if status is None: + # Default to UNKNOWN if status is not found + status = GoalStatus.UNKNOWN.value - results = {"status": ActionGoalStatus(message["status"]).name, "values": message["values"]} + LOGGER.debug("Received Action result with status: %s", status) + + 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 a3de567..2153dcc 100644 --- a/src/roslibpy/core.py +++ b/src/roslibpy/core.py @@ -2,6 +2,7 @@ import json import logging +import threading import time from enum import Enum @@ -21,9 +22,10 @@ "Service", "ServiceRequest", "ServiceResponse", - "ActionGoal", - "ActionFeedback", - "ActionResult", + "Goal", + "GoalStatus", + "Feedback", + "Result", "Time", "Topic", ] @@ -135,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 = {} @@ -144,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 = {} @@ -153,8 +155,8 @@ def __init__(self, values=None): self.update(values) -class ActionGoalStatus(Enum): - """ ROS2 Action Goal statuses. +class GoalStatus(Enum): + """ ROS 2 goal statuses. Reference: https://docs.ros2.org/latest/api/action_msgs/msg/GoalStatus.html """ @@ -167,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 = {} @@ -542,7 +544,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): @@ -553,26 +555,27 @@ 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 service call. + """Start a call to an action. - Note: - The action client is non-blocking. + Note: The action client is non-blocking. - Args: - request (:class:`.ServiceRequest`): Service request. + Args: + goal (:class:`.Goal`): Action goal. 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: 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( { @@ -585,15 +588,28 @@ 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): - """ 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( { @@ -603,9 +619,29 @@ 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/__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..41d284c 100644 --- a/src/roslibpy/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. @@ -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"] 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_action_client.py b/tests/ros2/test_action_client.py new file mode 100644 index 0000000..23f5d6f --- /dev/null +++ b/tests/ros2/test_action_client.py @@ -0,0 +1,141 @@ +"""Tests for ROS 2 ActionClient message handling.""" + +from unittest.mock import Mock + +from roslibpy.comm.comm import RosBridgeProtocol +from roslibpy.core import GoalStatus, Result + + +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) + + # ROS 2 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, Result) + assert result["status"] == GoalStatus.SUCCEEDED + 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, Result) + assert result["status"] == GoalStatus.SUCCEEDED + + +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"] == GoalStatus.ABORTED + 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, 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 new file mode 100644 index 0000000..97ee779 --- /dev/null +++ b/tests/ros2/test_actions.py @@ -0,0 +1,76 @@ +from __future__ import print_function + +import time + +from roslibpy import ActionClient, Goal, GoalStatus, Ros + + +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 = 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"] == GoalStatus.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 = 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"] == GoalStatus.CANCELED + + ros.close() + + +if __name__ == "__main__": + test_cancel()