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()