diff --git a/rqt_topic/models/message.py b/rqt_topic/models/message.py index abb064d..14d2ca1 100644 --- a/rqt_topic/models/message.py +++ b/rqt_topic/models/message.py @@ -2,7 +2,7 @@ from typing import List from python_qt_binding.QtGui import QColor -from pydantic import BaseModel, ConfigDict, field_validator +from pydantic import BaseModel, ConfigDict, validator import re TOPIC_RE = re.compile(r'^(\/([a-zA-Z0-9_]+))+$') @@ -25,12 +25,12 @@ def __str__(self): return "" return str(self.content) - @field_validator('topic') + @validator('topic') def validate_topic(cls, value): assert TOPIC_RE.match(value) is not None, f'Given topic is not valid: {value}' return value - @field_validator('timestamp') + @validator('timestamp') def validate_timestamp(cls, value): return value diff --git a/src/rqt_topic/buttons/__init__.py b/src/rqt_topic/buttons/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/rqt_topic/buttons/clear.py b/src/rqt_topic/buttons/clear.py deleted file mode 100644 index 85342ac..0000000 --- a/src/rqt_topic/buttons/clear.py +++ /dev/null @@ -1,15 +0,0 @@ -from python_qt_binding.QtWidgets import QAction, QStyle - - -class Clear(QAction): - def __init__(self, style, name: str = "Clear All"): - super(Clear, self).__init__(name) - - # Style is provided by the widget that uses this button - self.style = style - - self.clear_icon = self.style.standardIcon(QStyle.SP_DialogResetButton) - - self.setIcon(self.clear_icon) - self.setIconText("Clear All") - self.setStatusTip("Clear all the views") diff --git a/src/rqt_topic/buttons/hide_timestamps.py b/src/rqt_topic/buttons/hide_timestamps.py deleted file mode 100644 index 3af02a9..0000000 --- a/src/rqt_topic/buttons/hide_timestamps.py +++ /dev/null @@ -1,36 +0,0 @@ -from python_qt_binding.QtWidgets import QAction - - -# TODO(evan.flynn): it'd be better to make a generic "hideColumn" feature directly -# in the QAbstractTableModel. This is an acceptable work around for now. -class HideTimestamps(QAction): - def __init__(self, style, name: str = "Hide timestamps"): - super(HideTimestamps, self).__init__(name) - - # Style is provided by the widget that uses this button - self.style = style - - self.setStatusTip("Hide the timestamp columns from all views") - self.triggered.connect(self.toggle_hide) - self._hidden = False - - def is_hidden(self) -> bool: - return self._hidden - - def toggle_hide(self): - if self._hidden: - self.unhide() - else: - self.hide() - - def hide(self): - """Timestamps are hidden.""" - self.setText("Unhide timestamps") - self.setStatusTip("Unhide the timestamp columns from all views") - self._hidden = True - - def unhide(self): - """Button is resumed.""" - self.setText("Hide timestamps") - self.setStatusTip("Hide the timestamp columns from all views") - self._hidden = False diff --git a/src/rqt_topic/buttons/resize_columns.py b/src/rqt_topic/buttons/resize_columns.py deleted file mode 100644 index 28a7e7c..0000000 --- a/src/rqt_topic/buttons/resize_columns.py +++ /dev/null @@ -1,11 +0,0 @@ -from python_qt_binding.QtWidgets import QAction - - -class ResizeColumns(QAction): - def __init__(self, style, name: str = "Resize columns to contents"): - super(ResizeColumns, self).__init__(name) - - # Style is provided by the widget that uses this button - self.style = style - - self.setStatusTip("Resize columns to contents") diff --git a/src/rqt_topic/buttons/toggle_highlight.py b/src/rqt_topic/buttons/toggle_highlight.py deleted file mode 100644 index 65a12c8..0000000 --- a/src/rqt_topic/buttons/toggle_highlight.py +++ /dev/null @@ -1,34 +0,0 @@ -from python_qt_binding.QtWidgets import QAction - - -class ToggleHighlight(QAction): - def __init__(self, style, name: str = "Disable highlighting"): - super(ToggleHighlight, self).__init__(name) - - # Style is provided by the widget that uses this button - self.style = style - - self.setStatusTip("Disable color highlighting for new messages") - self.triggered.connect(self.toggle_highlight) - self._is_highlighting = True - - def is_highlighting(self) -> bool: - return self._is_highlighting - - def toggle_highlight(self): - if self._is_highlighting: - self.no_highlight() - else: - self.highlight() - - def highlight(self): - """Turn color highlighting on.""" - self.setText("Disable highlighting") - self.setStatusTip("Disable color highlighting for new messages") - self._is_highlighting = True - - def no_highlight(self): - """Turn color highlighting off.""" - self.setText("Highlight new messages") - self.setStatusTip("Do not highlight rows for new messages") - self._is_highlighting = False diff --git a/src/rqt_topic/buttons/toggle_pause.py b/src/rqt_topic/buttons/toggle_pause.py deleted file mode 100644 index 4d75f7b..0000000 --- a/src/rqt_topic/buttons/toggle_pause.py +++ /dev/null @@ -1,42 +0,0 @@ -from python_qt_binding.QtWidgets import QAction, QStyle - - -class TogglePause(QAction): - def __init__(self, style, name: str = "Pause"): - super(TogglePause, self).__init__(name) - - # Style is provided by the widget that uses this button - self.style = style - - self.pause_icon = self.style.standardIcon(QStyle.SP_MediaPause) - self.play_icon = self.style.standardIcon(QStyle.SP_MediaPlay) - - self.setIcon(self.pause_icon) - self.setIconText("Pause") - self.setStatusTip("Pause the view") - self._paused = False - - self.triggered.connect(self.toggle_pause) - - def is_paused(self) -> bool: - return self._paused - - def toggle_pause(self): - if self._paused: - self.resume() - else: - self.pause() - - def pause(self): - """Button is paused.""" - self.setIcon(self.play_icon) - self.setText("Resume") - self.setStatusTip("Resume the view") - self._paused = True - - def resume(self): - """Button is resumed.""" - self.setIcon(self.pause_icon) - self.setText("Pause") - self.setStatusTip("Pause the view") - self._paused = False diff --git a/src/rqt_topic/views/__init__.py b/src/rqt_topic/views/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/rqt_topic/views/message_detail.py b/src/rqt_topic/views/message_detail.py deleted file mode 100644 index 1e9c5a5..0000000 --- a/src/rqt_topic/views/message_detail.py +++ /dev/null @@ -1,25 +0,0 @@ -from python_qt_binding.QtCore import Slot -from python_qt_binding.QtWidgets import QTreeView - - -class MessageDetailView(QTreeView): - def __init__(self, parent, model): - super(MessageDetailView, self).__init__(parent=parent) - - self.model = model - self.setModel(model) - - self.model.layoutChanged.connect(self.update_view) - self.model.dataChanged.connect(self.update_data_view) - - self.expandAll() - - @Slot() - def update_view(self): - self.resizeColumnToContents(0) - self.resizeColumnToContents(1) - - @Slot() - def update_data_view(self): - self.resizeColumnToContents(0) - self.resizeColumnToContents(1) diff --git a/src/rqt_topic/views/message_list.py b/src/rqt_topic/views/message_list.py deleted file mode 100644 index ff4e7bc..0000000 --- a/src/rqt_topic/views/message_list.py +++ /dev/null @@ -1,49 +0,0 @@ -from python_qt_binding.QtCore import Slot -from python_qt_binding.QtWidgets import ( - QTableView, -) - - -class MessageListView(QTableView): - def __init__(self, parent, model): - super(MessageListView, self).__init__(parent=parent) - - self.model = model - self.setSortingEnabled(True) - self.setModel(model) - - self.horizontal_header = self.horizontalHeader() - self.horizontal_header.setStretchLastSection(True) - self.vertical_header = self.verticalHeader() - self.vertical_header.setVisible(False) - - self.setSelectionBehavior(QTableView.SelectRows) - - self.scrollToBottom() - - # store this to use later - self.scrollbar = self.verticalScrollBar() - self.scrollbar.rangeChanged.connect(self.resize_scroll_area) - - self.model.dataChanged.connect(self.update_view_data) - self.model.layoutChanged.connect(self.update_list) - - @Slot(int, int) - def resize_scroll_area(self, min_scroll, max_scroll): - self.scrollbar.setValue(max_scroll) - - @Slot() - def update_view_data(self): - # Scroll to the bottom automatically if scroll bar is already at the bottom - if self.scrollbar.value() >= self.scrollbar.maximum(): - self.scrollToBottom() - - @Slot() - def update_list(self): - # TODO(evan.flynn): this slows down the GUI a lot if called every time - # Investigate a smarter way to only call this when it's necessary - # self.resizeColumnsToContents() - - # Scroll to the bottom automatically if scroll bar is already at the bottom - if self.scrollbar.value() >= self.scrollbar.maximum(): - self.scrollToBottom() diff --git a/src/rqt_topic/views/topic_list.py b/src/rqt_topic/views/topic_list.py deleted file mode 100644 index e75e9b4..0000000 --- a/src/rqt_topic/views/topic_list.py +++ /dev/null @@ -1,41 +0,0 @@ -from python_qt_binding.QtCore import Slot -from python_qt_binding.QtWidgets import ( - QTableView, -) - - -class TopicListView(QTableView): - def __init__(self, parent, model): - super(TopicListView, self).__init__(parent=parent) - - self.model = model - self.setSortingEnabled(True) - self.setModel(model) - - self.horizontal_header = self.horizontalHeader() - self.vertical_header = self.verticalHeader() - self.horizontal_header.setStretchLastSection(True) - - self.setSelectionBehavior(QTableView.SelectRows) - self.resizeColumnsToContents() - - self.model.dataChanged.connect(self.update_view_data) - self.model.layoutChanged.connect(self.update_view) - - def topic_is_selected(self, topic: str) -> bool: - """Check if the given topic is currently selected.""" - indexes = self.selectedIndexes() - result = False - if indexes: - result = any( - [True for index in indexes if index.isValid() if index.data() == topic] - ) - return result - - @Slot() - def update_view(self): - self.resizeColumnsToContents() - - @Slot() - def update_view_data(self): - pass diff --git a/src/rqt_topic/workers/__init__.py b/src/rqt_topic/workers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/rqt_topic/workers/topic.py b/src/rqt_topic/workers/topic.py deleted file mode 100644 index b36a63a..0000000 --- a/src/rqt_topic/workers/topic.py +++ /dev/null @@ -1,216 +0,0 @@ -from datetime import datetime -from typing import TypeVar -import threading - -from python_qt_binding.QtCore import QRunnable, Slot, QObject, Signal - -from rclpy.node import Node -import rclpy.serialization -from ros2topic.verb.bw import ROSTopicBandwidth -from ros2topic.verb.hz import ROSTopicHz -from rqt_py_common.message_helpers import get_message_class - -from rqt_topic.models.topic import TopicModel -from rqt_topic.models.message import MessageModel - -MsgType = TypeVar("MsgType") - - -MAX_HZ = 60.0 # Hz -MAX_HZ_AS_SECONDS = 1 / MAX_HZ # seconds - - -class TopicWorkerSignals(QObject): - """ - Plain QObject-derived class to hold the signals used by the QRunnable. - - Signals are only supported for QObject-derived objects. - """ - - # start = Signal(TopicModel) - # stop = Signal(TopicModel) - update_topic = Signal(TopicModel) - update_message = Signal(MessageModel) - - -class TopicWorker(QRunnable): - """ - Meant to handle all work related to fetching data from a single topic. - - Runs in a separate thread than the main GUI to avoid GUI-lock and to - update as soon as new data is available. - - Each worker has one node with one subscription to completely seperate everything. - """ - - def __init__(self, window_id: int, topic: TopicModel, *args, **kwargs): - super(TopicWorker, self).__init__() - - self.window_id = int(window_id) - - # Initialize node - self.node = Node( - f"rqt_topic_worker_node_{self.window_id}_{topic.name.replace('/', '_')}" - ) - self.topic = topic - self.signals = TopicWorkerSignals() - self.subscriber = None - self.ros_topic_hz = ROSTopicHz(self.node, 100) - self.ros_topic_bw = ROSTopicBandwidth(self.node, 100) - self.message_class = get_message_class(topic.message_type) - - # self.signals.start.connect(self.run) - # self.signals.stop.connect(self.stop) - - # Use a MultiThreadedExecutor - self.executor = rclpy.executors.MultiThreadedExecutor() - self.executor.add_node(self.node) - - # To be filled in the `run` method - self.executor_thread = None - - self.last_message_updated_at = datetime.now() - - @Slot() - def run(self): - """ - Create subscriptin to the specified topic. - - Meant to be called in its own seperate thread via QThreadpool. - """ - if self.executor_thread is None or not self.executor_thread.is_alive(): - # Spin node in its own thread - self.executor_thread = threading.Thread( - target=self.executor.spin, daemon=True - ) - self.executor_thread.start() - - self.subscriber = self.node.create_subscription( - self.message_class, self.topic.name, self.impl, qos_profile=10, raw=True - ) - - @Slot() - def stop(self): - """Stop and remove the current subscription.""" - self.node.destroy_subscription(self.subscriber) - self.subscriber = None - self.executor.shutdown() - self.executor_thread = None - - def impl(self, data): - self.topic.timestamp = datetime.now() - - msg = rclpy.serialization.deserialize_message( - data, - self.message_class, - ) - - # Parse msg fields into a simple nested dictionary (converts arrays and sequences - # to strings) - # This avoids passing large arrays / sequences around to other models and views here - self.topic.message = MessageModel( - timestamp=self.topic.timestamp, - topic=self.topic.name, - message_type=self.topic.message_type, - content=self.recursively_parse_message(msg), - ) - - self.ros_topic_hz.callback_hz(msg, self.topic.name) - self.ros_topic_bw.callback(data) - - bw_tuple = self.ros_topic_bw.get_bw() - if bw_tuple: - self.topic.bandwidth.fill( - bw_tuple[0], bw_tuple[1], bw_tuple[2], bw_tuple[3], bw_tuple[4] - ) - - hz_tuple = self.ros_topic_hz.get_hz(self.topic.name) - if hz_tuple: - self.topic.frequency.fill( - hz_tuple[0], - hz_tuple[1], - hz_tuple[2], - hz_tuple[3], - hz_tuple[4], - ) - - if self.topic.frequency.rate == 0.0 or self.topic.frequency.rate > MAX_HZ: - # Throttle updating the GUI since most monitors cannot refresh faster than 60Hz anyways - time_since_last_publish = datetime.now() - self.last_message_updated_at - if time_since_last_publish.total_seconds() >= MAX_HZ_AS_SECONDS: - self.signals.update_topic.emit(self.topic) - self.signals.update_message.emit(self.topic.message) - self.last_message_updated_at = datetime.now() - else: - # If frequence is below limit, refresh GUI at rate that messages are available - self.signals.update_topic.emit(self.topic) - self.signals.update_message.emit(self.topic.message) - - def recursively_parse_message( - self, msg_content: MsgType, content_type_str: str = "" - ): - """ - Parse a given message into a nested dictionary of its fields. - - First call to this function expects a raw rclpy message class that has - the `get_fields_and_field_types` method - """ - contents = {} - if hasattr(msg_content, "get_fields_and_field_types"): - fields_and_types = msg_content.get_fields_and_field_types() - contents = { - field: self.recursively_parse_message( - msg_content=getattr(msg_content, field), content_type_str=type_str - ) - for field, type_str in fields_and_types.items() - } - else: - type_str, array_size = self.extract_array_info(content_type_str) - - if array_size is None: - return msg_content - - if "/" not in type_str and array_size: - # This is a sequence, so just display type instead of data: `sequence` - return content_type_str - - try: - msg_class = get_message_class(type_str)() - except (ValueError, TypeError): - msg_class = None - - if hasattr(msg_class, "__slots__"): - contents = { - index: self.recursively_parse_message( - msg_content=msg_class, - content_type_str=type_str, - ) - for index in range(int(array_size)) - } - return contents - - def extract_array_info(self, type_str): - """ - This converts a given array or sequence type string into a human readable string. - - By doing this we avoid storing large arrays and sequences since this tool is not meant for - that (e.g. image data, pointcloud data, etc.) - """ - array_size = None - if "[" in type_str and type_str[-1] == "]": - type_str, array_size_str = type_str.split("[", 1) - array_size_str = array_size_str[:-1] - if len(array_size_str) > 0: - array_size = int(array_size_str) - else: - array_size = 0 - elif type_str.startswith("sequence<") and type_str.endswith(">"): - type_str_sanitized = type_str[9:-1] - # type str may or may not include bounds - if "," in type_str_sanitized: - type_str, array_size = type_str[9:-1].split(", ") - else: - type_str = type_str_sanitized - array_size = "0" - - return type_str, array_size