Skip to content

Commit 97e8d5f

Browse files
committed
Merge branch 'copilot/fix-eb4017ce-00dc-4df3-994b-8484ca35ba21' of https://github.com/gramaziokohler/roslibpy into updates-ros2-support
2 parents 94907b4 + 631d567 commit 97e8d5f

File tree

3 files changed

+156
-5
lines changed

3 files changed

+156
-5
lines changed

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Unreleased
1616
**Changed**
1717

1818
**Fixed**
19+
* Fixed KeyError in ROS2 ActionClient when receiving action results with different message formats from rosbridge.
1920

2021
**Deprecated**
2122

src/roslibpy/comm/comm.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,12 @@ def send_ros_action_goal(self, message, resultback, feedback, errback):
138138
def _handle_action_request(self, message):
139139
if "action" not in message:
140140
raise ValueError("Expected action name missing in action request")
141-
raise RosBridgeException('Action server capabilities not yet implemented')
141+
raise RosBridgeException("Action server capabilities not yet implemented")
142142

143143
def _handle_action_cancel(self, message):
144144
if "action" not in message:
145145
raise ValueError("Expected action name missing in action request")
146-
raise RosBridgeException('Action server capabilities not yet implemented')
146+
raise RosBridgeException("Action server capabilities not yet implemented")
147147

148148
def _handle_action_feedback(self, message):
149149
if "action" not in message:
@@ -160,12 +160,21 @@ def _handle_action_result(self, message):
160160
if not action_handlers:
161161
raise RosBridgeException('No handler registered for action request ID: "%s"' % request_id)
162162

163-
resultback, _ , errback = action_handlers
163+
resultback, _, errback = action_handlers
164164
del self._pending_action_requests[request_id]
165165

166-
LOGGER.debug("Received Action result with status: %s", message["status"])
166+
# Handle different message formats for status field
167+
# ROS2 rosbridge may send status at top level or inside values
168+
status = message.get("status")
169+
if status is None and "values" in message:
170+
status = message["values"].get("status")
171+
if status is None:
172+
# Default to UNKNOWN if status is not found
173+
status = ActionGoalStatus.UNKNOWN.value
167174

168-
results = {"status": ActionGoalStatus(message["status"]).name, "values": message["values"]}
175+
LOGGER.debug("Received Action result with status: %s", status)
176+
177+
results = {"status": ActionGoalStatus(status).name, "values": message.get("values", {})}
169178

170179
if "result" in message and message["result"] is False:
171180
if errback:

tests/ros2/test_action_client.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Tests for ROS2 ActionClient message handling."""
2+
3+
from unittest.mock import Mock
4+
5+
from roslibpy.comm.comm import RosBridgeProtocol
6+
from roslibpy.core import ActionGoalStatus, ActionResult
7+
8+
9+
def test_action_result_with_status_at_top_level():
10+
"""Test handling of action results with status at the top level of the message."""
11+
protocol = RosBridgeProtocol()
12+
protocol.factory = Mock()
13+
protocol.send_message = Mock()
14+
15+
result_callback = Mock()
16+
feedback_callback = Mock()
17+
error_callback = Mock()
18+
19+
request_id = "send_action_goal:/test_action:1"
20+
protocol._pending_action_requests[request_id] = (result_callback, feedback_callback, error_callback)
21+
22+
# ROS2 rosbridge message format with status at top level
23+
message = {
24+
"op": "action_result",
25+
"action": "/test_action",
26+
"id": request_id,
27+
"status": 4, # SUCCEEDED
28+
"values": {"result": {"success": True, "message": "Action completed"}},
29+
"result": True,
30+
}
31+
32+
protocol._handle_action_result(message)
33+
34+
assert result_callback.called
35+
result = result_callback.call_args[0][0]
36+
assert isinstance(result, ActionResult)
37+
assert result["status"] == ActionGoalStatus.SUCCEEDED.name
38+
assert result["values"] == message["values"]
39+
40+
41+
def test_action_result_with_status_in_values():
42+
"""Test handling of action results with status inside the values field.
43+
44+
This reproduces the KeyError issue reported in GitHub issue.
45+
"""
46+
protocol = RosBridgeProtocol()
47+
protocol.factory = Mock()
48+
protocol.send_message = Mock()
49+
50+
result_callback = Mock()
51+
feedback_callback = Mock()
52+
error_callback = Mock()
53+
54+
request_id = "send_action_goal:/test_action:2"
55+
protocol._pending_action_requests[request_id] = (result_callback, feedback_callback, error_callback)
56+
57+
# Alternative message format with status inside values
58+
message = {
59+
"op": "action_result",
60+
"action": "/test_action",
61+
"id": request_id,
62+
"values": {
63+
"status": 4, # SUCCEEDED - status is here instead
64+
"result": {"success": True, "message": "Action completed"},
65+
},
66+
"result": True,
67+
}
68+
69+
# This should not raise KeyError
70+
protocol._handle_action_result(message)
71+
72+
assert result_callback.called
73+
result = result_callback.call_args[0][0]
74+
assert isinstance(result, ActionResult)
75+
assert result["status"] == ActionGoalStatus.SUCCEEDED.name
76+
77+
78+
def test_action_result_failure_with_status_at_top_level():
79+
"""Test handling of failed action results."""
80+
protocol = RosBridgeProtocol()
81+
protocol.factory = Mock()
82+
protocol.send_message = Mock()
83+
84+
result_callback = Mock()
85+
feedback_callback = Mock()
86+
error_callback = Mock()
87+
88+
request_id = "send_action_goal:/test_action:3"
89+
protocol._pending_action_requests[request_id] = (result_callback, feedback_callback, error_callback)
90+
91+
message = {
92+
"op": "action_result",
93+
"action": "/test_action",
94+
"id": request_id,
95+
"status": 6, # ABORTED
96+
"values": {"result": {"success": False, "message": "Action failed"}},
97+
"result": False,
98+
}
99+
100+
protocol._handle_action_result(message)
101+
102+
assert error_callback.called
103+
result = error_callback.call_args[0][0]
104+
assert result["status"] == ActionGoalStatus.ABORTED.name
105+
assert result["values"] == message["values"]
106+
107+
108+
def test_action_result_without_status_field():
109+
"""Test handling of action results when status field is missing entirely.
110+
111+
This is a defensive test for cases where neither top-level nor values contain status.
112+
In such cases, we should use a default status.
113+
"""
114+
protocol = RosBridgeProtocol()
115+
protocol.factory = Mock()
116+
protocol.send_message = Mock()
117+
118+
result_callback = Mock()
119+
feedback_callback = Mock()
120+
error_callback = Mock()
121+
122+
request_id = "send_action_goal:/test_action:4"
123+
protocol._pending_action_requests[request_id] = (result_callback, feedback_callback, error_callback)
124+
125+
# Message without status anywhere
126+
message = {
127+
"op": "action_result",
128+
"action": "/test_action",
129+
"id": request_id,
130+
"values": {"result": {"success": True}},
131+
"result": True,
132+
}
133+
134+
# Should not raise KeyError, should use default status
135+
protocol._handle_action_result(message)
136+
137+
assert result_callback.called
138+
result = result_callback.call_args[0][0]
139+
assert isinstance(result, ActionResult)
140+
# Should have some status value (likely UNKNOWN)
141+
assert "status" in result

0 commit comments

Comments
 (0)