12
12
# See the License for the specific language governing permissions and
13
13
# limitations under the License.
14
14
15
+ import hashlib
16
+ import os
17
+ import tempfile
15
18
import time
16
19
from typing import Optional
17
20
from typing import TypeVar
18
21
22
+ from prompt_toolkit import prompt
23
+ from prompt_toolkit .formatted_text import HTML
24
+ from prompt_toolkit .key_binding import KeyBindings
25
+ from prompt_toolkit .lexers import PygmentsLexer
26
+ from pygments .lexers .data import YamlLexer
19
27
import rclpy
20
28
from rclpy .node import Node
21
29
from rclpy .qos import QoSDurabilityPolicy
28
36
from ros2topic .api import TopicTypeCompleter
29
37
from ros2topic .verb import VerbExtension
30
38
from rosidl_runtime_py import set_message_fields
39
+ from rosidl_runtime_py .convert import message_to_yaml
31
40
from rosidl_runtime_py .utilities import get_message
32
41
import yaml
33
42
@@ -84,6 +93,9 @@ def add_arguments(self, parser, cli_name):
84
93
parser .add_argument (
85
94
'-p' , '--print' , metavar = 'N' , type = int , default = 1 ,
86
95
help = 'Only print every N-th published message (default: 1)' )
96
+ parser .add_argument (
97
+ '-i' , '--interactive' , action = 'store_true' ,
98
+ help = 'Interactively edit and send the message' )
87
99
group = parser .add_mutually_exclusive_group ()
88
100
group .add_argument (
89
101
'-1' , '--once' , action = 'store_true' ,
@@ -135,7 +147,7 @@ def main(self, *, args):
135
147
return main (args )
136
148
137
149
138
- def main (args ):
150
+ def main (args ) -> Optional [ str ] :
139
151
qos_profile = get_pub_qos_profile ()
140
152
141
153
qos_profile_name = args .qos_profile
@@ -149,12 +161,29 @@ def main(args):
149
161
if args .once :
150
162
times = 1
151
163
164
+ if args .interactive :
165
+ print ('Interactive mode...' )
166
+ # Read last message that was send if it exists in temp
167
+ content = get_last_message_content (args .message_type )
168
+ # Show the tui
169
+ content = show_interactive_tui (
170
+ message_to_yaml (parse_msg (args .message_type , content )),
171
+ message_to_yaml (parse_msg (args .message_type )))
172
+ # Load msg YAML just to be sure it does not fail and we store a broken message
173
+ parse_msg (args .message_type , content )
174
+ # Store the user input so we are able to load it the next time
175
+ store_message_content (args .message_type , content )
176
+ else :
177
+ content = args .values
178
+
179
+ # Parse the yaml string and get a message ofbject of the desired type
180
+ msg = parse_msg (args .message_type , content )
181
+
152
182
with DirectNode (args , node_name = args .node_name ) as node :
153
183
return publisher (
154
184
node .node ,
155
- args .message_type ,
156
185
args .topic_name ,
157
- args . values ,
186
+ msg ,
158
187
1. / args .rate ,
159
188
args .print ,
160
189
times ,
@@ -164,28 +193,147 @@ def main(args):
164
193
args .keep_alive )
165
194
166
195
196
+ def get_history_file (msg_type : str ) -> str :
197
+ """
198
+ Get paths for semi persistent history based on message name.
199
+
200
+ :param msg_type: Name of the message type
201
+ :returns: The path where a history file would be located if it exists
202
+ """
203
+ # Get temporary directory name
204
+ msg_history_cache_folder_path = os .path .join (
205
+ tempfile .gettempdir (), "ros_interactive_msg_cache" )
206
+ # Create temporary history dir if needed
207
+ os .makedirs (msg_history_cache_folder_path , exist_ok = True )
208
+ # Create a file based on the message name
209
+ return os .path .join (
210
+ msg_history_cache_folder_path ,
211
+ f'{ hashlib .sha224 (msg_type .encode ()).hexdigest ()[:20 ]} .yaml' )
212
+
213
+
214
+ def get_last_message_content (msg_type : str ) -> str :
215
+ """
216
+ Retrive the last message of the given type that was send using the tui if it exists.
217
+
218
+ :param msg_type: Name of the message type
219
+ :returns: The YAML representation containing the last message or an empty dict
220
+ """
221
+ content = "{}"
222
+ try :
223
+ history_path = get_history_file (msg_type )
224
+ # Load previous values for that message type
225
+ if os .path .exists (history_path ):
226
+ with open (history_path , 'r' ) as f :
227
+ content = f .read ()
228
+ except OSError :
229
+ print ('Unable load history...' )
230
+ return content
231
+
232
+
233
+ def store_message_content (msg_type : str , content : str ) -> None :
234
+ """
235
+ Store the YAML for the current message in a semi persistent file.
236
+
237
+ :param msg_type: Name of the message type
238
+ :param content: The YAML entered by the user
239
+ """
240
+ try :
241
+ history_path = get_history_file (msg_type )
242
+ # Clear cache
243
+ if os .path .exists (history_path ):
244
+ os .remove (history_path )
245
+ # Store last message in cache
246
+ with open (history_path , 'w' ) as f :
247
+ f .write (content )
248
+ except OSError :
249
+ print ('Unable to store history' )
250
+
251
+
252
+ def show_interactive_tui (msg_str : str , default_msg_str : Optional [str ] = None ) -> str :
253
+ """
254
+ Show a tui to edit a given message yaml.
255
+
256
+ :param msg_str: Mesage yaml string which is initially presented to the user
257
+ :param default_msg_str: Mesage yaml string with default values for the given message
258
+ :return: The mesage yaml string edited by the user
259
+ """
260
+ # Create the bottom bar to pressent the options to the user
261
+ def bottom_toolbar ():
262
+ return HTML (' Continue: <b>alt+enter</b> | Exit: <b>ctrl+c</b> | Reset: <b>ctrl+r</b>' )
263
+
264
+ # Create key bindings for the prompt
265
+ bindings = KeyBindings ()
266
+ if default_msg_str is not None :
267
+ @bindings .add ('c-r' )
268
+ def _ (event ):
269
+ """Reset the promt to the default message."""
270
+ event .app .current_buffer .text = default_msg_str
271
+
272
+ # Show prompt to edit the message before sending it
273
+ return prompt (
274
+ "> " ,
275
+ multiline = True ,
276
+ default = msg_str ,
277
+ lexer = PygmentsLexer (YamlLexer ),
278
+ mouse_support = True ,
279
+ bottom_toolbar = bottom_toolbar ,
280
+ key_bindings = bindings )
281
+
282
+
283
+ def parse_msg (msg_type : str , yaml_values : Optional [str ] = None ) -> MsgType :
284
+ """
285
+ Parse the name and contents of a given message.
286
+
287
+ :param msg_type: Name of the message as a string (e.g. std_msgs/msg/Header)
288
+ :param yaml_values: Contents of the message as a string in YAML layout
289
+ :returns: An constructed instance of the message type
290
+ """
291
+ # Get the message type from the name string
292
+ try :
293
+ msg_module = get_message (msg_type )
294
+ except (AttributeError , ModuleNotFoundError , ValueError ):
295
+ raise RuntimeError ('The passed message type is invalid' )
296
+ # Create a default instance of the message with the given name
297
+ msg = msg_module ()
298
+ # Check if we want to add values to the message
299
+ if yaml_values is not None :
300
+ # Load the user provided fields of the message
301
+ values_dictionary = yaml .safe_load (yaml_values )
302
+ if not isinstance (values_dictionary , dict ):
303
+ raise RuntimeError ('The passed value needs to be a dictionary in YAML format' )
304
+ # Set all fields in the message to the provided values
305
+ try :
306
+ set_message_fields (msg , values_dictionary )
307
+ except Exception as e :
308
+ raise RuntimeError (f'Failed to populate field: { e } ' )
309
+ return msg
310
+
311
+
167
312
def publisher (
168
313
node : Node ,
169
- message_type : MsgType ,
170
314
topic_name : str ,
171
- values : dict ,
315
+ msg : MsgType ,
172
316
period : float ,
173
317
print_nth : int ,
174
318
times : int ,
175
319
wait_matching_subscriptions : int ,
176
320
qos_profile : QoSProfile ,
177
321
keep_alive : float ,
178
- ) -> Optional [str ]:
179
- """Initialize a node with a single publisher and run its publish loop (maybe only once)."""
180
- try :
181
- msg_module = get_message (message_type )
182
- except (AttributeError , ModuleNotFoundError , ValueError ):
183
- raise RuntimeError ('The passed message type is invalid' )
184
- values_dictionary = yaml .safe_load (values )
185
- if not isinstance (values_dictionary , dict ):
186
- return 'The passed value needs to be a dictionary in YAML format'
322
+ ) -> None :
323
+ """
324
+ Initialize a node with a single publisher and run its publish loop (maybe only once).
187
325
188
- pub = node .create_publisher (msg_module , topic_name , qos_profile )
326
+ :param node: The given node used for publishing the given message
327
+ :param topic_name: The topic on which the the message is published
328
+ :param msg: The message that is published
329
+ :param period: Period after which the msg is published again
330
+ :param print_nth: Interval in which the message is printed
331
+ :param times: Number of times the message is published
332
+ :param wait_matching_subscriptions: Wait until there is a certain number of subscribtions
333
+ :param qos_profile: QOS profile
334
+ :param keep_alive: Time the node is kept alive after the message was send
335
+ """
336
+ pub = node .create_publisher (type (msg ), topic_name , qos_profile )
189
337
190
338
times_since_last_log = 0
191
339
while pub .get_subscription_count () < wait_matching_subscriptions :
@@ -196,12 +344,6 @@ def publisher(
196
344
times_since_last_log = (times_since_last_log + 1 ) % 10
197
345
time .sleep (0.1 )
198
346
199
- msg = msg_module ()
200
- try :
201
- set_message_fields (msg , values_dictionary )
202
- except Exception as e :
203
- return 'Failed to populate field: {0}' .format (e )
204
-
205
347
print ('publisher: beginning loop' )
206
348
count = 0
207
349
0 commit comments