Skip to content

Commit 6bbcb51

Browse files
Barry-Xu-2018fujitatomoya
authored andcommitted
Support 'ros2 action echo'
Signed-off-by: Barry Xu <[email protected]>
1 parent 9a0c044 commit 6bbcb51

File tree

5 files changed

+713
-0
lines changed

5 files changed

+713
-0
lines changed

ros2action/ros2action/api/__init__.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
from rclpy.expand_topic_name import expand_topic_name
16+
from rclpy.node import Node
1617
from rclpy.validate_full_topic_name import validate_full_topic_name
1718
from ros2cli.node.strategy import NodeStrategy
1819
from rosidl_runtime_py import get_action_interfaces
@@ -66,6 +67,43 @@ def get_action_names(*, node):
6667
return [n for (n, t) in action_names_and_types]
6768

6869

70+
def get_action_class(node: Node, action_name: str):
71+
"""
72+
Load action type module for the given action.
73+
74+
The action should be running for this function to find the action type.
75+
:param node: The node object of rclpy Node class.
76+
:param action_name: The fully-qualified name of the action.
77+
:return: the action class or None
78+
"""
79+
action_names_and_types = get_action_names_and_types(node=node)
80+
81+
matched_names_and_types = list(filter(lambda x: x[0] == action_name, action_names_and_types))
82+
if len(matched_names_and_types) < 1:
83+
raise RuntimeError(f"Cannot find type for '{action_name}'")
84+
if len(matched_names_and_types) > 1:
85+
raise RuntimeError(f"Unexpectedly saw more than one entry for action '{action_name}'")
86+
87+
# Now check whether there are multiple types associated with this action, which is unsupported
88+
action_name_and_types = matched_names_and_types[0]
89+
90+
types = action_name_and_types[1]
91+
if len(types) < 1:
92+
raise RuntimeError(f"No types associated with '{action_name}'")
93+
if len(types) > 1:
94+
raise RuntimeError(f"More than one type associated with action '{action_name}'")
95+
96+
action_type = types[0]
97+
98+
if action_type is None:
99+
return None
100+
101+
try:
102+
return get_action(action_type)
103+
except (AttributeError, ModuleNotFoundError, ValueError):
104+
raise RuntimeError(f"The action type '{action_type}' is invalid")
105+
106+
69107
def action_name_completer(prefix, parsed_args, **kwargs):
70108
"""Callable returning a list of action names."""
71109
with NodeStrategy(parsed_args) as node:

