diff --git a/controller_manager/doc/images/rqt_controller_manager.png b/controller_manager/doc/images/rqt_controller_manager.png new file mode 100644 index 0000000000..01c4f55bdf Binary files /dev/null and b/controller_manager/doc/images/rqt_controller_manager.png differ diff --git a/controller_manager/doc/userdoc.rst b/controller_manager/doc/userdoc.rst index a05f6a3afc..47c19fa907 100644 --- a/controller_manager/doc/userdoc.rst +++ b/controller_manager/doc/userdoc.rst @@ -129,6 +129,21 @@ There are two scripts to interact with controller manager from launch files: -c CONTROLLER_MANAGER, --controller-manager CONTROLLER_MANAGER Name of the controller manager ROS node +rqt_controller_manager +---------------------- +A GUI tool to interact with the controller manager services to be able to switch the lifecycle states of the controllers as well as the hardware components. + +.. image:: images/rqt_controller_manager.png + +It can be launched independently using the following command or as rqt plugin. + +.. code-block:: console + + ros2 run rqt_controller_manager rqt_controller_manager + + * Double-click on a controller or hardware component to show the additional info. + * Right-click on a controller or hardware component to show a context menu with options for lifecycle management. + Using the Controller Manager in a Process ----------------------------------------- diff --git a/rqt_controller_manager/package.xml b/rqt_controller_manager/package.xml index 2e404e9563..a613768269 100644 --- a/rqt_controller_manager/package.xml +++ b/rqt_controller_manager/package.xml @@ -12,11 +12,12 @@ https://github.com/ros-controls/ros2_control/issues https://github.com/ros-controls/ros2_control + Adolfo Rodríguez Tsouroukdissian Bence Magyar + Christoph Froehlich Enrique Fernandez - Mathias Lüdtke Kelsey Hawkins - Adolfo Rodríguez Tsouroukdissian + Mathias Lüdtke controller_manager controller_manager_msgs diff --git a/rqt_controller_manager/resource/controller_manager.ui b/rqt_controller_manager/resource/controller_manager.ui index 2ede975248..17031d9044 100644 --- a/rqt_controller_manager/resource/controller_manager.ui +++ b/rqt_controller_manager/resource/controller_manager.ui @@ -37,7 +37,7 @@ - + 0 @@ -70,6 +70,40 @@ + + + + + 0 + 0 + + + + true + + + QAbstractItemView::NoSelection + + + QAbstractItemView::SelectRows + + + false + + + false + + + false + + + true + + + false + + + diff --git a/rqt_controller_manager/resource/controller_info.ui b/rqt_controller_manager/resource/popup_info.ui similarity index 96% rename from rqt_controller_manager/resource/controller_info.ui rename to rqt_controller_manager/resource/popup_info.ui index b8c03799a9..b910397a8d 100644 --- a/rqt_controller_manager/resource/controller_info.ui +++ b/rqt_controller_manager/resource/popup_info.ui @@ -16,9 +16,6 @@ 0 - - Controller Information - diff --git a/rqt_controller_manager/rqt_controller_manager/controller_manager.py b/rqt_controller_manager/rqt_controller_manager/controller_manager.py index 7ca1f6d8c3..1b20fa549d 100644 --- a/rqt_controller_manager/rqt_controller_manager/controller_manager.py +++ b/rqt_controller_manager/rqt_controller_manager/controller_manager.py @@ -20,12 +20,16 @@ from controller_manager.controller_manager_services import ( configure_controller, list_controllers, + list_hardware_components, + set_hardware_component_state, load_controller, switch_controllers, unload_controller, ) + from controller_manager_msgs.msg import ControllerState from controller_manager_msgs.srv import SwitchController +from lifecycle_msgs.msg import State from python_qt_binding import loadUi from python_qt_binding.QtCore import QAbstractTableModel, Qt, QTimer from python_qt_binding.QtGui import QCursor, QFont, QIcon, QStandardItem, QStandardItemModel @@ -60,7 +64,7 @@ def __init__(self, context): # Pop-up that displays controller information self._popup_widget = QWidget() ui_file = os.path.join( - get_package_share_directory("rqt_controller_manager"), "resource", "controller_info.ui" + get_package_share_directory("rqt_controller_manager"), "resource", "popup_info.ui" ) loadUi(ui_file, self._popup_widget) self._popup_widget.setObjectName("ControllerInfoUi") @@ -78,7 +82,9 @@ def __init__(self, context): # Initialize members self._cm_name = "" # Name of the selected controller manager's node self._controllers = [] # State of each controller - self._table_model = None + self._hw_components = [] # State of each hw component + self._ctrl_table_model = None + self._hw_table_model = None # Store reference to node self._node = context.node @@ -93,16 +99,26 @@ def __init__(self, context): } # Controllers display - table_view = self._widget.table_view - table_view.setContextMenuPolicy(Qt.CustomContextMenu) - table_view.customContextMenuRequested.connect(self._on_ctrl_menu) - - table_view.doubleClicked.connect(self._on_ctrl_info) - - header = table_view.horizontalHeader() - header.setSectionResizeMode(QHeaderView.ResizeToContents) - header.setContextMenuPolicy(Qt.CustomContextMenu) - header.customContextMenuRequested.connect(self._on_header_menu) + ctrl_table_view = self._widget.ctrl_table_view + ctrl_table_view.setContextMenuPolicy(Qt.CustomContextMenu) + ctrl_table_view.customContextMenuRequested.connect(self._on_ctrl_menu) + ctrl_table_view.doubleClicked.connect(self._on_ctrl_info) + + ctrl_header = ctrl_table_view.horizontalHeader() + ctrl_header.setSectionResizeMode(QHeaderView.ResizeToContents) + ctrl_header.setContextMenuPolicy(Qt.CustomContextMenu) + ctrl_header.customContextMenuRequested.connect(self._on_ctrl_header_menu) + + # Hardware components display + hw_table_view = self._widget.hw_table_view + hw_table_view.setContextMenuPolicy(Qt.CustomContextMenu) + hw_table_view.customContextMenuRequested.connect(self._on_hw_menu) + hw_table_view.doubleClicked.connect(self._on_hw_info) + + hw_header = hw_table_view.horizontalHeader() + hw_header.setSectionResizeMode(QHeaderView.ResizeToContents) + hw_header.setContextMenuPolicy(Qt.CustomContextMenu) + hw_header.customContextMenuRequested.connect(self._on_hw_header_menu) # Timer for controller manager updates self._update_cm_list_timer = QTimer(self) @@ -116,6 +132,12 @@ def __init__(self, context): self._update_ctrl_list_timer.timeout.connect(self._update_controllers) self._update_ctrl_list_timer.start() + # Timer for running hw components updates + self._update_hw_components_list_timer = QTimer(self) + self._update_hw_components_list_timer.setInterval(int(1000.0 / self._cm_update_freq)) + self._update_hw_components_list_timer.timeout.connect(self._update_hw_components) + self._update_hw_components_list_timer.start() + # Signal connections w = self._widget w.cm_combo.currentIndexChanged[str].connect(self._on_cm_change) @@ -148,6 +170,7 @@ def _on_cm_change(self, cm_name): if cm_name: self._update_controllers() + self._update_hw_components() def _update_controllers(self): if not self._cm_name: @@ -190,20 +213,20 @@ def _list_controllers(self): return [] def _show_controllers(self): - table_view = self._widget.table_view - self._table_model = ControllerTable(self._controllers, self._icons) - table_view.setModel(self._table_model) + ctrl_table_view = self._widget.ctrl_table_view + self._ctrl_table_model = ControllerTable(self._controllers, self._icons) + ctrl_table_view.setModel(self._ctrl_table_model) def _on_ctrl_menu(self, pos): # Get data of selected controller - row = self._widget.table_view.rowAt(pos.y()) + row = self._widget.ctrl_table_view.rowAt(pos.y()) if row < 0: return # Cursor is not under a valid item ctrl = self._controllers[row] # Show context menu - menu = QMenu(self._widget.table_view) + menu = QMenu(self._widget.ctrl_table_view) if ctrl.state == "active": action_deactivate = menu.addAction(self._icons["inactive"], "Deactivate") action_kill = menu.addAction(self._icons["finalized"], "Deactivate and Unload") @@ -219,7 +242,7 @@ def _on_ctrl_menu(self, pos): action_configure = menu.addAction(self._icons["inactive"], "Load and Configure") action_activate = menu.addAction(self._icons["active"], "Load, Configure and Activate") - action = menu.exec_(self._widget.table_view.mapToGlobal(pos)) + action = menu.exec_(self._widget.ctrl_table_view.mapToGlobal(pos)) # Evaluate user action if ctrl.state == "active": @@ -253,6 +276,7 @@ def _on_ctrl_menu(self, pos): def _on_ctrl_info(self, index): popup = self._popup_widget + popup.setWindowTitle("Controller Information") ctrl = self._controllers[index.row()] popup.ctrl_name.setText(ctrl.name) @@ -271,42 +295,186 @@ def _on_ctrl_info(self, index): popup.move(QCursor.pos()) popup.show() - def _on_header_menu(self, pos): - header = self._widget.table_view.horizontalHeader() + def _on_ctrl_header_menu(self, pos): + ctrl_header = self._widget.ctrl_table_view.horizontalHeader() + + # Show context menu + menu = QMenu(self._widget.ctrl_table_view) + action_toggle_auto_resize = menu.addAction("Toggle Auto-Resize") + action = menu.exec_(ctrl_header.mapToGlobal(pos)) + + # Evaluate user action + if action is action_toggle_auto_resize: + if ctrl_header.resizeMode(0) == QHeaderView.ResizeToContents: + ctrl_header.setSectionResizeMode(QHeaderView.Interactive) + else: + ctrl_header.setSectionResizeMode(QHeaderView.ResizeToContents) + + def _update_hw_components(self): + if not self._cm_name: + return + + # Find hw_components associated to the selected controller manager + hw_components = self._list_hw_components() + + # Update controller display, if necessary + if self._hw_components != hw_components: + self._hw_components = hw_components + self._show_hw_components() # NOTE: Model is recomputed from scratch + + def _list_hw_components(self): + """ + List the hw_components associated to a controller manager node. + + @return List of hw_components associated to a controller manager + node. Contains both stopped/running hw_components, as returned by + the C{list_hardware_components} service + @rtype [str] + """ + # Add loaded hw_components first + try: + hw_components = list_hardware_components( + self._node, self._cm_name, 2.0 / self._cm_update_freq + ).component + return hw_components + except RuntimeError as e: + print(e) + return [] + + def _show_hw_components(self): + hw_table_view = self._widget.hw_table_view + self._hw_table_model = HwComponentTable(self._hw_components, self._icons) + hw_table_view.setModel(self._hw_table_model) + + def _on_hw_menu(self, pos): + # Get data of selected controller + row = self._widget.hw_table_view.rowAt(pos.y()) + if row < 0: + return # Cursor is not under a valid item + + hw_component = self._hw_components[row] + + # Show context menu + menu = QMenu(self._widget.hw_table_view) + if hw_component.state.label == "active": + action_deactivate = menu.addAction(self._icons["inactive"], "Deactivate") + action_cleanup = menu.addAction(self._icons["finalized"], "Deactivate and Cleanup") + elif hw_component.state.label == "inactive": + action_activate = menu.addAction(self._icons["active"], "Activate") + action_cleanup = menu.addAction(self._icons["unconfigured"], "Cleanup") + elif hw_component.state.label == "unconfigured": + action_configure = menu.addAction(self._icons["inactive"], "Configure") + action_spawn = menu.addAction(self._icons["active"], "Configure and Activate") + + action = menu.exec_(self._widget.hw_table_view.mapToGlobal(pos)) + + # Evaluate user action + if hw_component.state.label == "active": + if action is action_deactivate: + self._set_inactive_hw_component(hw_component.name) + elif action is action_cleanup: + self._set_unconfigured_hw_component(hw_component.name) + elif hw_component.state.label == "inactive": + if action is action_activate: + self._set_active_hw_component(hw_component.name) + elif action is action_cleanup: + self._set_unconfigured_hw_component(hw_component.name) + elif hw_component.state.label == "unconfigured": + if action is action_configure: + self._set_inactive_hw_component(hw_component.name) + elif action is action_spawn: + self._set_active_hw_component(hw_component.name) + + def _on_hw_info(self, index): + popup = self._popup_widget + popup.setWindowTitle("Hardware Component Info") + + hw_component = self._hw_components[index.row()] + popup.ctrl_name.setText(hw_component.name) + popup.ctrl_type.setText(hw_component.type) + + res_model = QStandardItemModel() + model_root = QStandardItem("Command Interfaces") + res_model.appendRow(model_root) + for command_interface in hw_component.command_interfaces: + hw_iface_item = QStandardItem(command_interface.name) + model_root.appendRow(hw_iface_item) + + model_root = QStandardItem("State Interfaces") + res_model.appendRow(model_root) + for state_interface in hw_component.state_interfaces: + hw_iface_item = QStandardItem(state_interface.name) + model_root.appendRow(hw_iface_item) + + popup.resource_tree.setModel(res_model) + popup.resource_tree.setItemDelegate(FontDelegate(popup.resource_tree)) + popup.resource_tree.expandAll() + popup.move(QCursor.pos()) + popup.show() + + def _on_hw_header_menu(self, pos): + hw_header = self._widget.hw_table_view.horizontalHeader() # Show context menu - menu = QMenu(self._widget.table_view) + menu = QMenu(self._widget.hw_table_view) action_toggle_auto_resize = menu.addAction("Toggle Auto-Resize") - action = menu.exec_(header.mapToGlobal(pos)) + action = menu.exec_(hw_header.mapToGlobal(pos)) # Evaluate user action if action is action_toggle_auto_resize: - if header.resizeMode(0) == QHeaderView.ResizeToContents: - header.setSectionResizeMode(QHeaderView.Interactive) + if hw_header.resizeMode(0) == QHeaderView.ResizeToContents: + hw_header.setSectionResizeMode(QHeaderView.Interactive) else: - header.setSectionResizeMode(QHeaderView.ResizeToContents) + hw_header.setSectionResizeMode(QHeaderView.ResizeToContents) def _activate_controller(self, name): - switch_controllers( - node=self._node, - controller_manager_name=self._cm_name, - deactivate_controllers=[], - activate_controllers=[name], - strict=SwitchController.Request.STRICT, - activate_asap=False, - timeout=0.3, - ) + self._switch_controllers([name], []) def _deactivate_controller(self, name): - switch_controllers( - node=self._node, - controller_manager_name=self._cm_name, - deactivate_controllers=[name], - activate_controllers=[], - strict=SwitchController.Request.STRICT, - activate_asap=False, - timeout=0.3, - ) + self._switch_controllers([], [name]) + + def _switch_controllers(self, activate, deactivate): + try: + switch_controllers( + node=self._node, + controller_manager_name=self._cm_name, + activate_controllers=activate, + deactivate_controllers=deactivate, + strict=SwitchController.Request.STRICT, + activate_asap=False, + timeout=0.3, + ) + except Exception as e: + print(e) + + def _set_active_hw_component(self, name): + active_state = State() + active_state.id = State.PRIMARY_STATE_ACTIVE + active_state.label = "active" + self._set_state_hw_component(name, active_state) + + def _set_inactive_hw_component(self, name): + inactive_state = State() + inactive_state.id = State.PRIMARY_STATE_INACTIVE + inactive_state.label = "inactive" + self._set_state_hw_component(name, inactive_state) + + def _set_unconfigured_hw_component(self, name): + unconfigure_state = State() + unconfigure_state.id = State.PRIMARY_STATE_UNCONFIGURED + unconfigure_state.label = "unconfigured" + self._set_state_hw_component(name, unconfigure_state) + + def _set_state_hw_component(self, name, state): + try: + set_hardware_component_state( + node=self._node, + controller_manager_name=self._cm_name, + component_name=name, + lifecyle_state=state, + ) + except Exception as e: + print(e) class ControllerTable(QAbstractTableModel): @@ -360,6 +528,57 @@ def data(self, index, role): return Qt.AlignCenter +class HwComponentTable(QAbstractTableModel): + """ + Model containing hardware component information for tabular display. + + The model allows display of basic read-only information like component + name and state. + """ + + def __init__(self, hw_component_info, icons, parent=None): + QAbstractTableModel.__init__(self, parent) + self._data = hw_component_info + self._icons = icons + + def rowCount(self, parent): + return len(self._data) + + def columnCount(self, parent): + return 2 + + def headerData(self, col, orientation, role): + if orientation != Qt.Horizontal or role != Qt.DisplayRole: + return None + if col == 0: + return "component" + elif col == 1: + return "state" + + def data(self, index, role): + if not index.isValid(): + return None + + hw_component = self._data[index.row()] + + if role == Qt.DisplayRole: + if index.column() == 0: + return hw_component.name + elif index.column() == 1: + return hw_component.state.label or "not loaded" + + if role == Qt.DecorationRole and index.column() == 0: + return self._icons.get(hw_component.state.label) + + if role == Qt.FontRole and index.column() == 0: + bf = QFont() + bf.setBold(True) + return bf + + if role == Qt.TextAlignmentRole and index.column() == 1: + return Qt.AlignCenter + + class FontDelegate(QStyledItemDelegate): """ Simple delegate for customizing font weight and italization.