From 5276b9c83d3c06bd9d628eb8440d8b74cc8b2491 Mon Sep 17 00:00:00 2001 From: "Ilan.G" Date: Tue, 29 Jul 2025 09:59:00 +0300 Subject: [PATCH 1/3] 1. Increment the version to 1.2.29 2. Add Load/Store buttons to store/load the table logs --- dronecan_gui_tool/version.py | 2 +- dronecan_gui_tool/widgets/__init__.py | 222 +++++++++++++++++++++++++- 2 files changed, 222 insertions(+), 2 deletions(-) diff --git a/dronecan_gui_tool/version.py b/dronecan_gui_tool/version.py index c29544a..0cf73d8 100644 --- a/dronecan_gui_tool/version.py +++ b/dronecan_gui_tool/version.py @@ -8,7 +8,7 @@ # Andrew Tridgell # # -__version__ = 1, 2, 28 +__version__ = 1, 2, 29 diff --git a/dronecan_gui_tool/widgets/__init__.py b/dronecan_gui_tool/widgets/__init__.py index d93bcd9..9fbabbf 100644 --- a/dronecan_gui_tool/widgets/__init__.py +++ b/dronecan_gui_tool/widgets/__init__.py @@ -11,7 +11,7 @@ import pkg_resources import queue from PyQt5.QtWidgets import QTableWidget, QTableWidgetItem, QAbstractItemView, QHeaderView, QApplication, QWidget, \ - QComboBox, QCompleter, QPushButton, QHBoxLayout, QVBoxLayout, QMessageBox + QComboBox, QCompleter, QPushButton, QHBoxLayout, QVBoxLayout, QMessageBox, QFileDialog from PyQt5.QtCore import Qt, QTimer, QStringListModel from PyQt5.QtGui import QColor, QKeySequence, QFont, QFontInfo, QIcon from logging import getLogger @@ -178,6 +178,145 @@ def search(self, direction, matcher): if current_row < 0: current_row = self.rowCount() - 1 + def store_log(self, filename): + # Get column names dynamically from the table's actual columns + column_names = [col.name for col in self.columns] + + with open(filename, 'w', encoding='utf-8') as f: + # Write XML header + f.write('\n') + f.write('\n') + + for row in range(self.rowCount()): + f.write(' \n') + + for col, column_name in enumerate(column_names): + # Get the table item + item = self.item(row, col) + field_value = item.text() if item else '' + + # Get background color if it exists + background_color = None + if item and item.background().color().isValid(): + color = item.background().color() + + # Check if this is a meaningful color (not default background) + # Skip colors that are: + # - Transparent (alpha = 0) + # - Pure black (#000000) - likely default text color being misread + # - Very light colors (> 245 on all RGB) - likely default/alternating row colors + # - Pure white (#ffffff) - default background + is_meaningful_color = ( + color.alpha() > 0 and + not (color.red() == 0 and color.green() == 0 and color.blue() == 0) and # Not black + not (color.red() == 255 and color.green() == 255 and color.blue() == 255) and # Not white + not (color.red() > 245 and color.green() > 245 and color.blue() > 245) # Not very light gray + ) + + if is_meaningful_color: + background_color = color.name() # Returns hex format like #ff0000 + + # Convert column name to XML-safe element name for comparison + element_name = column_name.lower().replace(" ", "_").replace("-", "_") + + # For data_hex and data_ascii fields, preserve original formatting (including newlines) + # For other fields, normalize whitespace + if element_name in ['data_hex', 'data_ascii']: + # Keep original formatting but still escape XML characters + processed_value = field_value + else: + # Normalize whitespace - replace newlines and multiple spaces with single spaces + processed_value = ' '.join(field_value.split()) + + # Escape XML special characters + processed_value = processed_value.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''') + + # Write the element with optional color attribute + if background_color: + f.write(f' <{element_name} color="{background_color}">{processed_value}\n') + else: + f.write(f' <{element_name}>{processed_value}\n') + + f.write(' \n') + + f.write('\n') + + def load_log(self, filename): + import xml.etree.ElementTree as ET + + try: + # Parse the XML file + tree = ET.parse(filename) + root = tree.getroot() + + # Verify it's a dronecan_log file + if root.tag != 'dronecan_log': + raise ValueError("Not a valid DroneCAN log file - root element should be 'dronecan_log'") + + # Get column names from the table + column_names = [col.name for col in self.columns] + + # Clear existing data + self.setRowCount(0) + + # Process each frame + for frame in root.findall('frame'): + row_data = [] + color_data = [] + + # For each column, try to find the corresponding XML element + for col in self.columns: + element_name = col.name.lower().replace(" ", "_").replace("-", "_") + element = frame.find(element_name) + + if element is not None and element.text is not None: + # Unescape XML characters + value = element.text.replace('&', '&').replace('<', '<').replace('>', '>').replace('"', '"').replace(''', "'") + + # For data_hex and data_ascii, preserve any formatting (including newlines) + # For other fields, the value is already processed + row_data.append(value) + + # Extract color attribute if present + color_attr = element.get('color') + if color_attr: + try: + color = QColor(color_attr) + color_data.append(color if color.isValid() else None) + except: + color_data.append(None) + else: + color_data.append(None) + else: + row_data.append('') # Empty string for missing data + color_data.append(None) + + # Add the row to the table + if row_data: # Only add if we have some data + row_index = self.rowCount() + self.insertRow(row_index) + + for col_index, (value, color) in enumerate(zip(row_data, color_data)): + if col_index < len(self.columns): + item = QTableWidgetItem(str(value)) + item.setTextAlignment(Qt.AlignVCenter | Qt.AlignLeft) + item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + + # Set background color if available + if color is not None: + item.setBackground(color) + + self.setItem(row_index, col_index, item) + + return True + + except ET.ParseError as e: + logger.error(f"XML parsing error: {e}") + return False + except Exception as e: + logger.error(f"Error loading log file: {e}") + return False + def set_filter(self, matcher): self.filter = matcher self.setUpdatesEnabled(False) @@ -339,6 +478,23 @@ def _do_search(self, direction): if result is None: flash(self, 'Nothing found', duration=10) +class StoreLogsButton(QPushButton): + def __init__(self, parent, on_clicked=None): + super(StoreLogsButton, self).__init__(parent) + self.setIcon(get_icon('fa6s.floppy-disk')) + self.setToolTip('Store logs') + self.setFocusPolicy(Qt.NoFocus) + if on_clicked: + self.clicked.connect(on_clicked) + +class LoadLogsButton(QPushButton): + def __init__(self, parent, on_clicked=None): + super(LoadLogsButton, self).__init__(parent) + self.setIcon(get_icon('fa6s.folder-open')) + self.setToolTip('Load logs') + self.setFocusPolicy(Qt.NoFocus) + if on_clicked: + self.clicked.connect(on_clicked) class FilterBar(QWidget): class Filter(QWidget): @@ -481,6 +637,15 @@ def __init__(self, parent, started_by_default=False, pre_redraw_hook=None, **tab self._filter_bar = FilterBar(self) self._filter_bar.on_filter = self._table.set_filter + self._store_button = StoreLogsButton(self) + self._store_button.clicked.connect(self._on_store_button_clicked) + + self._load_button = LoadLogsButton(self) + self._load_button.clicked.connect(self._on_load_button_clicked) + + # Track the last used filename for the store dialog + self._last_store_filename = None + self._row_count = LabelWithIcon(get_icon('fa6s.list'), '0', self) self._row_count.setToolTip('Row count') @@ -499,6 +664,8 @@ def __init__(self, parent, started_by_default=False, pre_redraw_hook=None, **tab controls_layout.addWidget(self._clear_button) controls_layout.addWidget(self._search_bar.show_search_bar_button) controls_layout.addWidget(self._filter_bar.add_filter_button) + controls_layout.addWidget(self._store_button) + controls_layout.addWidget(self._load_button) self._custom_area_layout = QHBoxLayout(self) self._custom_area_layout.setContentsMargins(0, 0, 0, 0) @@ -514,6 +681,59 @@ def __init__(self, parent, started_by_default=False, pre_redraw_hook=None, **tab layout.setContentsMargins(0, 0, 0, 0) self.setLayout(layout) + def _on_store_button_clicked(self): + # Determine the default filename + if self._last_store_filename: + default_filename = self._last_store_filename + else: + default_filename = 'dronecan_log.xml' + + # Open the save file dialog + filename, _ = QFileDialog.getSaveFileName( + self, + 'Save DroneCAN Log', + default_filename, + 'XML files (*.xml);;All files (*.*)' + ) + + # If a file was selected, store it for next time + if filename: + self._last_store_filename = filename + # Store the data in the table to the file + self._table.store_log(filename) + flash(self, 'Log saved to: %s', filename, duration=5) + + def _on_load_button_clicked(self): + # Open the load file dialog + filename, _ = QFileDialog.getOpenFileName( + self, + 'Load DroneCAN Log', + '', + 'XML files (*.xml);;All files (*.*)' + ) + + # If a file was selected, load it + if filename: + # Pause updates while loading + was_paused = self.paused + self._pause.setChecked(True) + + # Load the data + success = self._table.load_log(filename) + + if success: + flash(self, 'Log loaded from: %s (%d rows)', filename, self._table.rowCount(), duration=5) + # Update the row count display + self._row_count.setText(str(self._table.rowCount())) + else: + flash(self, 'Failed to load log from: %s', filename, duration=10) + show_error('Load Error', 'Failed to load the selected log file.', + 'Please check that the file is a valid DroneCAN XML log file.', self) + + # Restore pause state if it wasn't paused before + if not was_paused: + self._pause.setChecked(False) + def keyPressEvent(self, qkeyevent): super(RealtimeLogWidget, self).keyPressEvent(qkeyevent) if qkeyevent.matches(QKeySequence.Find): From 79d9891bf62d4da4152815d2158617727a3d54b9 Mon Sep 17 00:00:00 2001 From: "Ilan.G" Date: Tue, 29 Jul 2025 10:00:43 +0300 Subject: [PATCH 2/3] 1. Add .vscode configurations --- .vscode/launch.json | 37 +++++++++++++++++++++++++++++++++++++ .vscode/tasks.json | 17 +++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..6caa157 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,37 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug DroneCAN GUI Tool", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/bin/dronecan_gui_tool", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "python": "${workspaceFolder}/.venv/Scripts/python.exe", + "args": [ + "--debug" + ], + "justMyCode": true, + "env": { + "PYTHONPATH": "${workspaceFolder}" + } + }, + { + "name": "Debug DroneCAN GUI Tool (Module)", + "type": "debugpy", + "request": "launch", + "module": "dronecan_gui_tool.main", + "console": "integratedTerminal", + "cwd": "${workspaceFolder}", + "python": "${workspaceFolder}/.venv/Scripts/python.exe", + "args": [ + "--debug" + ], + "justMyCode": true, + "env": { + "PYTHONPATH": "${workspaceFolder}" + } + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..7509ac4 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run DroneCAN GUI Tool", + "type": "shell", + "command": "python", + "args": [ + "bin/dronecan_gui_tool", + "--debug" + ], + "group": "build", + "isBackground": false, + "problemMatcher": [] + } + ] +} \ No newline at end of file From 36002f5ec07711846c8c85b064b7f969e606196c Mon Sep 17 00:00:00 2001 From: "Ilan.G" Date: Wed, 6 Aug 2025 11:28:02 +0300 Subject: [PATCH 3/3] 1. Remove .vscode from git 2. Update .gitignore file to ignore virtual environments and vscode files 3. Restore the previous version to 1.2.28 --- .gitignore | 11 +++++++++++ .vscode/launch.json | 37 ------------------------------------ .vscode/tasks.json | 17 ----------------- dronecan_gui_tool/version.py | 2 +- 4 files changed, 12 insertions(+), 55 deletions(-) delete mode 100644 .vscode/launch.json delete mode 100644 .vscode/tasks.json diff --git a/.gitignore b/.gitignore index a720064..a5a1761 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,17 @@ var/ .installed.cfg *.egg +# Virtual environments +.venv/ +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# VS Code +.vscode/ + # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 6caa157..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Debug DroneCAN GUI Tool", - "type": "debugpy", - "request": "launch", - "program": "${workspaceFolder}/bin/dronecan_gui_tool", - "console": "integratedTerminal", - "cwd": "${workspaceFolder}", - "python": "${workspaceFolder}/.venv/Scripts/python.exe", - "args": [ - "--debug" - ], - "justMyCode": true, - "env": { - "PYTHONPATH": "${workspaceFolder}" - } - }, - { - "name": "Debug DroneCAN GUI Tool (Module)", - "type": "debugpy", - "request": "launch", - "module": "dronecan_gui_tool.main", - "console": "integratedTerminal", - "cwd": "${workspaceFolder}", - "python": "${workspaceFolder}/.venv/Scripts/python.exe", - "args": [ - "--debug" - ], - "justMyCode": true, - "env": { - "PYTHONPATH": "${workspaceFolder}" - } - } - ] -} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 7509ac4..0000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "Run DroneCAN GUI Tool", - "type": "shell", - "command": "python", - "args": [ - "bin/dronecan_gui_tool", - "--debug" - ], - "group": "build", - "isBackground": false, - "problemMatcher": [] - } - ] -} \ No newline at end of file diff --git a/dronecan_gui_tool/version.py b/dronecan_gui_tool/version.py index 0cf73d8..c29544a 100644 --- a/dronecan_gui_tool/version.py +++ b/dronecan_gui_tool/version.py @@ -8,7 +8,7 @@ # Andrew Tridgell # # -__version__ = 1, 2, 29 +__version__ = 1, 2, 28