33 from __future__
import division
39 import py_trees_msgs.msg
as py_trees_msgs
47 import uuid_msgs.msg
as uuid_msgs
49 from .
import visibility
51 from .dotcode_behaviour
import RosBehaviourTreeDotcodeGenerator
52 from .dynamic_timeline
import DynamicTimeline
53 from .dynamic_timeline_listener
import DynamicTimelineListener
54 from .timeline_listener
import TimelineListener
62 from python_qt_binding
import loadUi
63 from python_qt_binding.QtCore
import QFile, QIODevice, QObject, Qt, Signal, QEvent, Slot
64 from python_qt_binding.QtGui
import QIcon, QImage, QPainter, QKeySequence
65 from python_qt_binding.QtSvg
import QSvgGenerator
67 from python_qt_binding.QtGui
import QFileDialog, QGraphicsView, QGraphicsScene, QWidget, QShortcut
69 from python_qt_binding.QtWidgets
import QFileDialog, QGraphicsView, QGraphicsScene, QWidget, QShortcut
74 _deferred_fit_in_view = Signal()
75 _refresh_view = Signal()
76 _refresh_combo = Signal()
77 _message_changed = Signal()
78 _message_cleared = Signal()
79 _expected_type = py_trees_msgs.BehaviourTree()._type
80 _empty_topic =
"No valid topics available"
81 _unselected_topic =
"Not subscribing"
82 no_roscore_switch =
"--no-roscore"
85 """Event filter for the combo box. Will filter left mouse button presses,
86 calling a signal when they happen
92 :param Signal signal: signal that is emitted when a left mouse button press happens
98 if event.type() == QEvent.MouseButtonPress
and event.button() == Qt.LeftButton:
103 super(RosBehaviourTree, self).
__init__(context)
104 self.setObjectName(
'RosBehaviourTree')
106 parser = argparse.ArgumentParser()
107 RosBehaviourTree.add_arguments(parser,
False)
109 if not hasattr(context,
'argv'):
115 args = context.argv()
118 parsed_args = parser.parse_args(args)
145 rp = rospkg.RosPack()
146 ui_file = os.path.join(rp.get_path(
'rqt_py_trees'),
'resource',
'RosBehaviourTree.ui')
147 loadUi(ui_file, self.
_widget, {
'InteractiveGraphicsView': InteractiveGraphicsView})
148 self.
_widget.setObjectName(
'RosBehaviourTreeUi')
149 if hasattr(context,
'serial_number')
and context.serial_number() > 1:
150 self.
_widget.setWindowTitle(self.
_widget.windowTitle() + (
' (%d)' % context.serial_number()))
153 self.
_scene.setBackgroundBrush(Qt.white)
158 self.
_widget.fit_in_view_push_button.setIcon(QIcon.fromTheme(
'zoom-original'))
161 self.
_widget.load_bag_push_button.setIcon(QIcon.fromTheme(
'document-open'))
163 self.
_widget.load_dot_push_button.setIcon(QIcon.fromTheme(
'document-open'))
165 self.
_widget.save_dot_push_button.setIcon(QIcon.fromTheme(
'document-save-as'))
167 self.
_widget.save_as_svg_push_button.setIcon(QIcon.fromTheme(
'document-save-as'))
169 self.
_widget.save_as_image_push_button.setIcon(QIcon.fromTheme(
'image'))
172 for text
in visibility.combo_to_py_trees:
173 self.
_widget.visibility_level_combo_box.addItem(text)
202 self.
_widget.previous_tool_button.setIcon(QIcon.fromTheme(
'go-previous'))
203 self.
_widget.next_tool_button.pressed.connect(self.
_next)
204 self.
_widget.next_tool_button.setIcon(QIcon.fromTheme(
'go-next'))
206 self.
_widget.first_tool_button.setIcon(QIcon.fromTheme(
'go-first'))
207 self.
_widget.last_tool_button.pressed.connect(self.
_last)
208 self.
_widget.last_tool_button.setIcon(QIcon.fromTheme(
'go-last'))
211 self.
_widget.play_tool_button.pressed.connect(self.
_play)
212 self.
_widget.play_tool_button.setIcon(QIcon.fromTheme(
'media-playback-start'))
213 self.
_widget.stop_tool_button.pressed.connect(self.
_stop)
214 self.
_widget.stop_tool_button.setIcon(QIcon.fromTheme(
'media-playback-stop'))
217 self.
_widget.first_tool_button.pressed.connect(self.
_stop)
218 self.
_widget.previous_tool_button.pressed.connect(self.
_stop)
219 self.
_widget.last_tool_button.pressed.connect(self.
_stop)
220 self.
_widget.next_tool_button.pressed.connect(self.
_stop)
223 next_shortcut_vi = QShortcut(QKeySequence(
"l"), self.
_widget)
224 next_shortcut_vi.activated.connect(self.
_widget.next_tool_button.pressed)
225 previous_shortcut_vi = QShortcut(QKeySequence(
"h"), self.
_widget)
226 previous_shortcut_vi.activated.connect(self.
_widget.previous_tool_button.pressed)
227 first_shortcut_vi = QShortcut(QKeySequence(
"^"), self.
_widget)
228 first_shortcut_vi.activated.connect(self.
_widget.first_tool_button.pressed)
229 last_shortcut_vi = QShortcut(QKeySequence(
"$"), self.
_widget)
230 last_shortcut_vi.activated.connect(self.
_widget.last_tool_button.pressed)
233 next_shortcut_emacs = QShortcut(QKeySequence(
"Ctrl+f"), self.
_widget)
234 next_shortcut_emacs.activated.connect(self.
_widget.next_tool_button.pressed)
235 previous_shortcut_emacs = QShortcut(QKeySequence(
"Ctrl+b"), self.
_widget)
236 previous_shortcut_emacs.activated.connect(self.
_widget.previous_tool_button.pressed)
237 first_shortcut_emacs = QShortcut(QKeySequence(
"Ctrl+a"), self.
_widget)
238 first_shortcut_emacs.activated.connect(self.
_widget.first_tool_button.pressed)
239 last_shortcut_emacs = QShortcut(QKeySequence(
"Ctrl+e"), self.
_widget)
240 last_shortcut_emacs.activated.connect(self.
_widget.last_tool_button.pressed)
257 self.
_set_timeline_buttons(first_snapshot=
False, previous_snapshot=
False, next_snapshot=
False, last_snapshot=
False)
272 context.add_widget(self.
_widget)
275 context.setCentralWidget(self.
_widget)
279 elif parsed_args.latest_bag:
282 bag_dir = parsed_args.bag_dir
or os.getenv(
'ROS_HOME', os.path.expanduser(
'~/.ros')) +
'/behaviour_trees'
288 We match the combobox index to the visibility levels defined in py_trees.common.VisibilityLevel.
295 """Allows for the addition of arguments to the rqt_gui loading method
297 :param bool group: If set to false, this indicates that the function is
298 being called from the rqt_py_trees script as opposed to the inside
299 of rqt_gui.main. We use this to ensure that the same arguments can
300 be passed with and without the --no-roscore argument set. If it is
301 set, the rqt_gui code is bypassed. We need to make sure that all the
302 arguments are displayed with -h.
305 operate_object = parser
307 operate_object = parser.add_argument_group(
'Options for the rqt_py_trees viewer')
309 operate_object.add_argument(
'bag', action=
'store', nargs=
'?', help=
'Load this bag when the viewer starts')
310 operate_object.add_argument(
'-l',
'--latest-bag', action=
'store_true', help=
'Load the latest bag available in the bag directory. Bag files are expected to be under the bag directory in the following structure: year-month-day/behaviour_tree_hour-minute-second.bag. If this structure is not followed, the bag file which was most recently modified is used.')
311 operate_object.add_argument(
'--bag-dir', action=
'store', help=
'Specify the directory in which to look for bag files. The default is $ROS_HOME/behaviour_trees, if $ROS_HOME is set, or ~/.ros/behaviour_trees otherwise.')
312 operate_object.add_argument(
'-m',
'--by-time', action=
'store_true', help=
'The latest bag is defined by the time at which the file was last modified, rather than the date and time specified in the filename.')
313 operate_object.add_argument(RosBehaviourTree.no_roscore_switch, action=
'store_true', help=
'Run the viewer without roscore. It is only possible to view bag files if this is set.')
314 operate_object.add_argument(
'--topic', action=
'store', type=str, default=
None, help=
'Default topic to defer to [default:None]')
317 """Open the latest bag in the given directory
319 :param str bag_dir: the directory in which to look for bags
320 :param bool by_time: if true, the latest bag is the one with the latest
321 modification time, not the latest date-time specified by its filename
324 if not os.path.isdir(bag_dir):
325 rospy.logwarn(
"Requested bag directory {0} is invalid. Latest bag will not be loaded.".format(bag_dir))
329 for root, unused_dirnames, filenames
in os.walk(bag_dir, topdown=
True):
330 files.extend(fnmatch.filter(map(
lambda p: os.path.join(root, p), filenames),
'*.bag'))
333 rospy.logwarn(
"No files with extension .bag found in directory {0}".format(bag_dir))
339 re_str =
'.*\/\d{4}-\d{2}-\d{2}\/behaviour_tree_\d{2}-\d{2}-\d{2}.bag'
340 expr = re.compile(re_str)
341 valid = filter(
lambda f: expr.match(f), files)
348 latest_bag = sorted(valid)[-1]
351 latest_bag = sorted(files, cmp=
lambda x, y: cmp(os.path.getctime(x), os.path.getctime(y)))[-1]
357 Get the message in the list or bag that is being viewed that should be
367 return py_trees_msgs.BehaviourTree()
if msg
is None else msg
370 """Updates the topic that is subscribed to based on changes to the combo box
371 text. If the topic is unchanged, nothing will happnen. Otherwise, the
372 old subscriber will be unregistered, and a new one initialised for the
373 updated topic. If the selected topic corresponds to the unselected
374 topic, the subscriber will be unregistered and a new one will not be
378 selected_topic = self.
_widget.topic_combo_box.currentText()
384 self.
_widget.timeline_graphics_view.setScene(
None)
392 Update the topics displayed in the combo box that the user can use to select
393 which topic they want to listen on for trees, filtered so that only
394 topics with the correct message type are shown.
396 This method is triggered on startup and on user combobox interactions
400 self.
_widget.topic_combo_box.setEnabled(
False)
403 self.
_widget.topic_combo_box.clear()
404 topic_list = rospy.get_published_topics()
407 for topic_path, topic_type
in topic_list:
408 if topic_type == RosBehaviourTree._expected_type:
409 valid_topics.append(topic_path)
414 self.
_widget.topic_combo_box.addItems(valid_topics)
418 self.
_widget.topic_combo_box.addItem(RosBehaviourTree._empty_topic)
432 elif len(valid_topics) == 1:
433 topic = valid_topics[0]
438 topic = valid_topics[0]
443 if topic
is not None:
444 for index
in range(self.
_widget.topic_combo_box.count()):
445 if topic == self.
_widget.topic_combo_box.itemText(index):
446 self.
_widget.topic_combo_box.setCurrentIndex(index)
451 def _set_timeline_buttons(self, first_snapshot=None, previous_snapshot=None, next_snapshot=None, last_snapshot=None):
453 Allows timeline buttons to be enabled and disabled.
455 if first_snapshot
is not None:
456 self.
_widget.first_tool_button.setEnabled(first_snapshot)
457 if previous_snapshot
is not None:
458 self.
_widget.previous_tool_button.setEnabled(previous_snapshot)
459 if next_snapshot
is not None:
460 self.
_widget.next_tool_button.setEnabled(next_snapshot)
461 if last_snapshot
is not None:
462 self.
_widget.last_tool_button.setEnabled(last_snapshot)
466 Start a timer which will automatically call the next function every time its
467 duration is up. Only works if the current message is not the final one.
474 Helper function for the timer so that it can call the next function without
480 """Stop the play timer, if it exists.
487 """Navigate to the first message. Activates the next and last buttons, disables
488 first and previous, and refreshes the view. Also changes the state to be
489 browsing the timeline.
494 self.
_set_timeline_buttons(first_snapshot=
False, previous_snapshot=
False, next_snapshot=
True, last_snapshot=
True)
499 """Navigate to the previous message. Activates the next and last buttons, and
500 refreshes the view. If the current message is the second message, then
501 the first and previous buttons are disabled. Changes the state to be
502 browsing the timeline.
520 """Navigate to the last message. Activates the first and previous buttons,
521 disables next and last, and refreshes the view. The user is no longer
522 browsing the timeline after this is called.
527 self.
_set_timeline_buttons(first_snapshot=
True, previous_snapshot=
True, next_snapshot=
False, last_snapshot=
False)
533 """Navigate to the next message. Activates the first and previous buttons. If
534 the current message is the second from last, disables the next and last
535 buttons, and stops browsing the timeline.
556 instance_settings.set_value(
'auto_fit_graph_check_box_state',
557 self.
_widget.auto_fit_graph_check_box.isChecked())
558 instance_settings.set_value(
'highlight_connections_check_box_state',
559 self.
_widget.highlight_connections_check_box.isChecked())
560 combo_text = self.
_widget.topic_combo_box.currentText()
562 instance_settings.set_value(
'combo_box_subscribed_topic', combo_text)
566 self.
_widget.auto_fit_graph_check_box.setChecked(
567 instance_settings.value(
'auto_fit_graph_check_box_state',
True)
in [
True,
'true'])
568 self.
_widget.highlight_connections_check_box.setChecked(
569 instance_settings.value(
'highlight_connections_check_box_state',
True)
in [
True,
'true'])
571 saved_visibility_level = instance_settings.value(
'visibility_level', 1)
572 except TypeError
as e:
573 self.
_widget.auto_fit_graph_check_box.setChecked(
True)
574 self.
_widget.highlight_connections_check_box.setChecked(
True)
576 saved_visibility_level = 1
577 rospy.logerr(
"Rqt PyTrees: incompatible qt app configuration found, try removing ~/.config/ros.org/rqt_gui.ini")
578 rospy.logerr(
"Rqt PyTrees: %s" % e)
579 self.
_widget.visibility_level_combo_box.setCurrentIndex(visibility.saved_setting_to_combo_index[saved_visibility_level])
585 """Refresh the graph view by regenerating the dotcode from the current message.
594 Get the dotcode for the given message, checking the cache for dotcode that
595 was previously generated, and adding to the cache if it wasn't there.
598 Mostly stolen from rqt_bag.MessageLoaderThread
600 :param py_trees_msgs.BehavoiurTree message
613 for behaviour
in reversed(message.behaviours):
615 if str(behaviour.parent_id) == str(uuid_msgs.UniqueID()):
617 tip_id = behaviour.tip_id
623 for behaviour
in message.behaviours:
624 if str(behaviour.own_id) == str(tip_id):
626 if '"' in behaviour.message:
627 print(
"%s" % termcolor.colored(
'[ERROR] found double quotes in the feedback message [%s]' % behaviour.message,
'red'))
628 behaviour.message = behaviour.message.replace(
'"',
'')
629 print(
"%s" % termcolor.colored(
'[ERROR] stripped to stop from crashing, but do catch the culprit! [%s]' % behaviour.message,
'red'))
631 key = str(message.header.stamp)
638 visible_behaviours = visibility.filter_behaviours_by_visibility_level(message.behaviours, self.
visibility_level)
642 behaviours=visible_behaviours,
643 timestamp=message.header.stamp,
644 force_refresh=force_refresh
667 new_scene = QGraphicsScene()
668 new_scene.setBackgroundBrush(Qt.white)
670 if self.
_widget.highlight_connections_check_box.isChecked():
681 for node_item
in iter(nodes.values()):
682 new_scene.addItem(node_item)
683 for edge_items
in iter(edges.values()):
684 for edge_item
in edge_items:
685 edge_item.add_to_scene(new_scene)
687 new_scene.setSceneRect(new_scene.itemsBoundingRect())
704 if self.
_widget.auto_fit_graph_check_box.isChecked():
708 """Activated when the window is resized. Will re-fit the behaviour tree in the
709 window, and update the size of the timeline scene rectangle so that it
715 self.
_timeline.setSceneRect(0, 0, self.
_widget.timeline_graphics_view.width() - 2, max(self.
_widget.timeline_graphics_view.height() - 2, self.
_timeline._timeline_frame._history_bottom))
718 """Should be called whenever the timeline changes. At the moment this is only
719 used to ensure that the first and previous buttons are correctly
720 disabled when a new message coming in on the timeline pushes the
721 playhead to be at the first message
731 This function should be called when the message being viewed changes. Will
732 change the current message and update the view. Also ensures that the
733 timeline buttons are correctly set for the current position of the
734 playhead on the timeline.
750 This function should be called when the message being viewed was cleared.
751 Currently no situation where this happens?
756 """Decorator for ignoring right click events on mouse press
758 @functools.wraps(func)
760 if event.type() == QEvent.MouseButtonPress
and event.button() == Qt.RightButton:
768 Set the timeline to a dynamic timeline, listening to messages on the topic
769 selected in the combo box.
774 self.
_widget.timeline_graphics_view.mouseReleaseEvent = self.
_timeline.on_mouse_up
775 self.
_widget.timeline_graphics_view.mouseMoveEvent = self.
_timeline.on_mouse_move
776 self.
_widget.timeline_graphics_view.wheelEvent = self.
_timeline.on_mousewheel
780 self.
_widget.timeline_graphics_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
781 self.
_widget.timeline_graphics_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
801 """Set the timeline of this object to a bag timeline, hooking the graphics view
802 into mouse and wheel functions of the timeline.
808 self.
_widget.timeline_graphics_view.mouseReleaseEvent = self.
_timeline.on_mouse_up
809 self.
_widget.timeline_graphics_view.mouseMoveEvent = self.
_timeline.on_mouse_move
810 self.
_widget.timeline_graphics_view.wheelEvent = self.
_timeline.on_mousewheel
814 self.
_widget.timeline_graphics_view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
815 self.
_widget.timeline_graphics_view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
832 """Load a bag from file. If no file name is given, a dialogue will pop up and
833 the user will be asked to select a file. If the bag file selected
834 doesn't have any valid topic, nothing will happen. If there are valid
835 topics, we load the bag and add a timeline for managing it.
838 if file_name
is None:
839 file_name, _ = QFileDialog.getOpenFileName(
841 self.tr(
'Open trees from bag file'),
843 self.tr(
'ROS bag (*.bag)'))
844 if file_name
is None or file_name ==
"":
847 rospy.loginfo(
"Reading bag from {0}".format(file_name))
850 topics = list(bag.get_type_and_topic_info()[1].keys())
852 for i
in range(0, len(bag.get_type_and_topic_info()[1].values())):
853 types.append(list(bag.get_type_and_topic_info()[1].values())[i][0])
856 for ind, tp
in enumerate(types):
857 if tp ==
'py_trees_msgs/BehaviourTree':
858 tree_topics.append(topics[ind])
860 if len(tree_topics) == 0:
861 rospy.logerr(
'Requested bag did not contain any valid topics.')
866 rospy.loginfo(
'Reading behaviour trees from topic {0}'.format(tree_topics[0]))
867 for unused_topic, msg, unused_t
in bag.read_messages(topics=[tree_topics[0]]):
871 self.
_set_timeline_buttons(first_snapshot=
True, previous_snapshot=
True, next_snapshot=
False, last_snapshot=
False)
876 if file_name
is None:
877 file_name, _ = QFileDialog.getOpenFileName(
879 self.tr(
'Open tree from DOT file'),
881 self.tr(
'DOT graph (*.dot)'))
882 if file_name
is None or file_name ==
'':
886 fhandle = open(file_name,
'rb')
887 dotcode = fhandle.read()
894 self.
_widget.graphics_view.fitInView(self.
_scene.itemsBoundingRect(),
898 file_name, _ = QFileDialog.getSaveFileName(self.
_widget,
899 self.tr(
'Save as DOT'),
901 self.tr(
'DOT graph (*.dot)'))
902 if file_name
is None or file_name ==
'':
905 dot_file = QFile(file_name)
906 if not dot_file.open(QIODevice.WriteOnly | QIODevice.Text):
913 file_name, _ = QFileDialog.getSaveFileName(
915 self.tr(
'Save as SVG'),
917 self.tr(
'Scalable Vector Graphic (*.svg)'))
918 if file_name
is None or file_name ==
'':
921 generator = QSvgGenerator()
922 generator.setFileName(file_name)
923 generator.setSize((self.
_scene.sceneRect().size() * 2.0).toSize())
925 painter = QPainter(generator)
926 painter.setRenderHint(QPainter.Antialiasing)
927 self.
_scene.render(painter)
931 file_name, _ = QFileDialog.getSaveFileName(
933 self.tr(
'Save as image'),
935 self.tr(
'Image (*.bmp *.jpg *.png *.tiff)'))
936 if file_name
is None or file_name ==
'':
939 img = QImage((self.
_scene.sceneRect().size() * 2.0).toSize(),
940 QImage.Format_ARGB32_Premultiplied)
941 painter = QPainter(img)
942 painter.setRenderHint(QPainter.Antialiasing)
943 self.
_scene.render(painter)