ros2action/ros2action/verb/echo.py

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
# Copyright 2025 Sony Group Corporation.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from collections import OrderedDict
16+
from enum import Enum
17+
import queue
18+
import sys
19+
import threading
20+
21+
import rclpy
22+
from rclpy.qos import qos_profile_action_status_default
23+
from rclpy.qos import qos_profile_services_default
24+
from rclpy.qos import QoSProfile
25+
from rclpy.subscription import Subscription
26+
from rclpy.type_support import EventMessage
27+
from rclpy.type_support import MsgT
28+
29+
from ros2action.api import action_name_completer
30+
from ros2action.api import action_type_completer
31+
from ros2action.api import get_action_class
32+
from ros2action.verb import VerbExtension
33+
34+
from ros2cli.helpers import unsigned_int
35+
from ros2cli.node.strategy import NodeStrategy
36+
37+
from rosidl_runtime_py import message_to_csv
38+
from rosidl_runtime_py import message_to_ordereddict
39+
from rosidl_runtime_py.utilities import get_action
40+
41+
from service_msgs.msg import ServiceEventInfo
42+
43+
import yaml
44+
45+
DEFAULT_TRUNCATE_LENGTH = 128
46+
47+
48+
class ActionInterfaces(Enum):
49+
GOAL_SERVICE = 'GOAL_SERVICE'
50+
CANCEL_SERVICE = 'CANCEL_SERVICE'
51+
RESULT_SERVICE = 'RESULT_SERVICE'
52+
FEEDBACK_TOPIC = 'FEEDBACK_TOPIC'
53+
STATUS_TOPIC = 'STATUS_TOPIC'
54+
55+
56+
class EchoVerb(VerbExtension):
57+
"""Echo a action."""
58+
59+
# Custom representer for getting clean YAML output that preserves the order in an OrderedDict.
60+
# Inspired by: http://stackoverflow.com/a/16782282/7169408
61+
def __represent_ordereddict(self, dumper, data):
62+
items = []
63+
for k, v in data.items():
64+
items.append((dumper.represent_data(k), dumper.represent_data(v)))
65+
return yaml.nodes.MappingNode(u'tag:yaml.org,2002:map', items)
66+
67+
def __init__(self):
68+
self._event_number_to_name = {}
69+
for k, v in ServiceEventInfo._Metaclass_ServiceEventInfo__constants.items():
70+
self._event_number_to_name[v] = k
71+
72+
yaml.add_representer(OrderedDict, self.__represent_ordereddict)
73+
74+
def add_arguments(self, parser, cli_name):
75+
arg = parser.add_argument(
76+
'action_name',
77+
help="Name of the ROS action to echo (e.g. '/fibonacci')")
78+
arg.completer = action_name_completer
79+
arg = parser.add_argument(
80+
'action_type', nargs='?',
81+
help="Type of the ROS action (e.g. 'example_interfaces/action/Fibonacci')")
82+
arg.completer = action_type_completer
83+
parser.add_argument(
84+
'--interfaces', '-i', type=str, metavar='interfaces_name_list',
85+
help='Specify a action interfaces list are output. "|" is used as a delimiter for '
86+
'multiple action interfaces. Action interfaces include "GOAL_SERVICE", '
87+
'"CANCEL_SERVICE", "RESULT_SERVICE", "FEEDBACK_TOPIC" and "STATUS_TOPIC". '
88+
'If this option is not set, output messages from all interfaces of the action.')
89+
parser.add_argument(
90+
'--queue-size', '-q', type=unsigned_int, default=100,
91+
help='The length of output message queue. The default is 100.')
92+
parser.add_argument(
93+
'--csv', action='store_true', default=False,
94+
help=(
95+
'Output all recursive fields separated by commas (e.g. for plotting).'
96+
))
97+
parser.add_argument(
98+
'--full-length', '-f', action='store_true',
99+
help='Output all elements for arrays, bytes, and string with a '
100+
"length > '--truncate-length', by default they are truncated "
101+
"after '--truncate-length' elements with '...''")
102+
parser.add_argument(
103+
'--truncate-length', '-l', type=unsigned_int, default=DEFAULT_TRUNCATE_LENGTH,
104+
help='The length to truncate arrays, bytes, and string to '
105+
'(default: %d)' % DEFAULT_TRUNCATE_LENGTH)
106+
parser.add_argument(
107+
'--no-arr', action='store_true', help="Don't print array fields of messages")
108+
parser.add_argument(
109+
'--no-str', action='store_true', help="Don't print string fields of messages")
110+
parser.add_argument(
111+
'--flow-style', action='store_true',
112+
help='Print collections in the block style (not available with csv format)')
113+
114+
def main(self, *, args):
115+
output_interface_list = set()
116+
if args.interfaces is not None:
117+
if args.interfaces: # Not empty string
118+
# Fill in the user-specified action interface.
119+
interfaces = args.interfaces.split('|')
120+
for interface in interfaces:
121+
if interface.strip().upper() in ActionInterfaces:
122+
output_interface_list.add(interface.strip().upper())
123+
else:
124+
return f'"{interface.strip()}" is incorrect interface name.'
125+
else:
126+
return 'Input action interface name is empty.'
127+
else:
128+
# By default, output all the action interfaces.
129+
output_interface_list.update([interface.value for interface in ActionInterfaces])
130+
131+
if args.action_type is None:
132+
with NodeStrategy(args) as node:
133+
try:
134+
action_module = get_action_class(
135+
node, args.action_name)
136+
except (AttributeError, ModuleNotFoundError, ValueError):
137+
raise RuntimeError(f"The action name '{args.action_name}' is invalid")
138+
else:
139+
try:
140+
action_module = get_action(args.action_type)
141+
except (AttributeError, ModuleNotFoundError, ValueError):
142+
raise RuntimeError(f"The service type '{args.action_type}' is invalid")
143+
144+
if action_module is None:
145+
raise RuntimeError('Could not load the type for the passed action')
146+
147+
self.csv = args.csv
148+
self.truncate_length = args.truncate_length if not args.full_length else None
149+
self.flow_style = args.flow_style
150+
self.no_arr = args.no_arr
151+
self.no_str = args.no_str
152+
153+
send_goal_event_topic = args.action_name + '/_action/send_goal/_service_event'
154+
send_goal_event_msg_type = action_module.Impl.SendGoalService.Event
155+
156+
cancel_goal_event_topic = args.action_name + '/_action/cancel_goal/_service_event'
157+
cancel_goal_event_msg_type = action_module.Impl.CancelGoalService.Event
158+
159+
get_result_event_topic = args.action_name + '/_action/get_result/_service_event'
160+
get_result_event_msg_type = action_module.Impl.GetResultService.Event
161+
162+
feedback_topic = args.action_name + '/_action/feedback'
163+
feedback_topic_type = action_module.Impl.FeedbackMessage
164+
165+
status_topic = args.action_name + '/_action/status'
166+
status_topic_type = action_module.Impl.GoalStatusMessage
167+
168+
# Queue for messages from above topic
169+
self.output_msg_queue = queue.Queue(args.queue_size)
170+
171+
run_thread = True
172+
# Create a thread to output message from output_queue
173+
174+
def message_handler():
175+
while run_thread:
176+
try:
177+
message = self.output_msg_queue.get(block=True, timeout=0.5)
178+
self.output_msg_queue.task_done()
179+
except queue.Empty:
180+
continue
181+
print(message)
182+
output_msg_thread = threading.Thread(target=message_handler)
183+
output_msg_thread.start()
184+
185+
with NodeStrategy(args) as node:
186+
send_goal_event_sub = None
187+
if ActionInterfaces.GOAL_SERVICE.value in output_interface_list:
188+
send_goal_event_sub: Subscription[EventMessage] = node.create_subscription(
189+
send_goal_event_msg_type,
190+
send_goal_event_topic,
191+
self._send_goal_subscriber_callback,
192+
qos_profile_services_default)
193+
194+
cancel_goal_event_sub = None
195+
if ActionInterfaces.CANCEL_SERVICE.value in output_interface_list:
196+
cancel_goal_event_sub: Subscription[EventMessage] = node.create_subscription(
197+
cancel_goal_event_msg_type,
198+
cancel_goal_event_topic,
199+
self._cancel_goal_subscriber_callback,
200+
qos_profile_services_default)
201+
202+
get_result_event_sub = None
203+
if ActionInterfaces.RESULT_SERVICE.value in output_interface_list:
204+
get_result_event_sub: Subscription[EventMessage] = node.create_subscription(
205+
get_result_event_msg_type,
206+
get_result_event_topic,
207+
self._get_result_subscriber_callback,
208+
qos_profile_services_default)
209+
210+
feedback_sub = None
211+
if ActionInterfaces.FEEDBACK_TOPIC.value in output_interface_list:
212+
feedback_sub: Subscription[MsgT] = node.create_subscription(
213+
feedback_topic_type,
214+
feedback_topic,
215+
self._feedback_subscriber_callback,
216+
QoSProfile(depth=10)) # QoS setting refers to action client implementation
217+
218+
status_sub = None
219+
if ActionInterfaces.STATUS_TOPIC.value in output_interface_list:
220+
status_sub: Subscription[MsgT] = node.create_subscription(
221+
status_topic_type,
222+
status_topic,
223+
self._status_subscriber_callback,
224+
qos_profile_action_status_default)
225+
226+
executor: rclpy.Executor = rclpy.get_global_executor()
227+
try:
228+
executor.add_node(node)
229+
while executor.context.ok():
230+
executor.spin_once()
231+
except KeyboardInterrupt:
232+
pass
233+
finally:
234+
executor.remove_node(node)
235+
236+
if send_goal_event_sub:
237+
send_goal_event_sub.destroy()
238+
if cancel_goal_event_sub:
239+
cancel_goal_event_sub.destroy()
240+
if get_result_event_sub:
241+
get_result_event_sub.destroy()
242+
if feedback_sub:
243+
feedback_sub.destroy()
244+
if status_sub:
245+
status_sub.destroy()
246+
247+
run_thread = False
248+
if output_msg_thread.is_alive():
249+
output_msg_thread.join(1)
250+
251+
def _send_goal_subscriber_callback(self, msg):
252+
self._base_subscriber_callback(msg, ActionInterfaces.GOAL_SERVICE.value)
253+
254+
def _cancel_goal_subscriber_callback(self, msg):
255+
self._base_subscriber_callback(msg, ActionInterfaces.CANCEL_SERVICE.value)
256+
257+
def _get_result_subscriber_callback(self, msg):
258+
self._base_subscriber_callback(msg, ActionInterfaces.RESULT_SERVICE.value)
259+
260+
def _feedback_subscriber_callback(self, msg):
261+
self._base_subscriber_callback(msg, ActionInterfaces.FEEDBACK_TOPIC.value)
262+
263+
def _status_subscriber_callback(self, msg):
264+
self._base_subscriber_callback(msg, ActionInterfaces.STATUS_TOPIC.value)
265+
266+
def _base_subscriber_callback(self, msg, interface: str):
267+
to_print = 'interface: ' + interface + '\n'
268+
if self.csv:
269+
to_print += message_to_csv(msg, truncate_length=self.truncate_length,
270+
no_arr=self.no_arr, no_str=self.no_str)
271+
else:
272+
# The "easy" way to print out a representation here is to call message_to_yaml().
273+
# However, the message contains numbers for the event type, but we want to show
274+
# meaningful names to the user. So we call message_to_ordereddict() instead,
275+
# and replace the numbers with meaningful names before dumping to YAML.
276+
msgdict = message_to_ordereddict(msg, truncate_length=self.truncate_length,
277+
no_arr=self.no_arr, no_str=self.no_str)
278+
279+
if 'info' in msgdict:
280+
info = msgdict['info']
281+
if 'event_type' in info:
282+
info['event_type'] = self._event_number_to_name[info['event_type']]
283+
284+
to_print += yaml.dump(msgdict, allow_unicode=True, width=sys.maxsize,
285+
default_flow_style=self.flow_style)
286+
287+
to_print += '---'
288+
try:
289+
self.output_msg_queue.put(to_print, timeout=0.5)
290+
except queue.Full:
291+
print('Output message is full! Please increase the queue size of output message by '
292+
'"--queue_size"')

ros2action/setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
'send_goal = ros2action.verb.send_goal:SendGoalVerb',
4646
'type = ros2action.verb.type:TypeVerb',
4747
'find = ros2action.verb.find:FindVerb',
48+
'echo = ros2action.verb.echo:EchoVerb',
4849
],
4950
}
5051
)

0 commit comments

Comments
 (0)