diff --git a/ros2action/ros2action/verb/info.py b/ros2action/ros2action/verb/info.py index ab137a865..6befbed9b 100644 --- a/ros2action/ros2action/verb/info.py +++ b/ros2action/ros2action/verb/info.py @@ -15,7 +15,9 @@ import rclpy from ros2action.api import action_name_completer from ros2action.api import get_action_clients_and_servers +from ros2action.api import get_action_names_and_types from ros2action.verb import VerbExtension +from ros2cli.node.strategy import add_arguments as add_strategy_node_arguments from ros2cli.node.strategy import NodeStrategy @@ -23,16 +25,25 @@ class InfoVerb(VerbExtension): """Print information about an action.""" def add_arguments(self, parser, cli_name): + add_strategy_node_arguments(parser) arg = parser.add_argument( 'action_name', help="Name of the ROS action to get info (e.g. '/fibonacci')") arg.completer = action_name_completer parser.add_argument( '-t', '--show-types', action='store_true', - help='Additionally show the action type') + help='Additionally show the action type next to each listed ' + 'action client and action server') parser.add_argument( '-c', '--count', action='store_true', help='Only display the number of action clients and action servers') + parser.add_argument( + '--verbose', + '-v', + action='store_true', + help='Prints detailed information for each action client and action server of ' + 'this action, including the endpoint information of the underlying goal, ' + 'cancel, and result services and feedback and status topics') def main(self, *, args): with NodeStrategy(args) as node: @@ -44,20 +55,49 @@ def main(self, *, args): except (ValueError, rclpy.exceptions.InvalidTopicNameException) as e: raise RuntimeError(e) - print('Action: {}'.format(args.action_name)) - print('Action clients: {}'.format(len(action_clients))) - if not args.count: - for client_name, client_types in action_clients: - if args.show_types: - types_formatted = ', '.join(client_types) - print(f' {client_name} [{types_formatted}]') + action_names_and_types = get_action_names_and_types(node=node) + action_types = None + for (a_name, a_types) in action_names_and_types: + if a_name == args.action_name: + action_types = a_types + break + + line_end = '\n' + if args.verbose: + line_end = '\n\n' + + print('Action: {}'.format(args.action_name)) + if action_types is not None: + print('Type: %s' % ', '.join(action_types)) + + print('Action clients: {}'.format(len(action_clients))) + if not args.count: + if args.verbose: + try: + for info in node.get_action_clients_info_by_action(args.action_name): + print(info, end=line_end) + except NotImplementedError as e: + return str(e) else: - print(f' {client_name}') - print('Action servers: {}'.format(len(action_servers))) - if not args.count: - for server_name, server_types in action_servers: - if args.show_types: - types_formatted = ', '.join(server_types) - print(f' {server_name} [{types_formatted}]') + for client_name, client_types in action_clients: + if args.show_types: + types_formatted = ', '.join(client_types) + print(f' {client_name} [{types_formatted}]') + else: + print(f' {client_name}') + + print('Action servers: {}'.format(len(action_servers))) + if not args.count: + if args.verbose: + try: + for info in node.get_action_servers_info_by_action(args.action_name): + print(info, end=line_end) + except NotImplementedError as e: + return str(e) else: - print(f' {server_name}') + for server_name, server_types in action_servers: + if args.show_types: + types_formatted = ', '.join(server_types) + print(f' {server_name} [{types_formatted}]') + else: + print(f' {server_name}') diff --git a/ros2action/test/test_cli.py b/ros2action/test/test_cli.py index a44cdd5c3..8de6f416c 100644 --- a/ros2action/test/test_cli.py +++ b/ros2action/test/test_cli.py @@ -172,6 +172,7 @@ def test_fibonacci_info(self): assert launch_testing.tools.expect_output( expected_lines=[ 'Action: /test/fibonacci', + 'Type: test_msgs/action/Fibonacci', 'Action clients: 0', 'Action servers: 1', ' /test/fibonacci_action_server' @@ -189,6 +190,7 @@ def test_fibonacci_info_with_types(self): assert launch_testing.tools.expect_output( expected_lines=[ 'Action: /test/fibonacci', + 'Type: test_msgs/action/Fibonacci', 'Action clients: 0', 'Action servers: 1', ' /test/fibonacci_action_server [test_msgs/action/Fibonacci]' @@ -206,6 +208,7 @@ def test_fibonacci_info_count(self): assert launch_testing.tools.expect_output( expected_lines=[ 'Action: /test/fibonacci', + 'Type: test_msgs/action/Fibonacci', 'Action clients: 0', 'Action servers: 1', ], @@ -213,6 +216,60 @@ def test_fibonacci_info_count(self): strict=False ) + @launch_testing.markers.retry_on_failure(times=5, delay=1) + def test_fibonacci_info_verbose(self): + with self.launch_action_command( + arguments=['info', '-v', '/test/fibonacci']) as action_command: + assert action_command.wait_for_shutdown(timeout=10) + assert action_command.exit_code == launch_testing.asserts.EXIT_OK + assert launch_testing.tools.expect_output( + expected_lines=[ + 'Action: /test/fibonacci', + 'Type: test_msgs/action/Fibonacci', + 'Action clients: 0', + 'Action servers: 1', + 'Node name: fibonacci_action_server', + 'Node namespace: /test', + 'Action type: test_msgs/action/Fibonacci', + 'Endpoint type: SERVER', + 'Goal service:', + ' Service type: test_msgs/action/Fibonacci_SendGoal', + re.compile(r'\s*Service type hash: RIHS01_[a-f0-9]{64}'), + re.compile(r'\s*Endpoint count: [12]'), + re.compile(r'\s*Reliability: RELIABLE'), + 'Cancel service:', + ' Service type: action_msgs/srv/CancelGoal', + 'Result service:', + ' Service type: test_msgs/action/Fibonacci_GetResult', + 'Feedback topic:', + ' Topic type: test_msgs/action/Fibonacci_FeedbackMessage', + 'Status topic:', + ' Topic type: action_msgs/msg/GoalStatusArray', + re.compile(r'\s*Durability: TRANSIENT_LOCAL'), + ], + text=action_command.output, + strict=False + ) + + @launch_testing.markers.retry_on_failure(times=5, delay=1) + def test_fibonacci_info_verbose_count(self): + with self.launch_action_command( + arguments=['info', '-v', '-c', '/test/fibonacci']) as action_command: + assert action_command.wait_for_shutdown(timeout=10) + assert action_command.exit_code == launch_testing.asserts.EXIT_OK + assert launch_testing.tools.expect_output( + expected_lines=[ + 'Action: /test/fibonacci', + 'Type: test_msgs/action/Fibonacci', + 'Action clients: 0', + 'Action servers: 1', + ], + text=action_command.output, + strict=False + ) + # The count option should suppress the detailed endpoint information + assert 'Node name:' not in action_command.output + @launch_testing.markers.retry_on_failure(times=5, delay=1) def test_list(self): with self.launch_action_command(arguments=['list']) as action_command: diff --git a/ros2cli/ros2cli/daemon/__init__.py b/ros2cli/ros2cli/daemon/__init__.py index 07e4a8d47..ffcfda99f 100644 --- a/ros2cli/ros2cli/daemon/__init__.py +++ b/ros2cli/ros2cli/daemon/__init__.py @@ -100,10 +100,14 @@ def serve(server: LocalXMLRPCServer, *, timeout: float = 2 * 60 * 60): node.get_client_names_and_types_by_node, node.get_action_server_names_and_types_by_node, node.get_action_client_names_and_types_by_node, + node.get_action_clients_info_by_action, + node.get_action_servers_info_by_action, node.count_publishers, node.count_subscribers, node.count_clients, - node.count_services + node.count_services, + node.count_action_clients, + node.count_action_servers ] # Dealing with the timeouts in this server is a bit tricky. The caller diff --git a/ros2cli/ros2cli/xmlrpc/marshal/rclpy.py b/ros2cli/ros2cli/xmlrpc/marshal/rclpy.py index 14a9fbdb4..9c067c588 100644 --- a/ros2cli/ros2cli/xmlrpc/marshal/rclpy.py +++ b/ros2cli/ros2cli/xmlrpc/marshal/rclpy.py @@ -78,6 +78,12 @@ def dump_duration(marshaller, value, write): Marshaller.dispatch[rclpy.endpoint_info.ServiceEndpointInfo] = \ functools.partial(dump_any_with_slots, transform=lambda slot: slot.lstrip('_')) +Unmarshaller.dispatch[fullname(rclpy.endpoint_info.ActionEndpointInfo)] = \ + functools.partial(end_any_with_slots, type_=rclpy.endpoint_info.ActionEndpointInfo) + +Marshaller.dispatch[rclpy.endpoint_info.ActionEndpointInfo] = \ + functools.partial(dump_any_with_slots, transform=lambda slot: slot.lstrip('_')) + Unmarshaller.dispatch[fullname(rclpy.endpoint_info.EndpointTypeEnum)] = \ functools.partial(end_any_enum, enum_=rclpy.endpoint_info.EndpointTypeEnum) diff --git a/ros2cli/test/test_ros2cli_daemon.py b/ros2cli/test/test_ros2cli_daemon.py index ffd7158cb..ca7e7d4c9 100644 --- a/ros2cli/test/test_ros2cli_daemon.py +++ b/ros2cli/test/test_ros2cli_daemon.py @@ -304,6 +304,54 @@ def test_get_servers_info_by_service(daemon_node): TEST_SERVICE_SERVER_QOS.reliability +def test_get_action_clients_info_by_action(daemon_node): + clients_info = daemon_node.get_action_clients_info_by_action(TEST_ACTION_NAME) + assert len(clients_info) == 1 + test_client_info = clients_info[0] + assert test_client_info.node_name == TEST_NODE_NAME + assert test_client_info.node_namespace == TEST_NODE_NAMESPACE + assert test_client_info.action_type == TEST_ACTION_TYPE + assert test_client_info.endpoint_type == EndpointTypeEnum.CLIENT + goal_info = test_client_info.goal_service_info + assert goal_info.service_type == TEST_ACTION_TYPE + '_SendGoal' + assert goal_info.endpoint_type == EndpointTypeEnum.CLIENT + assert (goal_info.endpoint_count == 1 or goal_info.endpoint_count == 2) + assert len(goal_info.qos_profiles) == goal_info.endpoint_count + assert len(goal_info.endpoint_gids) == goal_info.endpoint_count + assert test_client_info.cancel_service_info.service_type == 'action_msgs/srv/CancelGoal' + assert test_client_info.result_service_info.service_type == TEST_ACTION_TYPE + '_GetResult' + feedback_info = test_client_info.feedback_topic_info + assert feedback_info.topic_type == TEST_ACTION_TYPE + '_FeedbackMessage' + assert feedback_info.endpoint_type == EndpointTypeEnum.SUBSCRIPTION + status_info = test_client_info.status_topic_info + assert status_info.topic_type == 'action_msgs/msg/GoalStatusArray' + assert status_info.endpoint_type == EndpointTypeEnum.SUBSCRIPTION + + +def test_get_action_servers_info_by_action(daemon_node): + servers_info = daemon_node.get_action_servers_info_by_action(TEST_ACTION_NAME) + assert len(servers_info) == 1 + test_server_info = servers_info[0] + assert test_server_info.node_name == TEST_NODE_NAME + assert test_server_info.node_namespace == TEST_NODE_NAMESPACE + assert test_server_info.action_type == TEST_ACTION_TYPE + assert test_server_info.endpoint_type == EndpointTypeEnum.SERVER + goal_info = test_server_info.goal_service_info + assert goal_info.service_type == TEST_ACTION_TYPE + '_SendGoal' + assert goal_info.endpoint_type == EndpointTypeEnum.SERVER + assert (goal_info.endpoint_count == 1 or goal_info.endpoint_count == 2) + assert len(goal_info.qos_profiles) == goal_info.endpoint_count + assert len(goal_info.endpoint_gids) == goal_info.endpoint_count + assert test_server_info.cancel_service_info.service_type == 'action_msgs/srv/CancelGoal' + assert test_server_info.result_service_info.service_type == TEST_ACTION_TYPE + '_GetResult' + feedback_info = test_server_info.feedback_topic_info + assert feedback_info.topic_type == TEST_ACTION_TYPE + '_FeedbackMessage' + assert feedback_info.endpoint_type == EndpointTypeEnum.PUBLISHER + status_info = test_server_info.status_topic_info + assert status_info.topic_type == 'action_msgs/msg/GoalStatusArray' + assert status_info.endpoint_type == EndpointTypeEnum.PUBLISHER + + def test_count_publishers(daemon_node): assert 1 == daemon_node.count_publishers(TEST_TOPIC_NAME) @@ -320,6 +368,14 @@ def test_count_services(daemon_node): assert 1 == daemon_node.count_services(TEST_SERVICE_NAME) +def test_count_action_clients(daemon_node): + assert 1 == daemon_node.count_action_clients(TEST_ACTION_NAME) + + +def test_count_action_servers(daemon_node): + assert 1 == daemon_node.count_action_servers(TEST_ACTION_NAME) + + def _wait_until(predicate, timeout): # Polling is_daemon_running() uses system.listMethods, which is not a # timer-resetting RPC, so it never keeps the daemon alive artificially.