Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
181 lines
28 KiB
Python
181 lines
28 KiB
Python
"""Общая консоль Qt: ограниченная история и инструменты просмотра без управления транспортом.""" # Модуль относится к порту представления templates и не реализует протоколы устройств.
|
||
from collections import deque # Кольцевая история ограничивает память при непрерывном обмене.
|
||
from html import escape # Текст устройства не должен становиться HTML-разметкой журнала.
|
||
from pathlib import Path # Экспорт сохраняет явно выбранный пользователем файл в UTF-8.
|
||
import csv # Стандартный CSV корректно экранирует кавычки, разделители и пустые строки журнала.
|
||
import json # JSON сохраняет строки с кириллицей без потери символов и дополнительного разбора протоколов.
|
||
import re # Метки направления извлекаются только из начала строки после необязательного времени.
|
||
try: # Поддерживаем актуальный Qt и существующие установки на Python для Windows 7.
|
||
from PySide6.QtCore import QTimer, Signal # Таймер объединяет обновления заполненного журнала.
|
||
from PySide6.QtGui import QFont, QTextDocument # Документ извлекает обычный текст из уже оформленных сообщений приложения.
|
||
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget # Панель использует только стандартные элементы Qt.
|
||
except ImportError: # Старый порт сохраняет тот же публичный интерфейс консоли.
|
||
from PySide2.QtCore import QTimer, Signal # В Qt 5 доступны те же сигналы и таймеры.
|
||
from PySide2.QtGui import QFont, QTextDocument # Цвета и извлечение текста не зависят от версии Qt.
|
||
from PySide2.QtWidgets import QApplication, QCheckBox, QComboBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget # Состав инструментов одинаков в обоих портах.
|
||
|
||
|
||
class ConsoleView(QPlainTextEdit): # Наследование сохраняет привычные appendPlainText, appendHtml и toPlainText у потребителей.
|
||
userCleared = Signal() # Владелец отдельной истории может удалить её при явном нажатии «Очистить».
|
||
|
||
def __init__(self, parent=None, limit=4000, directions=True, payloads=False): # Предел задан в строках, включая скрытые и поступившие на паузе.
|
||
super().__init__(parent) # Владельца редактора впоследствии принимает контейнер panel.
|
||
self.records = deque(maxlen=max(1, int(limit))) # Каждая запись хранит текст, оформление, направление и необязательные байты.
|
||
self.setReadOnly(True) # Журнал показывает полученные события и не допускает ручной подмены текста.
|
||
self.setFont(QFont("Cascadia Mono", 9)) # Моноширинный шрифт выравнивает HEX, адреса и метки времени.
|
||
super().setMaximumBlockCount(self.records.maxlen) # Документ ограничен тем же количеством строк, что история.
|
||
self.timer = QTimer(self) # Таймер уничтожается вместе с редактором и не переживает закрытие вкладки.
|
||
self.timer.setSingleShot(True) # За одну пачку входящих строк достаточно одного полного обновления.
|
||
self.timer.setInterval(100) # Интервал 100 мс ограничивает перестроения десятью в секунду при полном буфере.
|
||
self.timer.timeout.connect(self.refresh) # Перестроение всегда читает последние записи и текущие фильтры.
|
||
self.panel = QWidget(parent) # Контейнер можно вставить в layout или вкладку вместо самого редактора.
|
||
layout = QVBoxLayout(self.panel) # Две компактные строки инструментов остаются над областью текста.
|
||
layout.setContentsMargins(0, 0, 0, 0) # Внешние отступы задаёт приложение-потребитель.
|
||
actions = QHBoxLayout() # Основные операции сгруппированы отдельно от фильтров просмотра.
|
||
self.clear_button = QPushButton("Очистить") # Очистка удаляет также скрытую историю и накопленное на паузе.
|
||
self.clear_button.clicked.connect(self._user_clear) # Сигнал владельцу позволяет очистить дополнительный буфер кадров.
|
||
actions.addWidget(self.clear_button) # Очистка постоянно доступна у левого края консоли.
|
||
self.pause = QCheckBox("Пауза") # Пауза замораживает экран, продолжая сохранять входящие сообщения.
|
||
actions.addWidget(self.pause) # Пауза относится только к этому экземпляру журнала.
|
||
self.follow = QCheckBox("Автопрокрутка") # Прокрутку можно отключить для чтения старых записей.
|
||
self.follow.setChecked(True) # По умолчанию новые события видны сразу.
|
||
actions.addWidget(self.follow) # Управление расположено рядом с паузой просмотра.
|
||
for title, callback in (("Копировать", self.copy_visible), ("Сохранить…", self.save_dialog), ("−", lambda: self.zoomOut(1)), ("+", lambda: self.zoomIn(1))): # Масштаб меняется на один шаг Qt без изменения данных.
|
||
button = QPushButton(title) # Кнопки используют тему и шрифт приложения.
|
||
button.clicked.connect(callback) # Обработчик вызывается только по явному действию пользователя.
|
||
actions.addWidget(button) # Одинаковый порядок действий используется всеми консолями.
|
||
actions.addStretch(1) # Свободное место не растягивает подписи инструментов.
|
||
layout.addLayout(actions) # Основная строка отделена от параметров отображения.
|
||
filters = QHBoxLayout() # Поиск и направления занимают вторую строку для умеренной минимальной ширины.
|
||
self.search = QLineEdit() # Фильтр работает по обычному тексту всех сохранённых строк.
|
||
self.search.setPlaceholderText("Поиск / фильтр текста") # Подпись показывает, что скрытые строки остаются в истории.
|
||
filters.addWidget(self.search, 1) # Поле поиска получает свободную ширину консоли.
|
||
self.show_tx, self.show_rx = QCheckBox("TX", self.panel), QCheckBox("RX", self.panel) # Родитель назначается до setVisible, чтобы фильтры направлений не появлялись отдельными окнами при сборке консоли.
|
||
for control in (self.show_tx, self.show_rx): # Оба фильтра действуют независимо и применяются к истории.
|
||
control.setChecked(True) # Первое открытие показывает весь обмен.
|
||
control.setVisible(directions) # Потребители со своими фильтрами могут скрыть дублирующие элементы.
|
||
filters.addWidget(control) # Направления расположены рядом с текстовым фильтром.
|
||
self.display = QComboBox(self.panel) # Родитель исключает отдельное окно при setVisible(payloads); формат относится только к сообщениям с явно переданными байтами.
|
||
self.display.addItems(("Как получено", "HEX", "ASCII", "HEX + ASCII")) # Обычный текст и ответы устройства не перекодируются.
|
||
self.display.setToolTip("HEX/ASCII применяется к байтам, переданным через append_message(payload=…).") # Приложение явно определяет границы полезной нагрузки.
|
||
self.display.setVisible(payloads) # Выбор байтового представления показывается только потребителю с явным payload.
|
||
filters.addWidget(self.display) # Представление можно менять без повторной передачи данных.
|
||
self.wrap = QCheckBox("Перенос") # Перенос длинных строк удобен на узких панелях.
|
||
filters.addWidget(self.wrap) # Переключатель не изменяет исходный текст при экспорте.
|
||
self.count = QLabel() # Счётчик показывает объём ограниченной истории, включая скрытые строки.
|
||
filters.addWidget(self.count) # Количество остаётся видно при включённом фильтре.
|
||
layout.addLayout(filters) # Параметры находятся непосредственно над текстом.
|
||
layout.addWidget(self, 1) # Редактор занимает оставшуюся высоту контейнера.
|
||
for control in (self.pause, self.show_tx, self.show_rx, self.follow): # Изменения параметров применяются к уже накопленным данным.
|
||
control.toggled.connect(self.refresh) # Снятие паузы показывает сохранённые во время неё события.
|
||
self.search.textChanged.connect(self.refresh) # Поиск немедленно обновляет видимую часть истории.
|
||
self.display.currentIndexChanged.connect(self.refresh) # Выбор HEX/ASCII заново форматирует сохранённый payload.
|
||
self.wrap.toggled.connect(lambda checked: self.setLineWrapMode(QPlainTextEdit.WidgetWidth if checked else QPlainTextEdit.NoWrap)) # Отключённый перенос сохраняет горизонтальную прокрутку.
|
||
self.setLineWrapMode(QPlainTextEdit.NoWrap) # Начальный режим совпадает с выключенным флажком переноса.
|
||
self.refresh() # Пустой журнал сразу получает корректный счётчик.
|
||
|
||
def setMaximumBlockCount(self, limit): # Сохраняем совместимость с существующими настройками журналов SETGUI.
|
||
self.records = deque(self.records, maxlen=max(1, int(limit))) # Предел ограничивает и скрытую историю, а не только документ.
|
||
super().setMaximumBlockCount(self.records.maxlen) # Согласуем вместимость редактора с кольцевым буфером.
|
||
self.refresh() # Уменьшение лимита сразу удаляет вытесненные строки с экрана.
|
||
|
||
def append_message(self, text, direction="", color=None, payload=None, _html=None, _defer=False): # Принимаем готовый текст и данные, не выполняя разбор протокола.
|
||
for line in str(text).split("\n"): # Многострочные расшифровки также подчиняются общему лимиту строк.
|
||
match = re.match(r"^(?:\[[^\]]+\]\s*|\d\d:\d\d:\d\d(?:\.\d+)?\s*)?(TX|RX|>>>)\s", line) # Направление распознаётся только у начальной метки, а не по словам внутри ошибки.
|
||
role = direction or (("TX" if match[1] == ">>>" else match[1]) if match else "") # Явная роль имеет приоритет над текстовой эвристикой.
|
||
html = _html or ('<span style="color:%s;white-space:pre">%s</span>' % (escape(color, quote=True), escape(line)) if color else "") # Безопасное оформление сохраняет исходный текст устройства.
|
||
record = (line, html, role, bytes(payload) if payload is not None else None) # Снимок payload не зависит от последующих изменений входного bytearray.
|
||
expired = len(self.records) == self.records.maxlen # Вытеснение требует убрать старую строку даже при скрытых новых событиях.
|
||
self.records.append(record) # На паузе и под фильтрами данные продолжают сохраняться в пределах лимита.
|
||
if not self.pause.isChecked() and not _defer: # Пауза и пакетная замена замораживают документ, но не историю.
|
||
if expired or self.timer.isActive(): # Заполненный буфер обновляется пачками для устойчивого интенсивного приёма.
|
||
if not self.timer.isActive(): self.timer.start() # Следующие события не отодвигают уже запланированное обновление.
|
||
elif self._visible(record): self._append_record(record) # Обычный поток дописывает одну строку без переразбора всей истории.
|
||
self.count.setText("%d строк" % len(self.records)) # Счётчик истории обновляется даже при замороженном экране.
|
||
|
||
def appendPlainText(self, text): # Старые потребители могут продолжать писать обычный текст.
|
||
self.append_message(text) # Ограничения, пауза и фильтры применяются через единый путь записи.
|
||
|
||
def appendHtml(self, html): # Прежнее цветное оформление приложения остаётся доступно через API QPlainTextEdit.
|
||
document = QTextDocument() # Временный документ преобразует форматированный фрагмент в строки для поиска.
|
||
document.setHtml(html) # Разметка поступает от приложения; внешние данные оно должно экранировать.
|
||
text = document.toPlainText() # Обычный текст используется для поиска и определения направления.
|
||
self.append_message(text, _html=html if "\n" not in text else None) # Однострочное оформление сохраняется без полного обновления истории.
|
||
|
||
def setPlainText(self, text): # Снимок внешней истории полностью заменяет данные этого представления.
|
||
self.timer.stop() # Старое отложенное обновление не должно вернуть предыдущий снимок.
|
||
self.records.clear() # Сохраняется только актуальный результат форматирования потребителя.
|
||
if text: self.append_message(text, _defer=True) # Пустой снимок не создаёт фиктивную запись.
|
||
self.refresh() # Поиск и фильтры применяются к новому снимку целиком.
|
||
|
||
def clear(self): # Программная очистка удаляет все данные и отменяет ожидающее обновление.
|
||
self.timer.stop() # Запланированное обновление больше не требуется.
|
||
self.records.clear() # Скрытые записи и накопленное на паузе не должны вернуться.
|
||
super().clear() # Явная очистка действует сразу даже при замороженном экране.
|
||
self.count.setText("0 строк") # Счётчик согласован с удалённой историей.
|
||
|
||
def _user_clear(self): # Только действие оператора уведомляет владельца внешней истории.
|
||
self.clear() # Сначала очищаем собственный буфер и видимый документ.
|
||
self.userCleared.emit() # Потребитель может безопасно вызвать clear повторно без рекурсивного сигнала.
|
||
|
||
def _visible(self, record): # Единое условие применяется при добавлении и полном обновлении.
|
||
text, _html, role, _payload = record # Поиск использует исходный текст сообщения независимо от выбранного формата байтов.
|
||
return (role != "TX" or self.show_tx.isChecked()) and (role != "RX" or self.show_rx.isChecked()) and self.search.text().casefold() in text.casefold() # Служебные сообщения сохраняются при отключённых направлениях.
|
||
|
||
def _append_record(self, record): # Добавляем одну подготовленную запись, учитывая формат и положение прокрутки.
|
||
text, html, _role, payload = record # Байты присутствуют только при явной передаче payload приложением.
|
||
bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Запоминаем позицию до изменения размера документа.
|
||
horizontal, offset = self.horizontalScrollBar(), self.horizontalScrollBar().value() # Автопрокрутка по вертикали не должна сбрасывать просмотр длинной строки по горизонтали.
|
||
mode = self.display.currentIndex() # Нулевой режим сохраняет текст и цвета, заданные потребителем.
|
||
if payload is not None and mode: # Формат меняет только подписанную приложением полезную нагрузку.
|
||
hexadecimal = payload.hex(" ").upper() # HEX показывает каждый исходный байт двумя цифрами.
|
||
ascii_text = "".join(chr(value) if 32 <= value <= 126 else "." for value in payload) # Диапазон печатного ASCII задан стандартом; управляющие байты заменяются точками.
|
||
super().appendPlainText(text + " · " + (hexadecimal if mode == 1 else ascii_text if mode == 2 else hexadecimal + " | " + ascii_text)) # Исходная подпись не теряется при смене представления байтов.
|
||
elif html: super().appendHtml(html) # Сохраняем безопасное оформление или прежний фрагмент приложения.
|
||
else: super().appendPlainText(text) # Обычный текст не интерпретируется как HTML.
|
||
bar.setValue(bar.maximum() if self.follow.isChecked() else position) # Отключённая автопрокрутка сохраняет место чтения.
|
||
horizontal.setValue(offset) # Дописывание строки сохраняет выбранный горизонтальный участок журнала.
|
||
|
||
def refresh(self, *_args): # Сигналы Qt могут передавать индекс, флаг или текст.
|
||
self.timer.stop() # Немедленное обновление заменяет ожидающее пакетное перестроение.
|
||
if self.pause.isChecked(): return # Изменения фильтров во время паузы применятся после её снятия.
|
||
bar, position = self.verticalScrollBar(), self.verticalScrollBar().value() # Сохраняем место чтения до очистки документа.
|
||
horizontal, offset = self.horizontalScrollBar(), self.horizontalScrollBar().value() # Фильтрация и пакетное обновление не должны возвращать длинные строки к левому краю.
|
||
super().clear() # Очищаем только документ, сохраняя ограниченную историю.
|
||
for record in self.records: # Показываем оставшиеся записи в исходном порядке.
|
||
if self._visible(record): self._append_record(record) # Скрытые строки остаются доступными для следующего выбора фильтра.
|
||
bar.setValue(bar.maximum() if self.follow.isChecked() else min(position, bar.maximum())) # При сокращении документа ограничиваем прежнюю позицию новым диапазоном.
|
||
horizontal.setValue(offset) # Qt ограничивает прежнее смещение текущей шириной документа после перестроения.
|
||
self.count.setText("%d строк" % len(self.records)) # Счётчик включает временно скрытые записи.
|
||
|
||
def copy_visible(self): # Экспортируется фактически показанный текст, включая состояние паузы и фильтра.
|
||
QApplication.clipboard().setText(self.toPlainText()) # Копирование всей видимой консоли не требует ручного выделения.
|
||
|
||
def save_to(self, path, file_format=None): # Явный формат используется диалогом, а прямой вызов определяет формат по расширению файла.
|
||
destination = Path(path) # Путь может поступить строкой от Qt или объектом Path от вызывающего кода.
|
||
file_format = (file_format or destination.suffix.lstrip(".") or "txt").lower() # Имена расширений нечувствительны к регистру; без расширения сохраняется прежний TXT.
|
||
if file_format not in ("csv", "json", "txt"): # Не допускаем сохранения текста под видом неподдерживаемого формата.
|
||
raise ValueError("Выберите формат CSV, JSON или TXT") # Ошибка доступна как прямому вызывающему коду, так и диалогу.
|
||
text = self.toPlainText() # Единый снимок учитывает фильтры, формат байтов и замороженный на паузе экран.
|
||
rows = [{"line": index, "text": line} for index, line in enumerate(text.split("\n"), 1)] if text else [] # Нумерация с единицы сохраняет порядок и пустые строки расшифровки.
|
||
if file_format == "csv": # Табличное представление содержит номер строки и её полный текст.
|
||
with destination.open("w", encoding="utf-8-sig", newline="") as stream: # BOM помогает Excel распознать кириллицу; newline исключает лишние пустые строки в Windows.
|
||
writer = csv.DictWriter(stream, fieldnames=("line", "text")) # Стабильные имена столбцов позволяют обрабатывать журналы скриптами.
|
||
writer.writeheader() # Даже пустой журнал получает корректную схему CSV.
|
||
writer.writerows(rows) # Стандартный писатель экранирует кавычки и запятые без изменения содержимого.
|
||
elif file_format == "json": # Массив объектов сохраняет ту же схему строк, что CSV.
|
||
destination.write_text(json.dumps(rows, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") # Читаемый UTF-8 сохраняет кириллицу и валидный пустой массив.
|
||
else: # TXT остаётся точной текстовой копией показанного журнала.
|
||
destination.write_text(text, encoding="utf-8") # Прежний контракт сохранения TXT не добавляет заголовки или номера строк.
|
||
|
||
def save_dialog(self): # Пользователь самостоятельно выбирает назначение журнала.
|
||
filters = "CSV UTF-8 (*.csv);;JSON UTF-8 (*.json);;Текст UTF-8 (*.txt)" # Все консоли предлагают одинаковые три формата; CSV выбран первым.
|
||
path, selected_filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console", filters) # Имя без расширения позволяет выбрать любой формат без ручной замены суффикса.
|
||
if path: # Записываем только явно выбранный файл.
|
||
file_format = Path(path).suffix.lstrip(".").lower() # Явно введённое поддерживаемое расширение имеет приоритет над фильтром диалога.
|
||
if file_format not in ("csv", "json", "txt"): # Если расширение отсутствует или неизвестно, применяем выбранный формат.
|
||
file_format = "json" if "*.json" in selected_filter else "txt" if "*.txt" in selected_filter else "csv" # При отсутствии фильтра Qt используется первый предложенный формат CSV.
|
||
path += "." + file_format # Итоговое расширение всегда соответствует фактически записанному формату.
|
||
try: self.save_to(path, file_format) # Обрабатываем отказ файловой системы без прерывания приёма данных.
|
||
except (OSError, ValueError) as error: QMessageBox.warning(self, "Сохранение консоли", str(error)) # Причина ошибки остаётся доступна оператору.
|