From a7883ec6da303e2573b3ebf334089012bfaf0380 Mon Sep 17 00:00:00 2001 From: Florian Vahl Date: Thu, 1 Sep 2022 16:21:07 +0200 Subject: [PATCH 1/3] Add tui (interactive mode) for message creation Signed-off-by: Florian Vahl --- ros2topic/package.xml | 2 + ros2topic/ros2topic/verb/pub.py | 188 ++++++++++++++++++++++++++++---- 2 files changed, 169 insertions(+), 21 deletions(-) diff --git a/ros2topic/package.xml b/ros2topic/package.xml index 6bdd4701d..d570fbc01 100644 --- a/ros2topic/package.xml +++ b/ros2topic/package.xml @@ -20,6 +20,8 @@ ros2cli python3-numpy + python3-prompt-toolkit + python3-pygments python3-yaml rclpy rosidl_runtime_py diff --git a/ros2topic/ros2topic/verb/pub.py b/ros2topic/ros2topic/verb/pub.py index cfb865959..4770e73bb 100644 --- a/ros2topic/ros2topic/verb/pub.py +++ b/ros2topic/ros2topic/verb/pub.py @@ -12,10 +12,18 @@ # See the License for the specific language governing permissions and # limitations under the License. +import hashlib +import os +import tempfile import time from typing import Optional from typing import TypeVar +from prompt_toolkit import prompt +from prompt_toolkit.formatted_text import HTML +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.lexers import PygmentsLexer +from pygments.lexers.data import YamlLexer import rclpy from rclpy.node import Node from rclpy.qos import QoSDurabilityPolicy @@ -29,6 +37,7 @@ from ros2topic.api import TopicTypeCompleter from ros2topic.verb import VerbExtension from rosidl_runtime_py import set_message_fields +from rosidl_runtime_py.convert import message_to_yaml from rosidl_runtime_py.utilities import get_message import yaml @@ -85,6 +94,9 @@ def add_arguments(self, parser, cli_name): parser.add_argument( '-p', '--print', metavar='N', type=int, default=1, help='Only print every N-th published message (default: 1)') + parser.add_argument( + '-i', '--interactive', action='store_true', + help='Interactively edit and send the message') group = parser.add_mutually_exclusive_group() group.add_argument( '-1', '--once', action='store_true', @@ -137,7 +149,7 @@ def main(self, *, args): return main(args) -def main(args): +def main(args) -> Optional[str]: qos_profile = get_pub_qos_profile() qos_profile_name = args.qos_profile @@ -151,12 +163,29 @@ def main(args): if args.once: times = 1 + if args.interactive: + print('Interactive mode...') + # Read last message that was send if it exists in temp + content = get_last_message_content(args.message_type) + # Show the tui + content = show_interactive_tui( + message_to_yaml(parse_msg(args.message_type, content)), + message_to_yaml(parse_msg(args.message_type))) + # Load msg YAML just to be sure it does not fail and we store a broken message + parse_msg(args.message_type, content) + # Store the user input so we are able to load it the next time + store_message_content(args.message_type, content) + else: + content = args.values + + # Parse the yaml string and get a message ofbject of the desired type + msg = parse_msg(args.message_type, content) + with DirectNode(args, node_name=args.node_name) as node: return publisher( node.node, - args.message_type, args.topic_name, - args.values, + msg, 1. / args.rate, args.print, times, @@ -166,28 +195,148 @@ def main(args): args.keep_alive) +def get_history_file(msg_type: str) -> str: + """ + Get paths for semi persistent history based on message name. + + :param msg_type: Name of the message type + :returns: The path where a history file would be located if it exists + """ + # Get temporary directory name + msg_history_cache_folder_path = os.path.join( + tempfile.gettempdir(), "ros_interactive_msg_cache") + # Create temporary history dir if needed + os.makedirs(msg_history_cache_folder_path, exist_ok=True) + # Create a file based on the message name + return os.path.join( + msg_history_cache_folder_path, + f'{hashlib.sha224(msg_type.encode()).hexdigest()[:20]}.yaml') + + +def get_last_message_content(msg_type: str) -> str: + """ + Retrive the last message of the given type that was send using the tui if it exists. + + :param msg_type: Name of the message type + :returns: The YAML representation containing the last message or an empty dict + """ + content = "{}" + try: + history_path = get_history_file(msg_type) + # Load previous values for that message type + if os.path.exists(history_path): + with open(history_path, 'r') as f: + content = f.read() + except OSError: + print('Unable load history...') + return content + + +def store_message_content(msg_type: str, content: str) -> None: + """ + Store the YAML for the current message in a semi persistent file. + + :param msg_type: Name of the message type + :param content: The YAML entered by the user + """ + try: + history_path = get_history_file(msg_type) + # Clear cache + if os.path.exists(history_path): + os.remove(history_path) + # Store last message in cache + with open(history_path, 'w') as f: + f.write(content) + except OSError: + print('Unable to store history') + + +def show_interactive_tui(msg_str: str, default_msg_str: Optional[str] = None) -> str: + """ + Show a tui to edit a given message yaml. + + :param msg_str: Mesage yaml string which is initially presented to the user + :param default_msg_str: Mesage yaml string with default values for the given message + :return: The mesage yaml string edited by the user + """ + # Create the bottom bar to pressent the options to the user + def bottom_toolbar(): + return HTML(' Continue: alt+enter | Exit: ctrl+c | Reset: ctrl+r') + + # Create key bindings for the prompt + bindings = KeyBindings() + if default_msg_str is not None: + @bindings.add('c-r') + def _(event): + """Reset the promt to the default message.""" + event.app.current_buffer.text = default_msg_str + + # Show prompt to edit the message before sending it + return prompt( + "> ", + multiline=True, + default=msg_str, + lexer=PygmentsLexer(YamlLexer), + mouse_support=True, + bottom_toolbar=bottom_toolbar, + key_bindings=bindings) + + +def parse_msg(msg_type: str, yaml_values: Optional[str] = None) -> MsgType: + """ + Parse the name and contents of a given message. + + :param msg_type: Name of the message as a string (e.g. std_msgs/msg/Header) + :param yaml_values: Contents of the message as a string in YAML layout + :returns: An constructed instance of the message type + """ + # Get the message type from the name string + try: + msg_module = get_message(msg_type) + except (AttributeError, ModuleNotFoundError, ValueError): + raise RuntimeError('The passed message type is invalid') + # Create a default instance of the message with the given name + msg = msg_module() + # Check if we want to add values to the message + if yaml_values is not None: + # Load the user provided fields of the message + values_dictionary = yaml.safe_load(yaml_values) + if not isinstance(values_dictionary, dict): + raise RuntimeError('The passed value needs to be a dictionary in YAML format') + # Set all fields in the message to the provided values + try: + set_message_fields( + msg, values_dictionary, expand_header_auto=True, expand_time_now=True) + except Exception as e: + raise RuntimeError('Failed to populate field: {0}'.format(e)) + return msg + + def publisher( node: Node, - message_type: MsgType, topic_name: str, - values: dict, + msg: MsgType, period: float, print_nth: int, times: int, wait_matching_subscriptions: int, qos_profile: QoSProfile, keep_alive: float, -) -> Optional[str]: - """Initialize a node with a single publisher and run its publish loop (maybe only once).""" - try: - msg_module = get_message(message_type) - except (AttributeError, ModuleNotFoundError, ValueError): - raise RuntimeError('The passed message type is invalid') - values_dictionary = yaml.safe_load(values) - if not isinstance(values_dictionary, dict): - return 'The passed value needs to be a dictionary in YAML format' +) -> None: + """ + Initialize a node with a single publisher and run its publish loop (maybe only once). - pub = node.create_publisher(msg_module, topic_name, qos_profile) + :param node: The given node used for publishing the given message + :param topic_name: The topic on which the the message is published + :param msg: The message that is published + :param period: Period after which the msg is published again + :param print_nth: Interval in which the message is printed + :param times: Number of times the message is published + :param wait_matching_subscriptions: Wait until there is a certain number of subscribtions + :param qos_profile: QOS profile + :param keep_alive: Time the node is kept alive after the message was send + """ + pub = node.create_publisher(type(msg), topic_name, qos_profile) times_since_last_log = 0 while pub.get_subscription_count() < wait_matching_subscriptions: @@ -198,12 +347,9 @@ def publisher( times_since_last_log = (times_since_last_log + 1) % 10 time.sleep(0.1) - msg = msg_module() - try: - timestamp_fields = set_message_fields( - msg, values_dictionary, expand_header_auto=True, expand_time_now=True) - except Exception as e: - return 'Failed to populate field: {0}'.format(e) + # TODO(clalancette): This is broken because the msg that is passed in doesn't have timestamp_fields anymore, + # so updating the fields in the message won't work. + print('publisher: beginning loop') count = 0 From 64f6544183e99e15dfa04132dc207ac698ff2626 Mon Sep 17 00:00:00 2001 From: Chris Lalancette Date: Wed, 12 Oct 2022 20:56:33 +0000 Subject: [PATCH 2/3] Fixes so it works again. Signed-off-by: Chris Lalancette --- ros2topic/ros2topic/verb/pub.py | 60 +++++++++++++++------------------ 1 file changed, 28 insertions(+), 32 deletions(-) diff --git a/ros2topic/ros2topic/verb/pub.py b/ros2topic/ros2topic/verb/pub.py index 4770e73bb..99f6a1a05 100644 --- a/ros2topic/ros2topic/verb/pub.py +++ b/ros2topic/ros2topic/verb/pub.py @@ -16,8 +16,7 @@ import os import tempfile import time -from typing import Optional -from typing import TypeVar +from typing import List, Optional, Tuple, TypeVar from prompt_toolkit import prompt from prompt_toolkit.formatted_text import HTML @@ -165,27 +164,26 @@ def main(args) -> Optional[str]: if args.interactive: print('Interactive mode...') - # Read last message that was send if it exists in temp + # Read last message that was sent if it exists in temp content = get_last_message_content(args.message_type) # Show the tui - content = show_interactive_tui( - message_to_yaml(parse_msg(args.message_type, content)), - message_to_yaml(parse_msg(args.message_type))) - # Load msg YAML just to be sure it does not fail and we store a broken message - parse_msg(args.message_type, content) + orig_msg, orig_timestamp_fields = parse_msg(args.message_type, content) + default_msg, default_timestamp_fields = parse_msg(args.message_type) + content = show_interactive_tui(message_to_yaml(orig_msg), message_to_yaml(default_msg)) + # Load msg YAML now to be sure it does not fail and we store a broken message + msg, timestamp_fields = parse_msg(args.message_type, content) # Store the user input so we are able to load it the next time store_message_content(args.message_type, content) else: - content = args.values - - # Parse the yaml string and get a message ofbject of the desired type - msg = parse_msg(args.message_type, content) + # Parse the yaml string and get a message object of the desired type + msg, timestamp_fields = parse_msg(args.message_type, content) with DirectNode(args, node_name=args.node_name) as node: return publisher( node.node, args.topic_name, msg, + timestamp_fields, 1. / args.rate, args.print, times, @@ -215,7 +213,7 @@ def get_history_file(msg_type: str) -> str: def get_last_message_content(msg_type: str) -> str: """ - Retrive the last message of the given type that was send using the tui if it exists. + Retrieve the last message of the given type that was sent using the tui if it exists. :param msg_type: Name of the message type :returns: The YAML representation containing the last message or an empty dict @@ -255,9 +253,9 @@ def show_interactive_tui(msg_str: str, default_msg_str: Optional[str] = None) -> """ Show a tui to edit a given message yaml. - :param msg_str: Mesage yaml string which is initially presented to the user - :param default_msg_str: Mesage yaml string with default values for the given message - :return: The mesage yaml string edited by the user + :param msg_str: Message yaml string which is initially presented to the user + :param default_msg_str: Message yaml string with default values for the given message + :return: The message yaml string edited by the user """ # Create the bottom bar to pressent the options to the user def bottom_toolbar(): @@ -282,7 +280,7 @@ def _(event): key_bindings=bindings) -def parse_msg(msg_type: str, yaml_values: Optional[str] = None) -> MsgType: +def parse_msg(msg_type: str, yaml_values: Optional[str] = None) -> Tuple[MsgType, List]: """ Parse the name and contents of a given message. @@ -297,6 +295,7 @@ def parse_msg(msg_type: str, yaml_values: Optional[str] = None) -> MsgType: raise RuntimeError('The passed message type is invalid') # Create a default instance of the message with the given name msg = msg_module() + timestamp_fields = [] # Check if we want to add values to the message if yaml_values is not None: # Load the user provided fields of the message @@ -305,17 +304,18 @@ def parse_msg(msg_type: str, yaml_values: Optional[str] = None) -> MsgType: raise RuntimeError('The passed value needs to be a dictionary in YAML format') # Set all fields in the message to the provided values try: - set_message_fields( + timestamp_fields = set_message_fields( msg, values_dictionary, expand_header_auto=True, expand_time_now=True) except Exception as e: raise RuntimeError('Failed to populate field: {0}'.format(e)) - return msg + return msg, timestamp_fields def publisher( node: Node, topic_name: str, msg: MsgType, + timestamp_fields: list, period: float, print_nth: int, times: int, @@ -326,30 +326,28 @@ def publisher( """ Initialize a node with a single publisher and run its publish loop (maybe only once). - :param node: The given node used for publishing the given message + :param node: The node used for publishing the given message :param topic_name: The topic on which the the message is published - :param msg: The message that is published + :param msg: The message to be published + :param timestamp_fields: Any timestamp fields that need to be populated :param period: Period after which the msg is published again :param print_nth: Interval in which the message is printed :param times: Number of times the message is published :param wait_matching_subscriptions: Wait until there is a certain number of subscribtions - :param qos_profile: QOS profile - :param keep_alive: Time the node is kept alive after the message was send + :param qos_profile: QoS profile + :param keep_alive: Time the node is kept alive after the message was sent """ pub = node.create_publisher(type(msg), topic_name, qos_profile) times_since_last_log = 0 while pub.get_subscription_count() < wait_matching_subscriptions: - # Print a message reporting we're waiting each 1s, check condition each 100ms. + # Print a message reporting we're waiting 1s, but check the condition every 100ms. if not times_since_last_log: print( f'Waiting for at least {wait_matching_subscriptions} matching subscription(s)...') times_since_last_log = (times_since_last_log + 1) % 10 time.sleep(0.1) - # TODO(clalancette): This is broken because the msg that is passed in doesn't have timestamp_fields anymore, - # so updating the fields in the message won't work. - print('publisher: beginning loop') count = 0 @@ -368,9 +366,7 @@ def timer_callback(): timer = node.create_timer(period, timer_callback) while times == 0 or count < times: rclpy.spin_once(node) - # give some time for the messages to reach the wire before exiting - time.sleep(keep_alive) node.destroy_timer(timer) - else: - # give some time for the messages to reach the wire before exiting - time.sleep(keep_alive) + + # give some time for the messages to reach the wire before exiting + time.sleep(keep_alive) From 9c1f11231aec85987051717600bb90799654b03a Mon Sep 17 00:00:00 2001 From: Chris Lalancette Date: Wed, 12 Oct 2022 21:00:14 +0000 Subject: [PATCH 3/3] Remove the storing of previous content. While it is a nice idea, it adds too much to this feature. Signed-off-by: Chris Lalancette --- ros2topic/ros2topic/verb/pub.py | 90 +++++---------------------------- 1 file changed, 13 insertions(+), 77 deletions(-) diff --git a/ros2topic/ros2topic/verb/pub.py b/ros2topic/ros2topic/verb/pub.py index 99f6a1a05..3b2026385 100644 --- a/ros2topic/ros2topic/verb/pub.py +++ b/ros2topic/ros2topic/verb/pub.py @@ -12,9 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import hashlib -import os -import tempfile import time from typing import List, Optional, Tuple, TypeVar @@ -164,19 +161,14 @@ def main(args) -> Optional[str]: if args.interactive: print('Interactive mode...') - # Read last message that was sent if it exists in temp - content = get_last_message_content(args.message_type) # Show the tui - orig_msg, orig_timestamp_fields = parse_msg(args.message_type, content) - default_msg, default_timestamp_fields = parse_msg(args.message_type) - content = show_interactive_tui(message_to_yaml(orig_msg), message_to_yaml(default_msg)) - # Load msg YAML now to be sure it does not fail and we store a broken message - msg, timestamp_fields = parse_msg(args.message_type, content) - # Store the user input so we are able to load it the next time - store_message_content(args.message_type, content) + default_msg, default_timestamp_fields = parse_msg(args.message_type, args.values) + content = show_interactive_tui(message_to_yaml(default_msg)) else: - # Parse the yaml string and get a message object of the desired type - msg, timestamp_fields = parse_msg(args.message_type, content) + content = args.values + + # Parse the yaml string and get a message object of the desired type + msg, timestamp_fields = parse_msg(args.message_type, content) with DirectNode(args, node_name=args.node_name) as node: return publisher( @@ -193,63 +185,7 @@ def main(args) -> Optional[str]: args.keep_alive) -def get_history_file(msg_type: str) -> str: - """ - Get paths for semi persistent history based on message name. - - :param msg_type: Name of the message type - :returns: The path where a history file would be located if it exists - """ - # Get temporary directory name - msg_history_cache_folder_path = os.path.join( - tempfile.gettempdir(), "ros_interactive_msg_cache") - # Create temporary history dir if needed - os.makedirs(msg_history_cache_folder_path, exist_ok=True) - # Create a file based on the message name - return os.path.join( - msg_history_cache_folder_path, - f'{hashlib.sha224(msg_type.encode()).hexdigest()[:20]}.yaml') - - -def get_last_message_content(msg_type: str) -> str: - """ - Retrieve the last message of the given type that was sent using the tui if it exists. - - :param msg_type: Name of the message type - :returns: The YAML representation containing the last message or an empty dict - """ - content = "{}" - try: - history_path = get_history_file(msg_type) - # Load previous values for that message type - if os.path.exists(history_path): - with open(history_path, 'r') as f: - content = f.read() - except OSError: - print('Unable load history...') - return content - - -def store_message_content(msg_type: str, content: str) -> None: - """ - Store the YAML for the current message in a semi persistent file. - - :param msg_type: Name of the message type - :param content: The YAML entered by the user - """ - try: - history_path = get_history_file(msg_type) - # Clear cache - if os.path.exists(history_path): - os.remove(history_path) - # Store last message in cache - with open(history_path, 'w') as f: - f.write(content) - except OSError: - print('Unable to store history') - - -def show_interactive_tui(msg_str: str, default_msg_str: Optional[str] = None) -> str: +def show_interactive_tui(default_msg_str: str) -> str: """ Show a tui to edit a given message yaml. @@ -263,17 +199,16 @@ def bottom_toolbar(): # Create key bindings for the prompt bindings = KeyBindings() - if default_msg_str is not None: - @bindings.add('c-r') - def _(event): - """Reset the promt to the default message.""" - event.app.current_buffer.text = default_msg_str + @bindings.add('c-r') + def _(event): + """Reset the promt to the default message.""" + event.app.current_buffer.text = default_msg_str # Show prompt to edit the message before sending it return prompt( "> ", multiline=True, - default=msg_str, + default=default_msg_str, lexer=PygmentsLexer(YamlLexer), mouse_support=True, bottom_toolbar=bottom_toolbar, @@ -304,6 +239,7 @@ def parse_msg(msg_type: str, yaml_values: Optional[str] = None) -> Tuple[MsgType raise RuntimeError('The passed value needs to be a dictionary in YAML format') # Set all fields in the message to the provided values try: + # Unfortunately, if you specifi timestamp_fields = set_message_fields( msg, values_dictionary, expand_header_auto=True, expand_time_now=True) except Exception as e: