Files
templates/python/set_devices/qt_ports/console.py
Andrey a5e46d4627 Расширить обмен по шинам, CAN-адаптеры и обработку графиков
Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
2026-10-17 14:36:14 +03:00

181 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Общая консоль 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)) # Причина ошибки остаётся доступна оператору.