Skip to content

Commit 000a9ae

Browse files
committed
Initial action support and unit tests
1 parent 1d1cd6b commit 000a9ae

File tree

11 files changed

+628
-2
lines changed

11 files changed

+628
-2
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from rosbridge_library.capability import Capability
2+
from rosbridge_library.internal import message_conversion, ros_loader
3+
4+
5+
class ActionResult(Capability):
6+
7+
action_result_msg_fields = [
8+
(True, "action", str),
9+
(False, "id", str),
10+
(False, "values", dict),
11+
(True, "result", bool),
12+
]
13+
14+
def __init__(self, protocol):
15+
# Call superclass constructor
16+
Capability.__init__(self, protocol)
17+
18+
# Register the operations that this capability provides
19+
protocol.register_operation("action_result", self.action_result)
20+
21+
def action_result(self, message):
22+
# Typecheck the args
23+
self.basic_type_check(message, self.action_result_msg_fields)
24+
25+
# check for the action
26+
action_name = message["action"]
27+
if action_name in self.protocol.external_action_list:
28+
action_handler = self.protocol.external_action_list[action_name]
29+
# parse the message
30+
goal_id = message["id"]
31+
values = message["values"]
32+
# create a message instance
33+
result = ros_loader.get_action_result_instance(action_handler.action_type)
34+
message_conversion.populate_instance(values, result)
35+
# pass along the result
36+
action_handler.handle_result(goal_id, result)
37+
else:
38+
self.protocol.log(
39+
"error",
40+
f"Action {action_name} has not been advertised via rosbridge.",
41+
)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import fnmatch
2+
3+
import rclpy
4+
from rclpy.action import ActionServer
5+
from rclpy.callback_groups import ReentrantCallbackGroup
6+
from rosbridge_library.capability import Capability
7+
from rosbridge_library.internal import message_conversion
8+
from rosbridge_library.internal.ros_loader import get_action_class
9+
10+
11+
class AdvertisedActionHandler:
12+
13+
id_counter = 1
14+
15+
def __init__(self, action_name, action_type, protocol):
16+
self.goal_futures = {}
17+
self.action_name = action_name
18+
self.action_type = action_type
19+
self.protocol = protocol
20+
# setup the action
21+
self.action_server = ActionServer(
22+
protocol.node_handle,
23+
get_action_class(action_type),
24+
action_name,
25+
self.execute_callback,
26+
callback_group=ReentrantCallbackGroup(), # https://github.com/ros2/rclpy/issues/834#issuecomment-961331870
27+
)
28+
29+
def next_id(self):
30+
id = self.id_counter
31+
self.id_counter += 1
32+
return id
33+
34+
async def execute_callback(self, goal):
35+
# generate a unique ID
36+
goal_id = f"action_goal:{self.action}:{self.next_id()}"
37+
38+
future = rclpy.task.Future()
39+
self.request_futures[goal_id] = future
40+
41+
# build a request to send to the external client
42+
goal_message = {
43+
"op": "send_action_goal",
44+
"id": goal_id,
45+
"action": self.action_name,
46+
"args": message_conversion.extract_values(goal),
47+
}
48+
self.protocol.send(goal_message)
49+
50+
try:
51+
return await future
52+
finally:
53+
del self.goal_futures[goal_id]
54+
55+
def handle_result(self, goal_id, res):
56+
"""
57+
Called by the ActionResult capability to handle an action result from the external client.
58+
"""
59+
if goal_id in self.goal_futures:
60+
self.goal_futures[goal_id].set_result(res)
61+
else:
62+
self.protocol.log("warning", f"Received action result for unrecognized id: {goal_id}")
63+
64+
def graceful_shutdown(self):
65+
"""
66+
Signal the AdvertisedActionHandler to shutdown.
67+
"""
68+
if self.goal_futures:
69+
incomplete_ids = ", ".join(self.goal_futures.keys())
70+
self.protocol.log(
71+
"warning",
72+
f"Action {self.action_name} was unadvertised with an action in progress, "
73+
f"aborting action goals with request IDs {incomplete_ids}",
74+
)
75+
for future_id in self.goal_futures:
76+
future = self.goal_futures[future_id]
77+
future.set_exception(RuntimeError(f"Goal {self.action_name} was unadvertised"))
78+
self.action_server.destroy()
79+
80+
81+
class AdvertiseAction(Capability):
82+
actions_glob = None
83+
84+
advertise_action_msg_fields = [(True, "action", str), (True, "type", str)]
85+
86+
def __init__(self, protocol):
87+
# Call superclass constructor
88+
Capability.__init__(self, protocol)
89+
90+
# Register the operations that this capability provides
91+
protocol.register_operation("advertise_action", self.advertise_action)
92+
93+
def advertise_action(self, message):
94+
# Typecheck the args
95+
self.basic_type_check(message, self.advertise_action_msg_fields)
96+
97+
# parse the incoming message
98+
action_name = message["action"]
99+
100+
if AdvertiseAction.actions_glob is not None and AdvertiseAction.actions_glob:
101+
self.protocol.log(
102+
"debug",
103+
"Action security glob enabled, checking action: " + action_name,
104+
)
105+
match = False
106+
for glob in AdvertiseAction.actions_glob:
107+
if fnmatch.fnmatch(action_name, glob):
108+
self.protocol.log(
109+
"debug",
110+
"Found match with glob " + glob + ", continuing action advertisement...",
111+
)
112+
match = True
113+
break
114+
if not match:
115+
self.protocol.log(
116+
"warn",
117+
"No match found for action, cancelling action advertisement for: "
118+
+ action_name,
119+
)
120+
return
121+
else:
122+
self.protocol.log(
123+
"debug", "No action security glob, not checking action advertisement."
124+
)
125+
126+
# check for an existing entry
127+
if action_name in self.protocol.external_action_list.keys():
128+
self.protocol.log("warn", f"Duplicate action advertised. Overwriting {action_name}.")
129+
self.protocol.external_action_list[action_name].graceful_shutdown()
130+
del self.protocol.external_action_list[action_name]
131+
132+
# setup and store the action information
133+
action_type = message["type"]
134+
action_handler = AdvertisedActionHandler(action_name, action_type, self.protocol)
135+
self.protocol.external_action_list[action_name] = action_handler
136+
self.protocol.log("info", f"Advertised action {action_name}")
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# Software License Agreement (BSD License)
2+
#
3+
# Copyright (c) 2023, PickNik Inc.
4+
# All rights reserved.
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions
8+
# are met:
9+
#
10+
# * Redistributions of source code must retain the above copyright
11+
# notice, this list of conditions and the following disclaimer.
12+
# * Redistributions in binary form must reproduce the above
13+
# copyright notice, this list of conditions and the following
14+
# disclaimer in the documentation and/or other materials provided
15+
# with the distribution.
16+
# * Neither the name of the copyright holder nor the names of its
17+
# contributors may be used to endorse or promote products derived
18+
# from this software without specific prior written permission.
19+
#
20+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
21+
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
22+
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
23+
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
24+
# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
25+
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
26+
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
27+
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
29+
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
30+
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
31+
# POSSIBILITY OF SUCH DAMAGE.
32+
33+
import time
34+
from threading import Thread
35+
36+
import rclpy
37+
from rclpy.action import ActionClient
38+
from rclpy.callback_groups import ReentrantCallbackGroup
39+
from rclpy.expand_topic_name import expand_topic_name
40+
from rosbridge_library.internal.message_conversion import (
41+
extract_values,
42+
populate_instance,
43+
)
44+
from rosbridge_library.internal.ros_loader import (
45+
get_action_class,
46+
get_action_goal_instance,
47+
)
48+
49+
50+
class InvalidActionException(Exception):
51+
def __init__(self, action_name):
52+
Exception.__init__(self, f"Action {action_name} does not exist")
53+
54+
55+
class ActionClientHandler(Thread):
56+
def __init__(self, action, action_type, args, success_callback, error_callback, node_handle):
57+
"""
58+
Create a client handler for the specified action.
59+
Use start() to start in a separate thread or run() to run in this thread.
60+
61+
Keyword arguments:
62+
action -- the name of the action to execute.
63+
args -- arguments to pass to the action. Can be an
64+
ordered list, or a dict of name-value pairs. Anything else will be
65+
treated as though no arguments were provided (which is still valid for
66+
some kinds of actions)
67+
success_callback -- a callback to call with the JSON result of the
68+
service call
69+
error_callback -- a callback to call if an error occurs. The
70+
callback will be passed the exception that caused the failure
71+
node_handle -- a ROS 2 node handle to call services.
72+
"""
73+
Thread.__init__(self)
74+
self.daemon = True
75+
self.action = action
76+
self.action_type = action_type
77+
self.args = args
78+
self.success = success_callback
79+
self.error = error_callback
80+
self.node_handle = node_handle
81+
82+
def run(self):
83+
try:
84+
# Call the service and pass the result to the success handler
85+
self.success(send_goal(self.node_handle, self.action, self.action_type, args=self.args))
86+
except Exception as e:
87+
# On error, just pass the exception to the error handler
88+
self.error(e)
89+
90+
91+
def args_to_action_goal_instance(action, inst, args):
92+
""" "
93+
Populate an action goal instance with the provided args
94+
95+
args can be a dictionary of values, or a list, or None
96+
97+
Propagates any exceptions that may be raised.
98+
"""
99+
msg = {}
100+
if isinstance(args, list):
101+
msg = dict(zip(inst.get_fields_and_field_types().keys(), args))
102+
elif isinstance(args, dict):
103+
msg = args
104+
105+
# Populate the provided instance, propagating any exceptions
106+
populate_instance(msg, inst)
107+
108+
109+
def send_goal(node_handle, action, action_type, args=None, sleep_time=0.001):
110+
# Given the action nam and type, fetch a request instance
111+
action_name = expand_topic_name(action, node_handle.get_name(), node_handle.get_namespace())
112+
action_class = get_action_class(action_type)
113+
inst = get_action_goal_instance(action_type)
114+
115+
# Populate the instance with the provided args
116+
args_to_action_goal_instance(action_name, inst, args)
117+
118+
client = ActionClient(node_handle, action_class, action_name)
119+
send_goal_future = client.send_goal_async(inst)
120+
while rclpy.ok() and not send_goal_future.done():
121+
time.sleep(sleep_time)
122+
goal_handle = send_goal_future.result()
123+
124+
if not goal_handle.accepted:
125+
raise Exception("Action goal was rejected") # TODO: Catch better
126+
127+
result = goal_handle.get_result()
128+
client.destroy()
129+
130+
if result is not None:
131+
# Turn the response into JSON and pass to the callback
132+
json_response = extract_values(result)
133+
else:
134+
raise Exception(result)
135+
136+
return json_response

rosbridge_library/src/rosbridge_library/internal/services.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@
4747

4848

4949
class InvalidServiceException(Exception):
50-
def __init__(self, servicename):
51-
Exception.__init__(self, "Service %s does not exist" % servicename)
50+
def __init__(self, service_name):
51+
Exception.__init__(self, f"Service {service_name} does not exist")
5252

5353

5454
class ServiceCaller(Thread):

rosbridge_library/src/rosbridge_library/protocol.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ class Protocol:
8282
delay_between_messages = 0
8383
# global list of non-ros advertised services
8484
external_service_list = {}
85+
# global list of non-ros advertised actions
86+
external_action_list = {}
8587
# Use only BSON for the whole communication if the server has been started with bson_only_mode:=True
8688
bson_only_mode = False
8789

0 commit comments

Comments
 (0)