diff --git a/python/examples/console_examples.py b/python/examples/console_examples.py
new file mode 100644
index 0000000..1453266
--- /dev/null
+++ b/python/examples/console_examples.py
@@ -0,0 +1,46 @@
+"""Три автономных примера общей консоли templates без подключения к аппаратуре.""" # Примеры показывают служебный текст, CAN и двоичный RS485 на одном компоненте.
+import argparse # Ключ --kind выбирает один пример для отдельного запуска.
+import time # Время в модели кадра соответствует обычному источнику пакетов приложения.
+from set_devices.can485_board import BoardFrame # Штатная модель строит RS485-пакет с настоящим CRC.
+from set_devices.can_monitor import decode_protocol # CAN расшифровывается действующим декодером templates.
+from set_devices.can_transport import build_frame # Транспортный кадр не собирается вручную в UI.
+from set_devices.qt_ports.console import ConsoleView # Все примеры используют ту же консоль, что SETGUI.
+try: # Выбираем доступную версию Qt без зависимости от приложения SETGUI.
+ from PySide6.QtWidgets import QApplication, QTabWidget # Вкладки позволяют сравнить три независимых журнала.
+except ImportError: # Сохраняем совместимость примера с портом Qt 5.
+ from PySide2.QtWidgets import QApplication, QTabWidget # Интерфейс запуска одинаков для обеих версий Qt.
+
+
+def create_examples(): # Фабрика доступна также тесту и генератору снимков интерфейса.
+ service = ConsoleView(limit=4000) # Первый журнал показывает служебные ответы и команды платы.
+ service.append_message(">>> V", direction="TX", color="#8fd18f") # Метка отправки демонстрирует независимый фильтр TX.
+ service.append_message("#OK driver started", direction="RX", color="#6fa8dc") # Ответ устройства показывает цвет и фильтр RX.
+ service.append_message("#ERR transmit failed (no ack / bus busy)", color="#ff7b7b") # Ошибка остаётся видна независимо от фильтров направления.
+ can = ConsoleView(limit=4000, payloads=True) # Второй пример добавляет переключение представления настоящих байтов CAN.
+ frame = build_frame(0x701, b"\x05", ide=False, rtr=False) # ID 0x701 и байт 05 задают heartbeat CANopen узла 1 в Operational.
+ decoded = decode_protocol(frame, "deif") # Подпись события берётся из штатного протокольного декодера.
+ can.append_message("RX CAN 0x701 · " + decoded.summary, direction="RX", payload=b"\x05") # Исходный payload позволяет сравнить режимы HEX и ASCII.
+ rs485 = ConsoleView(limit=4000, payloads=True) # Третий пример показывает транспортный пакет платы отдельно от прикладного CAN.
+ packet = BoardFrame(time.time(), False, 0x701, False, 1, b"\x05", "RS485").rs485_packet() # Пакет AA55 и CRC8 формируются кодеком templates.
+ rs485.append_message("RX RS485 · пакет CAN485", direction="RX", payload=packet) # Консоль отображает байты, не повторяя алгоритм разбора пакета.
+ for console in (can, rs485): # Двоичные примеры сразу демонстрируют одновременно байты и печатное представление.
+ console.display.setCurrentText("HEX + ASCII") # Пользователь может вернуться к исходной подписи или выбрать один формат.
+ return (("Служебная консоль", service), ("CAN", can), ("RS485", rs485)) # Каждый журнал владеет своей историей и настройками просмотра.
+
+
+def main(): # Точка входа запускается обычным Python с установленным Qt-портом templates.
+ parser = argparse.ArgumentParser(description=__doc__) # Справка объясняет назначение примеров без отдельного приложения.
+ parser.add_argument("--kind", choices=("all", "service", "can", "rs485"), default="all") # Один пример каждого источника доступен отдельной командой.
+ args = parser.parse_args() # Пользователь выбирает источник до создания окна.
+ app = QApplication.instance() or QApplication([]) # Одно приложение Qt обслуживает все примеры.
+ window = QTabWidget() # Общий просмотр не связывает состояния независимых консолей.
+ for key, (title, console) in zip(("service", "can", "rs485"), create_examples()): # Стабильные ключи соответствуют значениям --kind.
+ if args.kind in ("all", key): window.addTab(console.panel, title) # Выбранные примеры добавляются с общей панелью инструментов.
+ window.setWindowTitle("Примеры консоли templates") # Заголовок явно отличает демонстрацию от подключения к плате.
+ window.resize(1100, 480) # Начальный размер в логических пикселях позволяет видеть все инструменты.
+ window.show() # Окно появляется только при прямом запуске примера.
+ return app.exec() # Цикл Qt обрабатывает поиск, паузу, экспорт и изменение масштаба.
+
+
+if __name__ == "__main__": # Импорт фабрики в тестах не должен открывать окно или запускать цикл событий.
+ raise SystemExit(main()) # Код завершения Qt возвращается вызывающему процессу.
diff --git a/python/set_devices/qt_ports/CONSOLE.md b/python/set_devices/qt_ports/CONSOLE.md
new file mode 100644
index 0000000..7c32bbf
--- /dev/null
+++ b/python/set_devices/qt_ports/CONSOLE.md
@@ -0,0 +1,68 @@
+# Общая консоль Qt
+
+`set_devices.qt_ports.console.ConsoleView` — общий порт представления для
+журналов SETGUI. Он не управляет портами, опросом и отправкой команд.
+
+Приложение → `ConsoleView` → Qt 6 / Qt 5. Протокольные декодеры передают
+готовые подписи и необязательные исходные байты; консоль не вычисляет CRC.
+
+| Файл | Назначение и зависимости |
+|---|---|
+| `set_devices/qt_ports/console.py` | Виджет и файловый экспорт; stdlib и PySide6 либо PySide2 |
+| `examples/console_examples.py` | По одному примеру служебной консоли, CAN и RS485; действующие кодеки templates |
+| `tests/test_console.py` | Проверки истории, паузы, фильтров, очистки и UTF-8; pytest и Qt |
+
+## Возможности, собранные из журналов SETGUI
+
+| Возможность | Источник / поведение общего компонента |
+|---|---|
+| Очистка | Канальные консоли платы, TX/RX, Modbus; удаляет также скрытые строки и очередь обновления |
+| Пауза | Эмуляторы CAN; экран заморожен, история продолжает принимать данные до заданного предела |
+| TX/RX | Диагностика и эмуляторы; служебные ошибки без направления остаются видны |
+| Автопрокрутка | Мониторы CAN и RS; её можно отключить для чтения истории |
+| Цвета | Консоль платы и монитор шины; сохраняются при переразборе фильтра |
+| HEX/ASCII | Диагностика RS; формат применяется к явно переданному payload |
+| Сохранение | Мониторы CAN и RS; экспорт фактически видимого текста в UTF-8 |
+| Поиск, копирование, перенос, масштаб | Общие инструменты всех подключённых текстовых журналов |
+
+## Подключение
+
+```python
+from set_devices.qt_ports.console import ConsoleView # Подключаем общий порт представления templates.
+console = ConsoleView(limit=4000, payloads=True) # Храним не более 4000 строк, включая скрытые и поступившие на паузе.
+layout.addWidget(console.panel, 1) # В layout вставляется контейнер с инструментами и редактором.
+console.append_message("RX ответ", direction="RX", payload=b"OK") # Передаём готовую подпись и байты без повторного разбора протокола.
+console.append_message("Ошибка связи", color="#ff7b7b") # Служебная ошибка остаётся видна при выключенных TX и RX.
+console.userCleared.connect(clear_frame_history) # Отдельный буфер кадров очищается тем же пользовательским действием.
+```
+
+Старые вызовы `appendPlainText`, `appendHtml`, `setPlainText`, `clear`,
+`toPlainText` и `setMaximumBlockCount` поддерживаются. `setPlainText` заменяет
+снимок истории, учитывая текущую паузу. Для внешней истории кадров подключите
+`userCleared`: программный `clear()` повторно этот сигнал не испускает.
+
+`directions=False` скрывает встроенные флажки, если приложение уже имеет свои
+фильтры TX/RX. `payloads=True` показывает форматы HEX/ASCII. События должны
+поступать в GUI-потоке Qt, как при работе с обычным `QPlainTextEdit`.
+
+При заполнении история обновляет экран пачками с интервалом 100 мс.
+Количество строк на панели относится ко всей ограниченной истории.
+На паузе сохранение и копирование используют замороженный видимый экран.
+
+## Три запускаемых примера
+
+Из каталога `python`, с установленными зависимостями `.[qt]`:
+
+```powershell
+python examples/console_examples.py --kind service
+python examples/console_examples.py --kind can
+python examples/console_examples.py --kind rs485
+```
+
+Без `--kind` открываются все три примера. Аппаратура не требуется.
+В каждом примере можно проверить очистку, паузу, поиск, TX/RX, экспорт,
+копирование, автопрокрутку, перенос и масштаб; в CAN и RS485 — HEX/ASCII.
+
+Используется в SETGUI: CAN/RS485 и служебная консоль ESP, журналы соединения
+и TX/RX, эмуляторы CAN, CAN FD, Modbus, диагностика ПМ35 и ТМС2812,
+спектр и логический анализатор Altera.
diff --git a/python/set_devices/qt_ports/console.py b/python/set_devices/qt_ports/console.py
new file mode 100644
index 0000000..a2efe2b
--- /dev/null
+++ b/python/set_devices/qt_ports/console.py
@@ -0,0 +1,156 @@
+"""Общая консоль Qt: ограниченная история и инструменты просмотра без управления транспортом.""" # Модуль относится к порту представления templates и не реализует протоколы устройств.
+from collections import deque # Кольцевая история ограничивает память при непрерывном обмене.
+from html import escape # Текст устройства не должен становиться HTML-разметкой журнала.
+from pathlib import Path # Экспорт сохраняет явно выбранный пользователем файл в UTF-8.
+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"), QCheckBox("RX") # Служебные сообщения не относятся ни к одному направлению.
+ for control in (self.show_tx, self.show_rx): # Оба фильтра действуют независимо и применяются к истории.
+ control.setChecked(True) # Первое открытие показывает весь обмен.
+ control.setVisible(directions) # Потребители со своими фильтрами могут скрыть дублирующие элементы.
+ filters.addWidget(control) # Направления расположены рядом с текстовым фильтром.
+ self.display = QComboBox() # Формат байтов относится только к сообщениям с явно переданным payload.
+ 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 ('%s' % (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 not self.pause.isChecked(): super().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() # Запоминаем позицию до изменения размера документа.
+ 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) # Отключённая автопрокрутка сохраняет место чтения.
+
+ def refresh(self, *_args): # Сигналы Qt могут передавать индекс, флаг или текст.
+ self.timer.stop() # Немедленное обновление заменяет ожидающее пакетное перестроение.
+ if self.pause.isChecked(): return # Изменения фильтров во время паузы применятся после её снятия.
+ bar, position = self.verticalScrollBar(), self.verticalScrollBar().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())) # При сокращении документа ограничиваем прежнюю позицию новым диапазоном.
+ self.count.setText("%d строк" % len(self.records)) # Счётчик включает временно скрытые записи.
+
+ def copy_visible(self): # Экспортируется фактически показанный текст, включая состояние паузы и фильтра.
+ QApplication.clipboard().setText(self.toPlainText()) # Копирование всей видимой консоли не требует ручного выделения.
+
+ def save_to(self, path): # Отдельный метод позволяет проверить экспорт без системного диалога.
+ Path(path).write_text(self.toPlainText(), encoding="utf-8") # UTF-8 сохраняет русские сообщения и расшифровки протоколов.
+
+ def save_dialog(self): # Пользователь самостоятельно выбирает назначение журнала.
+ path, _filter = QFileDialog.getSaveFileName(self, "Сохранить консоль", "console.txt", "Текст UTF-8 (*.txt);;Все файлы (*)") # Диалог не выполняет автоматической записи при отмене.
+ if path: # Записываем только явно выбранный файл.
+ try: self.save_to(path) # Обрабатываем отказ файловой системы без прерывания приёма данных.
+ except OSError as error: QMessageBox.warning(self, "Сохранение консоли", str(error)) # Причина ошибки остаётся доступна оператору.
diff --git a/python/tests/test_console.py b/python/tests/test_console.py
new file mode 100644
index 0000000..1b50ff1
--- /dev/null
+++ b/python/tests/test_console.py
@@ -0,0 +1,77 @@
+"""Проверки общей консоли на настоящем Qt без физического устройства.""" # Проверяем сохранность истории, фильтры и очистку при асинхронном обновлении.
+import os # Виртуальная платформа выбирается до импорта Qt.
+os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") # Тесты не открывают окна на рабочем столе.
+import pytest # Фикстура даёт каждому сценарию независимую консоль.
+from set_devices.qt_ports.console import ConsoleView, QApplication # Используем реальный порт templates, а не имитацию обработчиков.
+
+
+@pytest.fixture # Создаём отдельный журнал для проверки независимости состояний.
+def console(): # Ссылка на приложение удерживается до завершения сценария.
+ app = QApplication.instance() or QApplication([]) # Qt требует приложение перед созданием виджетов.
+ view = ConsoleView(limit=3, payloads=True) # Малый предел проверяет вытеснение без большого массива данных.
+ yield view # Сценарий управляет настоящими сигналами флажков и кнопок.
+ view.panel.deleteLater() # Удаляется контейнер вместе с редактором и таймером.
+ app.processEvents() # Отложенное удаление доставляется до следующего сценария.
+
+
+def test_pause_keeps_bounded_history_and_clear_cannot_resurrect(console): # Очистка на паузе должна удалять также невидимые данные.
+ console.appendPlainText("первая") # Первая запись сразу видна на экране.
+ console.pause.setChecked(True) # Замораживаем только представление.
+ for index in range(5): console.appendPlainText("строка %d" % index) # На паузе поступает больше данных, чем помещается в истории.
+ assert console.toPlainText() == "первая" and len(console.records) == 3 # Экран заморожен, память ограничена.
+ console.pause.setChecked(False) # Возобновление показывает последние сохранённые строки.
+ assert console.toPlainText().splitlines() == ["строка 2", "строка 3", "строка 4"] # Вытеснённые записи больше не появляются.
+ console.appendPlainText("ожидает таймер") # Полный буфер ставит пакетное обновление в очередь.
+ assert console.timer.isActive() # Проверяем именно очистку при ожидающем обновлении.
+ console.pause.setChecked(True) # Очистка должна работать и при замороженном экране.
+ console.clear_button.click() # Пользователь удаляет весь журнал через общую панель.
+ console.pause.setChecked(False) # Возобновление не должно вернуть старые данные.
+ assert not console.records and not console.toPlainText() and not console.timer.isActive() # Документ, история и очередь согласованы.
+
+
+def test_filters_restore_hidden_rows_and_preserve_service_errors(console): # Фильтры направлений не должны скрывать ошибки без метки TX/RX.
+ console.appendPlainText("[12:00:00] TX команда") # Начальная метка после времени определяет направление.
+ console.appendPlainText("12:00:01.123 RX ответ") # Поддерживаем существующий формат временных меток SETGUI.
+ console.appendPlainText("#ERR RX timeout") # Слово RX внутри ошибки не делает её принятым пакетом.
+ console.show_rx.setChecked(False) # Скрываем только входящие пакеты.
+ console.show_tx.setChecked(False) # Оба направления можно выключить одновременно.
+ assert console.toPlainText() == "#ERR RX timeout" # Служебная ошибка остаётся доступной оператору.
+ console.search.setText("ОТВЕТ") # Регистр запроса не должен влиять на поиск.
+ console.show_rx.setChecked(True) # Входящие данные восстанавливаются из сохранённой истории.
+ assert console.toPlainText() == "12:00:01.123 RX ответ" # Поиск применяется к той же записи без повторного приёма.
+ console.search.clear() # Снимаем текстовый фильтр.
+ assert len(console.records) == 3 # Фильтры не удаляют события.
+
+
+def test_payload_and_utf8_export_use_visible_snapshot(console, tmp_path): # Сохранение и копирование соответствуют выбранному представлению.
+ console.append_message("RX пакет <текст>", direction="RX", payload=b"A\x00\xff") # Payload содержит печатный, управляющий и не-ASCII байты.
+ console.display.setCurrentText("HEX + ASCII") # Выбираем совместное представление байтов.
+ assert console.toPlainText() == "RX пакет <текст> · 41 00 FF | A.." # Непечатные байты заменяются точками без потери HEX.
+ path = tmp_path / "журнал.txt" # Проверяем кириллицу в имени и содержимом файла.
+ console.save_to(path) # Экспорт выполняется без диалога, сохраняя тот же код записи.
+ assert path.read_text(encoding="utf-8") == console.toPlainText() # UTF-8 сохраняет фактически видимый текст.
+ console.copy_visible() # Кнопка копирует весь показанный журнал.
+ assert QApplication.clipboard().text() == console.toPlainText() # Буфер обмена совпадает с экспортом.
+
+
+def test_multiline_html_and_snapshot_obey_limit(console): # Предел относится к строкам, а не к числу вызовов записи.
+ console.appendPlainText("0\n1\n2\n3\n4") # Один пакет расшифровки превышает лимит документа и истории.
+ console.refresh() # Применяем запланированное пакетное обновление.
+ assert console.toPlainText() == "2\n3\n4" # Старые строки не возвращаются при смене фильтра.
+ console.setPlainText("снимок") # Потребитель может передать результат повторного разбора кадров целиком.
+ console.appendHtml('<ошибка>') # Проверяем совместимость прежнего цветного API SETGUI.
+ console.search.setText("ошибка") # Поиск должен работать по тексту без тегов разметки.
+ assert console.toPlainText() == "<ошибка>" # Спецсимволы устройства остаются обычным текстом.
+ console.search.clear() # Возвращаем обе записи с исходным оформлением.
+ assert console.toPlainText() == "снимок\n<ошибка>" # Фильтр не теряет предыдущий снимок истории.
+
+
+def test_clear_notifies_external_owner_once(console): # Владелец может очистить свой буфер и повторно вызвать clear без рекурсии.
+ external = ["старый кадр"] # Имитируем отдельную историю протокольных кадров приложения.
+ def clear_owner(): # Обработчик очищает внешний источник повторного форматирования.
+ external.clear() # Старые кадры не вернутся после смены парсера.
+ console.clear() # Повторный программный вызов не должен снова эмитировать пользовательский сигнал.
+ console.userCleared.connect(clear_owner) # Подключаем тот же контракт, что консоли платы и TX/RX SETGUI.
+ console.appendPlainText("строка") # До очистки журнал содержит данные.
+ console.clear_button.click() # Нажимаем настоящую кнопку Qt.
+ assert not external and not console.toPlainText() # Обе истории очищены одним действием.