Расширить обмен по шинам, CAN-адаптеры и обработку графиков

Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
This commit is contained in:
2026-10-17 14:36:14 +03:00
parent 95c8b8ac7d
commit a5e46d4627
69 changed files with 4413 additions and 682 deletions
+68
View File
@@ -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.
@@ -0,0 +1,175 @@
"""Общие поля конструктора CAN и SETCAN для адаптеров и обоих каналов DevBoard."""
from PySide6.QtCore import Signal # Сигнал изменений позволяет владельцу сбрасывать готовый кадр без зависимости от транспорта.
from PySide6.QtGui import QFont # Моноширинный шрифт сохраняет читаемость HEX-идентификатора и данных.
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QFormLayout, QComboBox, QSpinBox, QLineEdit, QLabel # Один набор виджетов определяет форму всех потребителей.
from protocan import protocan as pc # Сборка полей протокола выполняется существующими обёртками общего C-ядра.
from set_devices import can_transport as tr # Проверка CAN ID и разбор HEX используют действующий общий транспорт.
DESCRIPTION = "Обычный CAN: задайте ID, DLC и Data. Для сборки ID по полям протокола выберите SETCAN." # Одинаковое пояснение режимов используется во всех панелях.
class CanFrameFields(QWidget): # Компонент содержит только ввод кадра; подключение и отправка остаются у владельца.
changed = Signal() # Любое изменение ввода отменяет ранее собранный кадр в соответствующем канале.
def __init__(self, settings_factory, parent=None): # Редактор SETTINGS передаётся владельцем как визуальный компонент без копирования его реализации.
super().__init__(parent) # Родитель Qt управляет временем жизни всех полей формы.
layout = QVBoxLayout(self) # Единый вертикальный порядок исключает различия между вкладками.
layout.setContentsMargins(0, 0, 0, 0) # Внешние отступы задаёт панель приложения.
self.mode = QComboBox() # Переключатель сохраняет оба набора введённых значений.
self.mode.addItem("Обычный CAN — ID / DLC / Data", "can") # Исходный режим одинаков для всех трёх конструкторов.
self.mode.addItem("SETCAN — поля протокола", "setcan") # Протокольный режим всегда создаёт расширенный идентификатор.
mode_form = QFormLayout() # Первая строка остаётся видимой в обоих режимах.
mode_form.addRow("Режим", self.mode) # Подпись поясняет назначение переключателя.
layout.addLayout(mode_form) # Выбор режима предшествует зависимым полям.
self.can_panel = QWidget() # Контейнер скрывает вместе подписи и редакторы обычного CAN.
can_form = QFormLayout(self.can_panel) # Формат, идентификатор и длина выровнены общей формой.
can_form.setContentsMargins(0, 0, 0, 0) # Поля обоих режимов начинаются на одной вертикали.
self.format = QComboBox() # IDE задаётся явно независимо от численного значения идентификатора.
self.format.addItem("Стандартный — 11 бит (STD)", False) # STD соответствует диапазону CAN ID 0..0x7FF.
self.format.addItem("Расширенный — 29 бит (EXT)", True) # EXT соответствует диапазону CAN ID 0..0x1FFFFFFF.
self.can_id = QLineEdit("123") # Исходный пример идентификатора записан в HEX.
self.can_id.setToolTip("HEX, с префиксом 0x или без него. STD: 0..7FF; EXT: 0..1FFFFFFF.") # Указываем пределы CAN ID без неявного усечения.
self.dlc = QSpinBox() # Пользователь явно задаёт длину обычного CAN-кадра.
self.dlc.setRange(0, 8) # Классический CAN допускает от нуля до восьми байтов Data.
self.dlc.setToolTip("Количество байтов Data должно точно совпадать с DLC; дополнение и обрезка не выполняются.") # Описываем строгую проверку длины.
can_form.addRow("Формат ID (IDE)", self.format) # Формат отражает фактический флаг IDE.
can_form.addRow("CAN ID (HEX)", self.can_id) # Явно обозначаем систему счисления идентификатора.
can_form.addRow("DLC (0..8 байт)", self.dlc) # Длина дана в байтах полезной нагрузки.
layout.addWidget(self.can_panel) # Обычный CAN занимает то же место, что альтернативная форма SETCAN.
self.setcan_panel = QWidget() # Общий контейнер хранит поля составного 29-битного идентификатора.
form = QFormLayout(self.setcan_panel) # Подписи протокола выравниваются с остальными формами.
form.setContentsMargins(0, 0, 0, 0) # Убираем дополнительные внутренние отступы.
self.priority = QComboBox() # Приоритет соответствует старшему полю SETCAN ID.
self.priority.addItems(["0 — критический", "1 — стандартный"]) # Подписи сохраняют существующие значения приоритета протокола.
self.priority.setCurrentIndex(1) # По умолчанию используется стандартный приоритет.
self.route = QComboBox() # Направление отличает запрос мастера от сообщения устройства.
self.route.addItems(["0 — от мастера (PM)", "1 — от устройства"]) # Сохраняем совместимые значения поля ПМ.
self.dtype = QSpinBox() # Тип устройства выбирается отдельно от номера экземпляра.
self.dtype.setRange(0, 7) # Поле Device Type занимает три бита SETCAN ID.
self.dtype.setValue(1) # Сохраняем прежний исходный тип устройства.
self.did = QSpinBox() # Номер устройства задаёт адрес внутри выбранного типа.
self.did.setRange(0, 15) # Поле Device занимает четыре бита SETCAN ID.
self.did.setValue(2) # Сохраняем прежний исходный адрес устройства.
self.mtype = QComboBox() # Тип сообщения определяет подтип и назначение Body.
self.mtype.addItems([item.name for item in pc.MsgType]) # Каталог типов берётся из общего протокольного модуля.
self.mtype.setCurrentText(pc.MsgType.ANALOG.name) # Начальный протокольный пример остаётся аналоговым сообщением.
self.sub = QComboBox() # Набор подтипов меняется вместе с MsgType.
self.arg = QLineEdit("1") # Числовой аргумент поддерживает десятичный ввод и префикс 0x.
self.settings_body = settings_factory() # Используем переданный редактор байтов Z и Y без зависимости от приложения.
self.arg_panel = QWidget() # Альтернативные редакторы аргумента занимают одну строку.
arg_layout = QHBoxLayout(self.arg_panel) # Горизонтальная компоновка сохраняет левое выравнивание SETTINGS.
arg_layout.setContentsMargins(0, 0, 0, 0) # Исключаем сдвиг аргумента относительно остальных редакторов.
arg_layout.addWidget(self.arg) # Обычный аргумент виден для всех типов кроме SETTINGS.
arg_layout.addWidget(self.settings_body) # Два байта SETTINGS заменяют единое поле аргумента.
arg_layout.addStretch(1) # Свободная ширина остаётся справа от редакторов.
self.count = QSpinBox() # RegCount используется протокольными типами Modbus.
self.count.setRange(0, 15) # Поле количества занимает четыре бита Body Modbus.
self.count.setValue(1) # Исходный запрос относится к одному регистру.
self.sub_label = QLabel("Подтип") # Подпись уточняется при выборе типа сообщения.
self.arg_label = QLabel("SensorID / адрес") # Назначение аргумента зависит от MsgType.
rows = [("Priority", self.priority), ("ПМ", self.route), ("Device Type (0..7)", self.dtype), ("Device (0..15)", self.did), ("Msg Type", self.mtype), (self.sub_label, self.sub), (self.arg_label, self.arg_panel), ("RegCount (Modbus)", self.count)] # Единый порядок полей исключает расхождение форм.
for label, control in rows: # Все протокольные строки добавляются из одного визуального описания.
form.addRow(label, control) # Подпись связана с соответствующим редактором.
layout.addWidget(self.setcan_panel) # Протокольная форма показывается только в режиме SETCAN.
self.data = QLineEdit() # Полезная нагрузка общая для обоих способов ввода идентификатора.
self.data.setPlaceholderText("01 02 03 04 05 06 07 08") # Пример показывает формат ввода HEX-байтов.
data_form = QFormLayout() # Общая строка Data остаётся видимой при смене режима.
data_form.addRow("Data (HEX, до 8 байт)", self.data) # Указываем ограничение классического CAN.
layout.addLayout(data_form) # Данные завершают форму перед кнопками владельца.
for control in (self.mode, self.data): # Длинные поля получают достаточную ширину в обоих режимах.
control.setMaximumWidth(420) # Предел 420 пикселей сохраняет компактность исходного конструктора.
self.format.setMaximumWidth(340) # Название формата целиком помещается в строке шириной до 340 пикселей.
for control in (self.priority, self.route, self.mtype, self.sub, self.arg, self.can_id): # Одинаковая ширина полей ID задаётся в общем компоненте.
control.setMaximumWidth(220) # Предел 220 пикселей соответствует прежней форме SETCAN.
for control in (self.dtype, self.did, self.count, self.dlc): # Короткие числовые поля не растягиваются по ширине панели.
control.setMaximumWidth(100) # Предел 100 пикселей достаточен для всех допустимых значений.
font = QFont("Consolas", 10) # Шрифт с фиксированной шириной знаков упрощает чтение HEX.
for control in (self.can_id, self.data): # Числовой HEX и полезная нагрузка оформляются одинаково.
control.setFont(font) # Используем один шрифт для всех владельцев компонента.
self._subtype() # Настраиваем первоначальные подписи до вычисления ширины столбца.
labels = [part.labelForField(part.itemAt(row, QFormLayout.ItemRole.FieldRole).widget()) for part in (mode_form, can_form, form, data_form) for row in range(part.rowCount())] # Собираем подписи всех форм для общего выравнивания.
width = max(label.sizeHint().width() for label in labels) # Ширина столбца рассчитывается в пикселях по текущему шрифту Qt.
for label in labels: # Поля обоих режимов должны начинаться в одной позиции.
label.setMinimumWidth(width) # Самая длинная подпись задаёт единый левый столбец.
for container in (self, self.can_panel, self.setcan_panel, self.arg_panel): # Промежуточные контейнеры наследуют фон панели без тёмных полос.
container.setObjectName("canBuilderFields") # Локальный селектор ограничивает правило контейнерами формы.
container.setStyleSheet("QWidget#canBuilderFields { background: transparent; }") # Редакторы сохраняют собственный штатный фон.
self.mode.currentIndexChanged.connect(self._mode_changed) # Переключение режима одновременно меняет видимость и отменяет готовность кадра.
self.mtype.currentIndexChanged.connect(self._subtype) # Новый MsgType обновляет подтипы и редактор Body.
for control in (self.format, self.priority, self.route, self.mtype, self.sub): # Любой выбор из списка меняет будущий кадр.
control.currentIndexChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем индекс Qt, поскольку владельцу нужен только сигнал устаревания результата.
for control in (self.can_id, self.arg, self.data): # Все текстовые поля участвуют в сборке кадра.
control.textChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем текст сигнала Qt и отменяем старый кадр даже при неверном промежуточном вводе.
for control in (self.dlc, self.dtype, self.did, self.count, self.settings_body.z, self.settings_body.y): # Слежение включает оба байта SETTINGS.
control.valueChanged.connect(lambda *_: self.changed.emit()) # Отбрасываем числовой аргумент Qt, сохраняя единый сигнал необходимости новой сборки.
self._mode_changed() # Начальная видимость одинакова у всех потребителей.
def _mode_changed(self): # Режим определяет только видимость; введённые значения сохраняются.
raw = self.mode.currentData() == "can" # Полный CAN ID вводится только в обычном режиме.
self.can_panel.setVisible(raw) # Показываем формат, ID и DLC для обычного CAN.
self.setcan_panel.setVisible(not raw) # Альтернативно показываем составные поля SETCAN.
self.changed.emit() # Смена способа ввода отменяет ранее собранный кадр.
def _subtype(self): # Общие подписи и список подтипов используются всеми экземплярами формы.
msg_type = pc.MsgType[self.mtype.currentText()] # Значение выбирается из существующего общего каталога.
settings = msg_type == pc.MsgType.SETTINGS # Только SETTINGS редактируется отдельными байтами Z и Y.
self.arg.setVisible(not settings) # Единый числовой аргумент скрывается для SETTINGS.
self.settings_body.setVisible(settings) # Двухбайтовый редактор появляется вместо числового аргумента.
table = {pc.MsgType.BROADCAST: (pc.BroadcastType, "Broadcast.Type", "Body (0..15)"), pc.MsgType.DISCRETE: (pc.DiscreteType, "Discrete.Type", "Body (0..4095)"), pc.MsgType.ANALOG: (pc.AnalogType, "Analog.Type", "SensorID (0..4095)")} # Таблица определяет визуальные подписи существующих протокольных перечислений.
current = self.sub.currentText() # Сохраняем выбранный подтип, если он допустим для нового сообщения.
self.sub.clear() # Старые варианты не должны остаться в новом типе сообщения.
self.sub.setEnabled(msg_type in table) # Отдельный подтип доступен только у трёх перечисленных типов.
if msg_type in table: # Подписи и значения перечислений берутся из одного места.
enum, sub_label, arg_label = table[msg_type] # Получаем визуальное описание выбранного MsgType.
self.sub.addItems([item.name for item in enum]) # Значения берутся из общего протокольного каталога.
if current in [item.name for item in enum]: # Совместимый выбор пользователя сохраняется.
self.sub.setCurrentText(current) # Восстанавливаем допустимый подтип после заполнения списка.
else: # Типы без отдельного подтипа используют Body по собственному назначению.
sub_label = "—" # Подпись явно показывает отсутствие подтипа.
arg_label = "Body (обычно 0)" # Для PULSE сохраняем исходное описание аргумента.
if msg_type in (pc.MsgType.MODBUS_COIL, pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # Все четыре вида Modbus задают начальный адрес.
arg_label = "StrAdr (0..4095)" # Двенадцатибитный адрес показан в прежних единицах регистров.
elif msg_type == pc.MsgType.GENERAL_ADDRESS_SPACE: # GAS использует всё поле Body под адрес.
arg_label = "Адрес регистра (0..65535)" # Полный 16-битный диапазон указан в подписи.
elif msg_type == pc.MsgType.ERROR: # Код ошибки вводится в отдельный байт Body.
arg_label = "ErrorCode (0..255)" # Показываем допустимый однобайтовый диапазон.
elif settings: # SETTINGS редактируется двухбайтовым компонентом.
arg_label = "Body: Z | Y" # Подпись соответствует порядку байтов протокола.
self.sub_label.setText(sub_label) # Одинаковая подпись подтипа обновляется во всех вкладках.
self.arg_label.setText(arg_label) # Аргумент всегда подписан по выбранному MsgType.
def body(self, msg_type, arg, count): # Общая диспетчеризация вызывает существующие обёртки C-ядра без второй реализации wire-кодека.
if msg_type == pc.MsgType.BROADCAST: # BROADCAST объединяет подтип и четырёхбитный аргумент.
return pc.merge_broadcast(pc.BroadcastType[self.sub.currentText()], arg & 0xF) # Маска 0xF сохраняет прежнее ограничение аргумента четырьмя битами.
if msg_type == pc.MsgType.DISCRETE: # Дискретное сообщение использует собственное перечисление подтипов.
return pc.merge_discrete(pc.DiscreteType[self.sub.currentText()], arg) # Поле собирается общим протокольным модулем.
if msg_type == pc.MsgType.ANALOG: # Аналоговое сообщение передаёт SensorID в Body.
return pc.merge_analog(pc.AnalogType[self.sub.currentText()], arg) # Существующий кодек задаёт расположение подтипа и SensorID.
if msg_type in (pc.MsgType.MODBUS_COIL, pc.MsgType.MODBUS_DISCRETE, pc.MsgType.MODBUS_HOLDING, pc.MsgType.MODBUS_INPUT): # У четырёх видов Modbus одинаковый формат Body.
return pc.merge_modbus(arg, count) # Адрес и количество объединяются общей функцией протокола.
if msg_type == pc.MsgType.ERROR: # Сохраняем существующую категорию ошибки с нулевым кодом источника.
return pc.merge_error(0, arg) # Размещение кода ошибки остаётся ответственностью общего ядра.
return arg & 0xFFFF # Остальные типы сохраняют прежнее ограничение Body шестнадцатью битами.
def frame_values(self): # Возвращаем проверенные параметры CAN без упаковки конкретного транспорта.
data = tr.parse_hex(self.data.text()) # Общий разбор сохраняет поддержку пробелов, запятых и префиксов 0x.
if len(data) > 8: # Классический CAN не допускает более восьми байтов данных.
raise ValueError("данные длиннее 8 байт") # Сообщаем об ошибке до передачи в транспорт.
if self.mode.currentData() == "can": # Обычный режим использует полный идентификатор и явный DLC.
identifier = int(self.can_id.text().strip(), 16) # Поле ID всегда интерпретируется как HEX.
extended = bool(self.format.currentData()) # Малый числовой ID также может иметь выбранный EXT.
tr.validate_can_id(identifier, extended) # Проверка общего транспорта запрещает усечение ID до допустимого диапазона.
if len(data) != self.dlc.value(): # Количество введённых байтов обязано точно совпадать с DLC.
raise ValueError(f"DLC = {self.dlc.value()}, а Data содержит {len(data)} байт; количество байтов должно совпадать.") # Оператор видит обе длины и может исправить ввод.
return identifier, extended, data, None # У обычного CAN нет отдельного протокольного Body.
msg_type = pc.MsgType[self.mtype.currentText()] # SETCAN использует выбранный тип сообщения.
arg = self.settings_body.value() if msg_type == pc.MsgType.SETTINGS else int(self.arg.text().strip() or "0", 0) # SETTINGS читается из Z/Y, остальные аргументы поддерживают привычные числовые префиксы.
body = self.body(msg_type, arg, self.count.value()) # Один путь сборки Body применяется ко всем владельцам формы.
identifier = pc.ProtoCanId.build(self.priority.currentIndex(), self.route.currentIndex(), self.dtype.value(), self.did.value(), msg_type, body) # Идентификатор собирается существующей обёрткой общего C-ядра.
return identifier, True, data, body # SETCAN всегда передаётся с расширенным 29-битным идентификатором.
def bind(self, owner, prefix): # Сохраняем публичные имена полей приложения при переходе на общий компонент.
names = ("mode", "can_panel", "setcan_panel", "format", "can_id", "dlc", "priority", "route", "dtype", "did", "mtype", "sub", "arg", "settings_body", "count", "data", "sub_label", "arg_label") # Совместимые атрибуты позволяют не менять интеграцию транспорта и внешние потребители.
for name in names: # Каждое старое имя ссылается на настоящий виджет общего компонента.
setattr(owner, prefix + name, getattr(self, name)) # Префиксы build_, builder_ и rs485_builder_ разделяют три независимых состояния.
+137 -54
View File
@@ -2,33 +2,40 @@
from datetime import datetime
from pathlib import Path
from PySide6.QtCore import QTimer
from PySide6.QtCore import QTimer, Signal # Таймер обновляет буфер, а общий сигнал сообщает владельцу об изменении любых настроек расшифровки.
from PySide6.QtWidgets import (QWidget, QHBoxLayout, QVBoxLayout, QPushButton,
QCheckBox, QComboBox, QLabel, QLineEdit, QTableWidget, QTableWidgetItem,
QMessageBox, QApplication, QAbstractItemView, QFileDialog)
from ..can_transport import build_frame
from ..can_monitor import CanSession, PROTOCOLS, parse_filter, format_data, decode_protocol
from ..can_monitor import CanSession, PROTOCOLS, UMP_MAPS, parse_filter, parse_gas_filter, matches_filters, format_data, decode_protocol # Общие декодер, карты и независимые фильтры обеспечивают одинаковое поведение всех CAN-мониторов.
class MonitorControls(QWidget):
decoding_changed = Signal() # Владельцы обновляют онлайн и трассировку при смене формата, карты адресов или номера УМП.
def __init__(self, owner, directory, default="auto"):
super().__init__(owner)
self.owner = owner
self.directory = directory
self.session = CanSession()
self._dirty = False
layout = QHBoxLayout(self)
self._pending_frames = {} # Упорядоченные ключи объединяют повторы между тактами таймера и сохраняют порядок первого поступления новых кадров.
self._history_rows = {} # Ключ уникального кадра указывает на строку и последний показанный счётчик, исключая поиск по всей таблице.
self._history_total = 0 # Сумма показанных повторов обновляется по разнице счётчиков, без перебора накопленной истории.
self._history_needs_reset = False # Частичная ошибка записи требует однократной сверки таблицы с фактически сохранённой сессией.
controls_layout = QVBoxLayout(self) # Отдельная строка параметров УМП сохраняет компактную ширину основной панели монитора.
controls_layout.setContentsMargins(0, 0, 0, 0) # Панель встраивается в существующую вкладку без дополнительных внешних отступов.
layout = QHBoxLayout() # Основные кнопки остаются в прежнем горизонтальном порядке над параметрами УМП.
controls_layout.addLayout(layout) # Общие элементы занимают первую строку вертикального контейнера.
layout.setContentsMargins(0, 0, 0, 0)
self.protocol = QComboBox()
for key, label in PROTOCOLS:
self.protocol.addItem(label, key)
self.protocol.setMinimumContentsLength(28)
self.protocol.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToContents)
self.protocol.setToolTip(
"Выберите формат CAN вручную. УМП / Balsam — legacy CAN; "
"карта 167/v1 — имена слов 0…127 и 0x5000…0x507F в ProtoCAN; "
"новые ID — назначенные диапазоны 5000…5127 и 6000…6127.")
self.protocol.setCurrentIndex(self.protocol.findData(default))
self.protocol.setToolTip("Выберите формат CAN. Для УМП / ProtoCAN GAS карта адресов и устройство выбираются отдельно.") # Подсказка объясняет разделение протокола и прикладного профиля.
legacy_ump = default in ("ump1_gas167", "ump2_gas167", "ump_gas") # Старые значения исходного профиля поддерживаются без возврата дублирующихся пунктов в список.
self.protocol.setCurrentIndex(self.protocol.findData("ump_protocan" if legacy_ump else default)) # Прежние профили УМП открывают единый формат с отдельно восстановленными параметрами.
layout.addWidget(QLabel("Протокол"))
layout.addWidget(self.protocol)
self.raw = QCheckBox("Без парса")
@@ -57,15 +64,52 @@ class MonitorControls(QWidget):
self.status = QLabel("Буфер: 0")
layout.addWidget(self.status)
layout.addStretch(1)
self.ump_options = QWidget() # Контейнер дополнительных параметров показывается только для формата УМП / ProtoCAN GAS.
ump_layout = QHBoxLayout(self.ump_options) # Карта и номер УМП расположены рядом на второй строке панели.
ump_layout.setContentsMargins(0, 0, 0, 0) # Вложенная строка выравнивается с основными элементами монитора.
ump_layout.addWidget(QLabel("Карта адресов")) # Подпись отличает нумерацию регистров от формата CAN-кадра.
self.ump_map = QComboBox() # Выбор карты определяет словарь параметров при неизменном формате ProtoCAN.
for key, label in UMP_MAPS: # Оба монитора используют единый каталог действующей и планируемой карт.
self.ump_map.addItem(label, key) # Пользователь видит статус карты, а декодер получает устойчивый ключ профиля.
self.ump_map.setToolTip("167/v1: слова 0…127 и 0x5000…0x507F. Новые десятичные ID 5000…5127 и 6000…6127 назначены, но ещё не перенесены в прошивку; устройство определяется по ID.") # Подсказка указывает диапазоны обеих карт и состояние планируемой адресации.
self.ump_map.setCurrentIndex(self.ump_map.findData("assigned" if default == "ump_gas" else "gas167")) # Прежний профиль новых ID восстанавливает сквозную карту, остальные начинают с текущей.
ump_layout.addWidget(self.ump_map) # Карта выбирается независимо от списка форматов CAN.
self.ump_node_label = QLabel("Устройство") # Номер УМП относится к устройству, а не к отдельному протоколу.
ump_layout.addWidget(self.ump_node_label) # Подпись находится перед селектором номера УМП.
self.ump_node = QComboBox() # Текущая локальная карта требует ручного назначения УМП 1 или УМП 2.
self.ump_node.addItem("УМП 1", 1) # Число 1 обозначает первый УМП и передаётся декодеру как номер устройства.
self.ump_node.addItem("УМП 2", 2) # Число 2 обозначает второй УМП; карта регистров остаётся общей для обоих приборов.
self.ump_node.setCurrentIndex(self.ump_node.findData(2 if default == "ump2_gas167" else 1)) # Старый профиль второго УМП сохраняет выбранный прибор, остальные используют первый.
self.ump_node.setToolTip("Номер УМП для карты 167/v1 задаётся вручную. В новой карте устройство определяется по GAS ID.") # Подсказка объясняет, почему ручной выбор скрывается для сквозных ID.
ump_layout.addWidget(self.ump_node) # Устройство занимает собственное поле после карты адресов.
ump_layout.addStretch(1) # Свободное место справа удерживает дополнительные поля у левого края панели.
controls_layout.addWidget(self.ump_options) # Параметры УМП добавляются под основной строкой без расширения списка протоколов.
self._update_ump_options() # Исходный формат определяет видимость дополнительной строки до первого показа вкладки.
self.timer = QTimer(self)
self.timer.setInterval(250)
self.timer.timeout.connect(self.refresh)
self.timer.start()
QApplication.instance().aboutToQuit.connect(self.session.close)
self.destroyed.connect(lambda: self.session.close())
self.protocol.currentIndexChanged.connect(lambda *_: self.refilter())
self.protocol.currentIndexChanged.connect(self._change_protocol) # Смена формата обновляет параметры УМП и все представления принятых кадров.
self.ump_map.currentIndexChanged.connect(self._change_protocol) # Смена карты перерасшифровывает буфер и обновляет необходимость ручного номера устройства.
self.ump_node.currentIndexChanged.connect(self._change_protocol) # Смена устройства применяется к уже принятым кадрам текущей локальной карты.
self.raw.toggled.connect(lambda *_: self.refilter())
def _update_ump_options(self): # Видимость дополнительных полей зависит от формата и способности карты определить устройство по адресу.
self.ump_options.setVisible(self.protocol.currentData() == "ump_protocan") # Остальные форматы обходятся без настроек карты УМП.
manual_node = self.ump_map.currentData() == "gas167" # Только действующая локальная карта требует вручную выбранного номера УМП.
self.ump_node_label.setVisible(manual_node) # Подпись устройства скрывается вместе с ненужным ручным селектором новой карты.
self.ump_node.setVisible(manual_node) # Сквозные ID сами задают УМП, поэтому ручной номер не должен противоречить адресу кадра.
def _change_protocol(self, *_args): # Сигналы трёх селекторов используют общий путь обновления, не зависящий от индекса изменённого поля.
self._update_ump_options() # Сначала интерфейс показывает только применимые к выбранному формату параметры.
self.refilter() # Таблица уникальных кадров сразу получает расшифровку с новой картой и устройством.
self.decoding_changed.emit() # Владелец обновляет онлайн, трассировку и сведения о ранее принятых кадрах.
def decode(self, frame): # Все таблицы монитора разбирают кадры с одним набором выбранных параметров.
return decode_protocol(frame, self.protocol.currentData(), ump_map=self.ump_map.currentData(), ump_node=self.ump_node.currentData()) # Формат, карта и номер УМП передаются независимо, исключая расхождение онлайн и буфера.
def format_data(self, data):
return format_data(data, self.data_format.isChecked())
@@ -79,13 +123,28 @@ class MonitorControls(QWidget):
layout = QVBoxLayout(panel)
layout.setContentsMargins(0, 0, 0, 0)
row = QHBoxLayout()
row.addWidget(QLabel("Фильтр ID (hex)"))
self.filter_enabled = QCheckBox("Фильтр CAN ID (hex)") # Первый флажок управляет существующим отбором по идентификатору кадра.
self.filter_enabled.setChecked(True) # Сохраняем прежнее поведение: ввод CAN ID сразу фильтрует нижнюю таблицу.
row.addWidget(self.filter_enabled) # Флажок занимает место прежней подписи фильтра.
self.filter = QLineEdit()
self.filter.setPlaceholderText("701 или 0x701, 201")
self.filter.setToolTip("Фильтр только нижней таблицы; сверху отображаются все ID")
self.filter.setToolTip("CAN ID в HEX. Оба включённых фильтра работают по И; фильтруется только нижняя таблица, сверху остаются все кадры.") # Поясняем совместный отбор и сохраняем независимость основных таблиц.
self.filter.setClearButtonEnabled(True)
self.filter.textChanged.connect(self.refilter)
row.addWidget(self.filter)
self.gas_filter_enabled = QCheckBox("Фильтр GAS (dec / 0xHEX)") # Второй флажок позволяет включить адреса GAS отдельно или совместно с CAN ID.
row.addWidget(self.gas_filter_enabled) # Размещаем новый критерий в той же строке рядом с существующим фильтром.
self.gas_filter = QLineEdit() # Поле содержит список адресов слов GAS, независимо от списка идентификаторов кадров.
self.gas_filter.setPlaceholderText("5000, 5127 или 0x5000") # Примеры различают десятичные назначенные ID и шестнадцатеричное локальное окно.
self.gas_filter.setToolTip("GAS ID: DEC либо HEX с 0x, через запятую. Учитываются все слова кадра. При включённых обоих фильтрах должны совпасть и CAN ID, и GAS ID.") # Объясняем формат и поиск адреса внутри многословной посылки.
self.gas_filter.setClearButtonEnabled(True) # Очистка временно убирает GAS-условие без изменения флажка CAN ID.
self.gas_filter.setEnabled(False) # Новый фильтр включается пользователем явно и не меняет прежний начальный экран.
self.gas_filter.textChanged.connect(self.refilter) # Изменение списка сразу пересчитывает строки и счётчики сохранённого буфера.
self.filter_enabled.toggled.connect(self.filter.setEnabled) # Выключенный CAN-критерий сохраняет введённый текст для повторного включения.
self.filter_enabled.toggled.connect(self.refilter) # Снятие флажка сразу убирает ограничение CAN ID.
self.gas_filter_enabled.toggled.connect(self.gas_filter.setEnabled) # Состояние поля показывает, применяется ли GAS-условие.
self.gas_filter_enabled.toggled.connect(self.refilter) # Каждый флажок независимо пересчитывает пересечение двух критериев.
row.addWidget(self.gas_filter) # Поле GAS расположено перед общим счётчиком совпавших кадров.
self.count = QLabel("Кадров: 0")
row.addWidget(self.count)
layout.addLayout(row)
@@ -106,8 +165,10 @@ class MonitorControls(QWidget):
def ingest(self, identifier, data, extended, rtr=False, source="CAN", timestamp=None):
try:
self.session.append(identifier, data, extended, rtr, source, timestamp)
key = self.session.append(identifier, data, extended, rtr, source, timestamp) # Сессия возвращает точную идентичность кадра с учётом канала, STD/EXT, RTR и данных.
self._pending_frames[key] = None # Один ключ объединяет любое число повторов до следующего обновления экрана.
except OSError as exc:
self._history_needs_reset = True # Ошибка могла возникнуть после изменения буфера, поэтому следующий такт заново сверит все строки.
self.status.setText("Ошибка записи: " + str(exc))
self.session.stop()
self.record.setText("● Запись")
@@ -159,46 +220,68 @@ class MonitorControls(QWidget):
if not self.owner._paused and self._dirty:
self._dirty = False
self.status.setText("Буфер: %d" % sum(self.session.counts.values()))
self.refilter()
self._update_history(self._history_needs_reset) # Обычный такт обрабатывает лишь новые данные; полная сверка нужна только после ошибки записи.
def refilter(self):
if not hasattr(self, "filter"):
return
try:
ids = parse_filter(self.filter.text())
except ValueError as exc:
self.count.setText(str(exc))
ids = set()
else:
count = sum(value for key, value in self.session.counts.items()
if ids is None or key in ids)
unique_count = sum(1 for values, _ in self.session.unique_frames.values()
if ids is None or int(values[3], 16) in ids)
self.count.setText("Кадров: %d; уникальных: %d" % (count, unique_count))
self.history.horizontalHeaderItem(7).setText(
"Данные (DEC)" if self.data_format.isChecked() else "Данные (HEX)")
self.history.setColumnHidden(8, self.raw.isChecked())
self.history.setVisible(ids is not None)
if ids is None:
self.history.setRowCount(0)
return
frames = [(values, count) for values, count in self.session.unique_frames.values()
if int(values[3], 16) in ids]
self.history.setRowCount(len(frames))
for row, (values, count) in enumerate(frames):
data = bytes.fromhex(values[6])
summary = ""
if not self.raw.isChecked():
frame = build_frame(int(values[3], 16), data,
ide=values[2] == "EXT", rtr=bool(values[4]),
to_can=values[1] == "TX")
summary = decode_protocol(frame, self.protocol.currentData()).summary
display = values[:6] + (count, self.format_data(data), summary)
for column, value in enumerate(display):
item = self.history.item(row, column)
if item is None:
self.history.setItem(row, column, QTableWidgetItem(str(value)))
elif item.text() != str(value):
item.setText(str(value))
if column == 8:
self.history.item(row, column).setToolTip(summary)
def refilter(self): # Изменение фильтра, протокола или формата требует однократного пересчёта существующих строк.
self._update_history(True) # Полная перестройка применяется только по явному изменению настроек либо сбросу буфера.
def _update_history(self, rebuild): # Таймер и настройки используют один путь отбора, но разный объём входных записей.
if not hasattr(self, "filter"): # При сборке панели сигналы могут прийти до создания нижней таблицы.
return # Отложим отображение до завершения построения элементов интерфейса.
error = None # Ошибка активного фильтра заменяет счётчики понятным сообщением и очищает выборку.
try: # Разбор коротких условий выполняется одинаково для полного и добавочного обновления.
ids = parse_filter(self.filter.text()) if self.filter_enabled.isChecked() else None # Текст выключенного CAN-фильтра не влияет на выборку и не вызывает ошибок ввода.
gas_ids = parse_gas_filter(self.gas_filter.text()) if self.gas_filter_enabled.isChecked() else None # Независимо разбираем только включённое условие адресов GAS.
except ValueError as exc: # Неверный ввод не должен оставлять на экране строки от прежнего корректного фильтра.
error = str(exc) # Сохраняем объяснение для итоговой подписи после очистки строк.
ids, gas_ids = set(), None # Пустое множество CAN ID даёт пустую видимую выборку.
filtered = ids is not None or gas_ids is not None # Нижняя таблица нужна лишь при наличии хотя бы одного непустого условия.
self.history.horizontalHeaderItem(7).setText("Данные (DEC)" if self.data_format.isChecked() else "Данные (HEX)") # Заголовок соответствует выбранному представлению байтов.
self.history.setColumnHidden(8, self.raw.isChecked()) # Сырой режим скрывает расшифровку независимо от способа обновления.
self.history.setVisible(filtered) # Без ограничений сохраняем компактный вид основной таблицы.
updates = self.history.updatesEnabled() # Запоминаем исходный режим отрисовки, чтобы не включать её вопреки владельцу.
self.history.setUpdatesEnabled(False) # Пачка изменений не должна вызывать промежуточную перерисовку и перерасчёт геометрии.
blocked = self.history.blockSignals(True) # Программное заполнение не должно вызывать обработчики отдельных изменённых ячеек.
try: # Восстановление состояния Qt гарантируется также при ошибке декодирования.
if rebuild or not filtered or error is not None: # Смена условий и недопустимый ввод отменяют прежнее соответствие ключей строкам.
self.history.setRowCount(0) # Полная очистка выполняется по настройке, а не каждые 250 мс при приёме.
self._history_rows.clear() # Индексы строк больше не относятся к новой выборке.
self._history_total = 0 # Сумма повторов пересчитывается для нового набора подходящих кадров.
if filtered and error is None: # Скрытая таблица и некорректные условия не требуют фильтрации или декодирования истории.
keys = self.session.unique_frames if rebuild else self._pending_frames # Только явная перестройка перебирает всю сессию; таймер берёт изменённые ключи.
for key in keys: # Словари сохраняют порядок первого появления уникальных кадров, даже если между ними пришли повторы.
values, count = self.session.unique_frames[key] # Получаем последние время и счётчик с учётом всех повторов в текущей пачке.
previous = self._history_rows.get(key) # Уже показанный кадр прошёл неизменные фильтры и не нуждается в повторной расшифровке.
if previous is not None: # У повторов меняются только время последнего приёма и число физических кадров.
row, old_count = previous # Сохраняем прежнюю сумму, чтобы добавить только новые приёмы.
display = ((0, values[0]), (6, count)) # Колонки 0 и 6 содержат время и счётчик, остальные данные идентичны по ключу.
else: # Новый ключ необходимо проверить по обоим активным условиям.
data = bytes.fromhex(values[6]) # Данные нужны для GAS-отбора и однократной расшифровки новой строки.
if not matches_filters(int(values[3], 16), data, values[2] == "EXT", bool(values[4]), can_ids=ids, gas_ids=gas_ids): # Проверяем пересечение CAN/GAS с учётом формата и RTR.
continue # Неподходящий кадр остаётся в полной сессии для сохранения и последующей смены фильтров.
row, old_count = self.history.rowCount(), 0 # Новая уникальная посылка добавляется в конец с её полным счётчиком с начала сессии.
self.history.insertRow(row) # Существующие строки и выделение не пересоздаются при поступлении новой посылки.
summary = "" # В режиме без парса не вызываем декодер даже при первом отображении кадра.
if not self.raw.isChecked(): # Расшифровка вычисляется только для новой строки либо при явной перестройке настроек.
frame = build_frame(int(values[3], 16), data, ide=values[2] == "EXT", rtr=bool(values[4]), to_can=values[1] == "TX") # Восстанавливаем исходную метаинформацию сохранённой посылки.
summary = self.decode(frame).summary # Используем выбранные протокол, карту адресов и номер устройства.
display = enumerate(values[:6] + (count, self.format_data(data), summary)) # Новая строка заполняется целиком в порядке заголовков таблицы.
for column, value in display: # У повторов этот цикл касается двух ячеек вместо всей накопленной таблицы.
item = self.history.item(row, column) # Повторно используем существующий объект ячейки для сохранения выделения.
if item is None: # Ячейки создаются только при добавлении новой уникальной посылки.
item = QTableWidgetItem() # Пустой объект получает текст общим путём ниже.
self.history.setItem(row, column, item) # Таблица принимает владение созданной ячейкой.
if item.text() != str(value): # Не уведомляем модель Qt о фактически неизменившемся значении.
item.setText(str(value)) # Преобразование сохраняет прежнее отображение чисел и меток времени.
if column == 8: # Последний столбец содержит длинную расшифровку, которую удобно читать в подсказке.
item.setToolTip(str(value)) # Подсказка обновляется вместе с новой расшифровкой при смене настроек.
self._history_rows[key] = (row, count) # Запоминаем актуальные индекс и показанный счётчик для следующей пачки повторов.
self._history_total += count - old_count # Добавляем только новые физические приёмы без суммирования всей истории.
self._pending_frames.clear() # Все поступившие ключи учтены либо скрыты неизменными фильтрами.
self._history_needs_reset = False # Сверка после ошибки успешно завершена и больше не требует полного обхода.
total = self._history_total if filtered else sum(self.session.counts.values()) # Без фильтра подпись относится ко всей сессии, хотя сама таблица скрыта.
unique = len(self._history_rows) if filtered else len(self.session.unique_frames) # Число уникальных кадров берём из готового индекса без повторного отбора.
self.count.setText(error if error is not None else "Кадров: %d; уникальных: %d" % (total, unique)) # При ошибке оставляем причину, иначе показываем согласованные счётчики выборки.
finally: # Любой выход должен вернуть исходное поведение сигналов и перерисовки Qt.
self.history.blockSignals(blocked) # Восстанавливаем состояние, установленное владельцем до обновления.
self.history.setUpdatesEnabled(updates) # Готовая пачка отображается за одно обновление виджета.
File diff suppressed because one or more lines are too long
+180
View File
@@ -0,0 +1,180 @@
"""Общая консоль 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)) # Причина ошибки остаётся доступна оператору.
+3 -3
View File
@@ -14,7 +14,7 @@ except ImportError:
DRAG_HINT = ('ЛКМ рядом с маркером — двигать маркер; ЛКМ на свободном поле — '
'перемещение по X; Ctrl + ЛКМ или Shift + ЛКМ — перемещение по Y. '
'перемещение по X; Ctrl + ЛКМ — перемещение по Y; Shift + ЛКМ — по Y со стороны начала жеста. ' # Для двух шкал Shift выбирает сторону в точке нажатия и сохраняет её до отпускания кнопки.
'Щелчок мимо маркеров — одиночный курсор.')
@@ -34,7 +34,7 @@ def cursor_hit(plot, position, value, event, radius=8):
class PlotDragInteraction:
"""Capture a hit marker or pan one axis, chosen at the start of the drag."""
def _capture_y_pan(self):
def _capture_y_pan(self, event=None): # Необязательное событие нажатия позволяет владельцу двух шкал выбрать одну Y; клавиатура передаёт только исходную область.
return self.viewport
def _apply_y_pan(self, fraction, initial):
@@ -58,7 +58,7 @@ class PlotDragInteraction:
self.setFocus(Qt.MouseFocusReason)
self._press = QPointF(pos)
self._press_viewport = self.viewport
self._press_y_pan = self._capture_y_pan()
self._press_y_pan = self._capture_y_pan(event) # Фиксируем выбранную шкалу и её исходный диапазон при нажатии, чтобы пересечение центра не меняло цель перетаскивания.
self._press_markers = self.markers
self._press_bounds = self.visible_bounds
self._pan_vertical = vertical_pan_requested(event)
+23 -10
View File
@@ -8,6 +8,13 @@ except ImportError:
from PySide2.QtCore import QPointF, Qt
from PySide2.QtGui import QColor
from protocan.plot import Bounds, Viewport
from set_devices.plot_ticks import tick_precision # Обе амплитудные шкалы должны различать соседние деления даже при сильном приближении.
def amplitude_axis_margin(widget, bounds): # Отступ правой шкалы зависит от ширины её уточнённых подписей в текущем шрифте виджета.
step = (bounds.top - bounds.bottom) / max(100, widget.height() / 50) # Консервативная плотность делений даёт запас точности без обращения к геометрии, которая сама вычисляет этот отступ.
labels = [f'{value:.{tick_precision(value, step)}g}' for value in (bounds.bottom, bounds.top)] # Оцениваем самые длинные крайние подписи в том же формате, что используется при отрисовке шкалы.
return max(72, 14 + max(widget.fontMetrics().horizontalAdvance(label) for label in labels)) # Прежний минимум 72 пикселя и запас 14 пикселей сохраняют обычный вид и предотвращают обрезание длинных чисел.
def small_amplitude(values, threshold=30):
@@ -32,7 +39,8 @@ def split_amplitudes(series, threshold=30):
class DualAxisPlot:
"""Mixin: left axis for large channels, right for small channels.
Shift-wheel selects the axis by the pointer's half of the analog pane.
Ctrl + колесо выбирает шкалу по половине аналоговой области под указателем.
Shift + ЛКМ перемещает одну Y, выбранную стороной графика при нажатии.
The host supplies series_bounds/project_series rendering hooks.
"""
def __init__(self, *args, **kwargs):
@@ -115,23 +123,28 @@ class DualAxisPlot:
def plot_rect(self):
rect = super().plot_rect()
if self.right_axis_keys:
rect.setWidth(max(1, rect.width() - 72))
rect.setWidth(max(1, rect.width() - self.right_axis_margin())) # Уточнённые подписи правой Y получают достаточно места без выхода за границы виджета.
return rect
def right_axis_margin(self):
return 72 if self.right_axis_keys else 0
return amplitude_axis_margin(self, self.right_visible_bounds) if self.right_axis_keys else 0 # Используем фактический правый диапазон и текущий шрифт; при отсутствии правой шкалы отступ не нужен.
def reset_viewport(self):
self.right_viewport = Viewport()
super().reset_viewport()
def _capture_y_pan(self):
return super()._capture_y_pan(), self.right_viewport
def _capture_y_pan(self, event=None): # Снимок содержит области выбранных шкал; None исключает другую шкалу из всего текущего жеста.
left, right = super()._capture_y_pan(event), self.right_viewport # Сохраняем начальные области до движения, чтобы смещение считалось от точки нажатия без накопления ошибок.
if event is not None and event.modifiers() & Qt.ShiftModifier and self.right_axis_keys and self.has_left_axis(): # Выбор стороны нужен только для Shift с двумя отдельными Y; Ctrl, стрелки и единственная Y сохраняют общий перенос.
position = event.position() if hasattr(event, 'position') else event.localPos() # Получаем локальную точку нажатия одинаково для Qt 6 и Qt 5.
return (None, right) if position.x() >= self.value_rect().center().x() else (left, None) # Центр аналоговой области разделяет стороны; на самой границе выбираем правую Y, как при Ctrl с колесом.
return left, right # Общие команды перемещения получают обе области, а график с одной Y использует свою основную область.
def _apply_y_pan(self, fraction, initial):
left, right = initial
super()._apply_y_pan(fraction, left)
if self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis():
if left is not None: # Отсутствующая левая область означает, что Shift выбрал только правую шкалу при нажатии.
super()._apply_y_pan(fraction, left) # Общий обработчик перемещает левую Y и учитывает её фиксацию без изменения временной оси.
if right is not None and self.core and not self.lock_y and self.right_axis_keys and self.has_left_axis(): # Правая Y сдвигается лишь при включении в снимок жеста, наличии двух шкал и снятой общей фиксации Y.
self.right_viewport = right.transform(self.core, pan_y=fraction)
def reset_interaction(self):
@@ -139,7 +152,7 @@ class DualAxisPlot:
super().reset_interaction()
def wheelEvent(self, event):
if not event.modifiers() & Qt.ShiftModifier:
if not event.modifiers() & Qt.ControlModifier: # Ctrl включает масштаб Y выбранной стороны; колесо без Ctrl, включая Shift, передаётся общей навигации по X.
return super().wheelEvent(event)
pos = event.position() if hasattr(event, 'position') else event.posF()
rect = self.value_rect()
@@ -177,7 +190,7 @@ def paint_right_axis(painter, core, rect, bounds):
for index in range(count):
value = (first + index) * step
y = rect.top() + bounds.fraction(core, value, True) * rect.height()
painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.4g}')
painter.drawText(QPointF(rect.right() + 7, y + 4), f'{value:.{tick_precision(value, step)}g}') # Сохраняем четыре значащие цифры при обычном масштабе и добавляем разряды по шагу правой шкалы при приближении.
painter.restore()
@@ -189,7 +202,7 @@ class SingleAmplitudeAxis:
def plot_rect(self):
rect = super().plot_rect()
if not self.has_left_axis():
rect.setWidth(max(1, rect.width() - 72))
rect.setWidth(max(1, rect.width() - amplitude_axis_margin(self, self.visible_bounds))) # Единственная правая шкала тренда или спектра получает тот же запас для длинных подписей при приближении.
return rect
def paint_axes(self, painter):
@@ -10,7 +10,8 @@ except ImportError:
from PySide2.QtCore import Qt
from PySide2.QtWidgets import QScrollArea
NAVIGATION_HINT = ('Колесо и +/− — масштаб X; Ctrl + колесо и Ctrl + +/− — масштаб Y. '
NAVIGATION_HINT = ('Колесо и +/− — масштаб X; Ctrl + колесо — масштаб Y со стороны указателя; ' # Поясняем выбор левой или правой амплитудной шкалы по положению указателя при удерживаемом Ctrl.
'Ctrl + +/− — масштаб Y. ' # Клавиатурный масштаб Y сохраняет прежнее поведение без выбора стороны указателем.
'ЛКМ — перемещение по X; Ctrl + ЛКМ — по Y; '
'←/→ — перемещение по X; ↑/↓ — по Y; Home/End — начало/конец.')
+35 -7
View File
@@ -1,24 +1,27 @@
"""Reusable background workers for dense plots and viewport measurements."""
from collections import deque # Кольцевые очереди живого графика требуют единого снимка между проходами амплитудной статистики и частоты.
try:
from PySide6.QtCore import QObject, QRunnable, Signal
except ImportError:
from PySide2.QtCore import QObject, QRunnable, Signal
from set_devices.plot_decimation import pixel_envelope, viewport_samples
from set_devices.plot_decimation import pixel_envelope, render_envelope, viewport_samples # Рабочее задание поддерживает точный прежний формат и компактную экранную огибающую для аналогового графика.
from set_devices.oscilloscope import measurements
from set_devices.background_work import WorkBudget # Даже фоновый Python-расчёт должен регулярно отдавать GIL многочисленным обработчикам интерфейса Qt.
class EnvelopeSignals(QObject):
ready = Signal(str, object, object)
class EnvelopeJob(QRunnable):
def __init__(self, key, geometry, timestamps, values, left, right, width, signals):
def __init__(self, key, geometry, timestamps, values, left, right, width, signals, compact=False): # Необязательный режим оставляет прежний контракт внешних пользователей, а аналоговый график включает два экстремума на столбец.
super().__init__()
self.args = (timestamps, values, left, right, width)
self.key, self.geometry, self.signals = key, geometry, signals
self.select = render_envelope if compact else pixel_envelope # Выбор алгоритма выполняется один раз при создании задания, не внутри многомиллионного обхода.
def run(self):
try:
points = tuple(pixel_envelope(*self.args))
points = tuple(self.select(*self.args)) # Фоновый результат сохраняет точные пики и разрывы в согласованном с рисователем экранном бюджете.
except Exception:
points = None
try:
@@ -31,18 +34,43 @@ class NavigationSignals(QObject):
ready = Signal(int, object, object)
class _NavigationSamples: # Повторяемая выборка окна позволяет измерять исходные ряды без копии миллионов пар координат.
def __init__(self, times, values, bounds, check_cancelled): # Храним источники задания; вызывающий код создаёт снимок изменяемых очередей перед численными проходами.
self.times, self.values, self.bounds, self.check_cancelled = times, values, bounds, check_cancelled # Ссылки сохраняют исходную точность и порядок без экранного сокращения.
self.count = 0 # Число отсчётов определяется при первом полном проходе и включает нечисловые амплитуды, как прежний список.
def __iter__(self): # Статистики и частота независимо обходят то же окно, не удерживая промежуточные пары в памяти.
count = 0 # Каждый проход считает только точки внутри включительных временных границ.
for index, (x, y) in enumerate(viewport_samples(self.times, self.values, self.bounds.left, self.bounds.right)): # Индексируемый источник ищет окно двоичным поиском; потоковый сохраняет прежний порядок.
if index % 1024 == 0: # Проверяем отмену через 1024 просмотренных отсчёта, даже когда неупорядоченный ряд лежит вне окна.
self.check_cancelled() # Не продолжаем чтение старого диапазона после нового жеста навигации.
if self.bounds.left <= x <= self.bounds.right: # Включаем обе границы и все дубликаты временных меток, как исходная реализация.
count += 1 # Счётчик соответствует прежнему len(points), независимо от конечности амплитуды.
yield x, y # Пара существует только на время текущего численного прохода и не копируется в большой список.
self.count = count # Публикуем количество только после полного прохода, чтобы отменённая выборка не выдавала частичный результат.
class NavigationJob(QRunnable):
def __init__(self, revision, series, bounds, emitter):
def __init__(self, revision, series, bounds, emitter, cancelled=None): # Необязательное событие отмены сохраняет совместимость общих графиков и останавливает устаревшие измерения.
super().__init__()
self.revision, self.series, self.bounds, self.emitter = revision, series, bounds, emitter
self.cancelled = cancelled # Событие принадлежит одному заданию и не сбрасывается при запуске расчёта нового окна.
self.work_budget = WorkBudget(cancelled) # Короткие добровольные паузы предотвращают многосекундное ожидание GIL главным потоком.
def _check_cancelled(self): # Проверяем отмену на границах этапов и небольших блоков исходных отсчётов.
self.work_budget() # Одновременно прекращаем устаревшие измерения и регулярно освобождаем GIL для рисования и событий мыши.
def run(self):
stats = {}
try:
for key, times, values in self.series:
points = [(x, y) for x, y in viewport_samples(times, values, self.bounds.left, self.bounds.right)
if self.bounds.left <= x <= self.bounds.right]
stats[key] = measurements(points), len(points)
self._check_cancelled() # Не начинаем обход следующего канала после возобновления перемещения или изменения масштаба.
samples = _NavigationSamples(times, values, self.bounds, self._check_cancelled) # Повторяемый вид файлового окна устраняет копирование миллионов пар и сохраняет отмену чтения источника.
ordered = getattr(times, "ordered", isinstance(times, (list, tuple, range))) # Применяем тот же контракт упорядоченности, что и viewport_samples, включая смещённые файловые координаты.
points = tuple(samples) if isinstance(times, deque) or isinstance(values, deque) or not ordered else samples # Только живые очереди и неупорядоченный путь получают единый снимок для согласованных амплитуд и частоты.
result = measurements(points, self._check_cancelled) # Передаём отмену внутрь всех численных этапов, чтобы новый жест не ждал окончания полного среднего или СКЗ.
stats[key] = result, samples.count # Количество относится к завершённому проходу того же окна либо к первоначальному снимку живого ряда.
self._check_cancelled() # При отмене во время вычислений не продолжаем остальные каналы и не публикуем устаревшие значения.
self.emitter.ready.emit(self.revision, stats, self.bounds)
except Exception:
try:
@@ -6,6 +6,8 @@ from pathlib import Path
import subprocess
import tempfile
from threading import Lock
import math # Кодируем разрывы кривой нечисловыми значениями Y, сохраняя упорядоченные координаты X для нативного движка.
from set_devices.plot_decimation import pixel_envelope # Передаём движку только точки, различимые при текущем масштабе изображения.
try:
from PySide6.QtCore import QObject, QRunnable, Signal
except ImportError:
@@ -44,19 +46,26 @@ class NativePlotEngine:
self.process = subprocess.Popen([str(self.executable)], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=env, bufsize=0,
creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), cwd=self.executable.parent)
key = (id(times), id(values))
key = (id(times), id(values), len(times), len(values), bounds.left, bounds.right, width) # Выборка зависит от исходного снимка, окна X и физической ширины изображения; масштаб Y её не меняет.
record = self.records.get(key)
if record is None:
self.serial += 1
path = Path(self.directory.name) / f'{self.serial}.bin'
points = [] # Временный список ограничен экранной шириной и не содержит миллионов исходных измерений.
for point in pixel_envelope(times, values, bounds.left, bounds.right, width): # Сохраняем первый, минимальный, максимальный и последний отсчёты каждого пиксельного столбца.
if point is not None: # Конечные измерения передаются с исходными координатами и амплитудами.
points.append(point) # Экстремумы остаются видимыми даже для импульса длиной в один исходный отсчёт.
elif points and math.isfinite(points[-1][1]): # Повторные маркеры одного разрыва не должны увеличивать размер передаваемого ряда.
points.append((points[-1][0], math.nan)) # Дублированная метка времени и NaN в Y разрывают путь без нарушения порядка X.
with path.open('wb') as stream:
array('d', times).tofile(stream)
array('d', values).tofile(stream)
record = (path, times, values)
array('d', (point[0] for point in points)).tofile(stream) # Записываем только экранные координаты X в прежнем формате 64-битных чисел.
array('d', (point[1] for point in points)).tofile(stream) # Массив Y соответствует сокращённому X и включает маркеры разрывов.
record = (path, times, values, len(points)) # Ссылки сохраняют идентичность исходного снимка; длина описывает только передаваемый экранный ряд.
self.records[key] = record
if len(self.records) > 4:
self.records.pop(next(iter(self.records)))
request = dict(file=str(record[0]), count=len(times), width=width, height=height,
expired = self.records.pop(next(iter(self.records))) # Оставляем четыре последние экранные выборки, как и в кэше нативного процесса.
expired[0].unlink(missing_ok=True) # Освобождаем устаревший временный файл, чтобы навигация не накапливала данные на диске.
request = dict(file=str(record[0]), count=record[3], width=width, height=height, # Нативному процессу сообщается фактическое число отрисовываемых точек вместо полного размера записи.
left=bounds.left, right=bounds.right, bottom=bounds.bottom, top=bounds.top, color=color)
self.process.stdin.write((json.dumps(request)+'\n').encode())
self.process.stdin.flush()
+175 -37
View File
@@ -7,8 +7,11 @@
from __future__ import annotations
from dataclasses import dataclass
from collections import deque # Храним ограниченную историю FD для отдельного просмотра диагностики.
from ..can_adapter_settings import SlcanOptions, FD_LENGTHS # Профиль прошивки определяет команды и допустимые длины CAN FD.
import re # Поиск ближайшего служебного байта за один проход сохраняет скорость при пакетном приёме большого числа CAN-кадров.
from .qt_compat import QObject, Signal
from .qt_compat import QObject, Signal, QElapsedTimer, QTimer # Таймеры ограничивают ожидание ответов и откладывают выдачу кадров до окончания настройки.
from .qt_compat import QSerialPort, QSerialPortInfo
@@ -33,11 +36,7 @@ class SlcanChannel:
return "slcan"
_BITRATE_COMMANDS = {
10_000: "S0", 20_000: "S1", 50_000: "S2", 100_000: "S3",
125_000: "S4", 250_000: "S5", 500_000: "S6", 800_000: "S7",
1_000_000: "S8",
}
_RECORD_END = re.compile(rb"[\r\n\x07]") # CR, LF и BEL имеют коды ASCII 13, 10 и 7; любой из них завершает запись протокола.
def encode_frame(can_id: int, data: bytes, *, extended: bool | None = None) -> bytes:
@@ -80,6 +79,7 @@ class SlcanAdapter(QObject):
frame_received = Signal(int, bytes)
capture_received = Signal(int, bytes, bool, bool)
connection_lost = Signal(str)
fd_received = Signal(int, bytes, bool, int, int) # Отдельный маршрут FD исключает ошибочный разбор кадра как Classic CAN.
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
@@ -88,10 +88,19 @@ class SlcanAdapter(QObject):
self._serial.errorOccurred.connect(self._error)
self._buffer = bytearray()
self._listen_only = False
self.options = SlcanOptions() # Без расширенных параметров остаётся прежний подтверждаемый протокол Lawicel.
self.fd_history = deque(maxlen=500) # Не более 500 последних FD-кадров сохраняется между открытиями окна просмотра.
self._query_prefix = None # Диагностические ответы отличаем от подтверждений настройки и CAN-кадров.
self._query_reply = None # Полная строка ответа сохраняется только во время явного диагностического запроса.
self._initializing = False # Открытие COM-порта ещё не подтверждает настройку CAN-контроллера.
self._waiting_command = False # Отличаем подтверждение текущей команды от служебных строк рабочего потока.
self._command_reply: bool | None = None # None означает ожидание, True — CR/ACK, False — BEL/отказ прошивки.
self._after_cr = False # Следующий LF после CR относится к тому же окончанию строки, даже при раздельных USB-пакетах.
self._pending_frames: list[tuple[int, bytes, bool]] = [] # Кадры при открытии CAN сохраняются до готовности подписчиков монитора.
@property
def connected(self) -> bool:
return self._serial.isOpen()
return self._serial.isOpen() and not self._initializing # Считаем соединение готовым только после подтверждения всех команд настройки.
def scan(self) -> list[SlcanChannel]:
# Windows не предоставляет надёжного признака протокола SLCAN.
@@ -102,11 +111,11 @@ class SlcanAdapter(QObject):
def connect_channel(self, channel: SlcanChannel, bitrate: int,
host_baudrate: int = 115200,
listen_only: bool = False) -> None:
listen_only: bool = False, *, options: SlcanOptions | None = None) -> None: # Расширенные параметры передаются отдельно от прежнего контракта подключения.
selected = options or SlcanOptions() # Дефолт сохраняет C, Sn и O/L с ожиданием CR/BEL.
commands = selected.commands(bitrate, listen_only) # Проверяем весь профиль до закрытия прежнего соединения и открытия COM.
self.disconnect_channel()
command = _BITRATE_COMMANDS.get(bitrate)
if command is None:
raise SlcanError(f"SLCAN не поддерживает {bitrate} бит/с")
self.options = selected # Настройка ожидания команд нужна уже во время инициализации порта.
self._serial.setPortName(channel.path)
if host_baudrate not in (9600, 19200, 38400, 57600, 115200,
230400, 460800, 921600):
@@ -119,23 +128,67 @@ class SlcanAdapter(QObject):
if not self._serial.open(QSerialPort.OpenModeFlag.ReadWrite):
raise SlcanError(f"Не удалось открыть {channel.path}: {self._serial.errorString()}")
self._buffer.clear()
self.fd_history.clear() # Журнал нового подключения не содержит сообщений прежнего устройства.
self._listen_only = listen_only
# Close first so reconnecting also works after an interrupted session.
open_command = "L" if listen_only else "O"
if self._serial.write(f"C\r{command}\r{open_command}\r".encode("ascii")) < 0:
message = self._serial.errorString()
self._serial.close()
self._listen_only = False
raise SlcanError(f"Не удалось настроить SLCAN: {message}")
self._initializing = True # Во время настройки не выдаём ложный статус готовности и первые кадры с прежней скоростью CAN.
try: # Любой отказ настройки должен закрыть порт и разрешить повторную попытку подключения.
self._command("C", allow_rejection=True) # Закрываем прежний канал; BEL допустим, если канал уже закрыт по правилам Lawicel.
for command in commands: # Профиль определяет порядок скорости, режима, фильтров, повторов и открытия.
self._command(command) # Любой отказ прекращает настройку без опасного возврата к активному режиму.
except SlcanError: # Отказ прошивки, неполная запись и отсутствие ответа одинаково означают неудачное подключение.
self._serial.close() # Освобождаем COM-порт, чтобы пользователь мог исправить скорость или тип адаптера.
self._buffer.clear() # Остатки неудачного обмена не должны повреждать строки следующего подключения.
self._pending_frames.clear() # Не отправляем в монитор данные сеанса, который не удалось открыть.
self._listen_only = False # Сбрасываем ограничение режима вместе с неудачным подключением.
raise # Сохраняем точную причину отказа для статусной строки вызывающей вкладки.
finally: # Признак настройки должен сбрасываться как при успехе, так и при исключении.
self._initializing = False # После возврата вызывающая вкладка сможет установить правильную скорость и статус.
QTimer.singleShot(0, self._read) # Выдаём накопленные первые кадры в следующем цикле Qt, после обновления настроек монитора.
def _command(self, command: str, *, allow_rejection: bool = False) -> None: # Последовательный обмен сохраняет синхронный контракт подключения всех вкладок.
self._read() # Убираем ранее пришедшие ответы до начала ожидания новой команды.
self._command_reply = None # Старое подтверждение не может подтверждать следующую настройку.
self._waiting_command = True # Пустая строка CR или отдельный BEL теперь относятся к текущей команде.
packet = (command + "\r").encode("ascii") # Каждая команда Lawicel передаётся отдельной строкой с завершающим ASCII CR.
elapsed = QElapsedTimer() # Монотонное время не зависит от перевода системных часов Windows.
elapsed.start() # Общий срок ожидания включает запись команды и все фрагменты ответа.
try: # Флаг ожидания обязательно очищается при любом результате обмена.
if self._serial.write(packet) != len(packet): # Частичная локальная запись также не гарантирует передачу полной команды адаптеру.
raise SlcanError(f"SLCAN: не удалось передать {command}: {self._serial.errorString()}") # Указываем неотправленную команду и причину драйвера.
if self.options.profile in ("canable", "canable2"): # Эти прошивки не предоставляют CR/BEL для подтверждения конфигурации.
if self._serial.bytesToWrite() and not self._serial.waitForBytesWritten(self.options.command_timeout_ms): # Ждём фактическую передачу очереди Qt в драйвер, а не выдуманный ACK.
raise SlcanError(f"CANable: не удалось отправить {command}") # Ошибка локального драйвера не считается успешным подключением.
self._serial.waitForReadyRead(20) # Пауза до 20 мс даёт прошивке обработать отдельную команду USB CDC.
self._read() # Сохраняем уже пришедшие CAN-кадры, не требуя неподдерживаемого служебного ответа.
if self._command_reply is False or not self._serial.isOpen(): # Не игнорируем явный BEL и отключение даже в профиле без ACK.
raise SlcanError(f"CANable: отказ при выполнении {command}") # Пользователь получает конкретный этап неудачной настройки.
return # Статус интерфейса отдельно сообщает, что профиль не подтверждает команды.
while self._command_reply is None and self._serial.isOpen(): # CAN-кадры между служебными ответами не заменяют подтверждение команды.
remaining = self.options.command_timeout_ms - elapsed.elapsed() # Общий выбранный тайм-аут включает все фрагменты ответа команды.
if remaining <= 0: # Поток посторонних байтов не должен удерживать подключение в ожидании навсегда.
break # Далее сформируем понятную ошибку отсутствия SLCAN-ответа.
if self._serial.bytesAvailable() or self._serial.waitForReadyRead(remaining): # Используем уже поступившие байты или ждём только в пределах общего срока.
self._read() # Тот же обработчик разбирает ответы CR/BEL и сохраняет встреченные CAN-кадры.
else: # Драйвер завершил ожидание без новых данных либо порт был отключён.
break # Не запускаем повторный полный тайм-аут на ту же команду.
if not self._serial.isOpen(): # Физическое отключение во время настройки не является подтверждением команды.
raise SlcanError(f"SLCAN: порт закрыт при выполнении {command}: {self._serial.errorString()}") # Сообщаем о потере порта на конкретном этапе.
if self._command_reply is None: # Успешное открытие COM и запись в драйвер сами по себе не доказывают поддержку Lawicel.
raise SlcanError(f"SLCAN: нет ответа на {command}; проверьте тип адаптера и скорость COM") # Направляем пользователя к настройкам, влияющим на обмен ASCII-командами.
if not self._command_reply and not allow_rejection: # Только отказ повторного C при уже закрытом канале допускается протоколом настройки.
raise SlcanError(f"SLCAN: адаптер отклонил команду {command} (BEL)") # Отказ скорости или режима CAN больше не маскируется статусом подключения.
finally: # После команды подтверждения рабочего потока больше не меняют результат настройки.
self._waiting_command = False # Возвращаем обработчик приёма в обычный режим.
def disconnect_channel(self) -> None:
if not self._serial.isOpen():
return
self._serial.write(b"C\r")
self._serial.waitForBytesWritten(100)
self._serial.close()
self._buffer.clear()
self._listen_only = False
if self._serial.isOpen(): # Команда закрытия нужна только для реально открытого COM-порта.
self._serial.write(b"C\r") # Просим прошивку остановить CAN до освобождения порта.
self._serial.waitForBytesWritten(100) # Ограничиваем ожидание отправки закрытия ста миллисекундами, чтобы не задерживать выход.
self._serial.close() # Освобождаем порт независимо от доступности прошивки.
self._buffer.clear() # Частичная строка прежнего сеанса не должна приклеиваться к первому кадру нового подключения.
self._pending_frames.clear() # Отложенный обработчик Qt не должен выдавать кадры после отключения или переподключения.
self._after_cr = False # Новый сеанс начинает разбор окончаний строк без состояния предыдущего CR.
self._listen_only = False # Отключённый транспорт не сохраняет ограничение старого режима.
def send(self, can_id: int, data: bytes, *, extended: bool = True) -> None:
if not self.connected:
@@ -149,24 +202,109 @@ class SlcanAdapter(QObject):
raise SlcanError(f"SLCAN не передал CAN-кадр: {self._serial.errorString()}")
def _read(self) -> None:
self._buffer.extend(bytes(self._serial.readAll()))
while b"\r" in self._buffer:
raw, _, remainder = self._buffer.partition(b"\r")
self._buffer[:] = remainder
frame = decode_frame(raw)
if frame is not None:
self.capture_received.emit(frame[0], frame[1], raw[:1] == b'T', False)
self.frame_received.emit(*frame)
if not self._serial.isOpen(): # Отложенное чтение после закрытия не должно обращаться к неоткрытому драйверу.
return # Отключение уже очистило очередь и неполную строку.
self._buffer.extend(bytes(self._serial.readAll())) # Накапливаем фрагменты USB/COM до полного кадра или служебного ответа.
while self._buffer: # За одно чтение драйвер может вернуть несколько кадров и подтверждений команд.
if self._after_cr: # CR+LF считается одним окончанием, даже если LF пришёл в следующем чтении.
self._after_cr = False # Состояние относится только к ближайшему байту после CR.
if self._buffer[0] == 10: # ASCII LF имеет код 10 и не является началом следующего SLCAN-кадра.
del self._buffer[0] # Удаляем дополнительный LF, чтобы он не мешал распознаванию t/T и не давал второй ACK.
continue # Продолжаем с первого байта следующей записи, если он уже поступил.
boundary = _RECORD_END.search(self._buffer) # Ищем первый разделитель, не сканируя весь остаток пакета отдельно для отсутствующих LF и BEL.
if boundary is None: # Неполную строку нельзя декодировать до поступления окончания.
break # Сохраняем её в буфере для следующего readyRead.
end = boundary.start() # Ближайшая граница сохраняет исходный порядок кадров и подтверждений команд.
raw = bytes(self._buffer[:end]) # Получаем строку без служебного разделителя.
separator = self._buffer[end] # Тип разделителя отличает подтверждение команды от BEL.
del self._buffer[:end + 1] # Удаляем обработанную запись, не теряя оставшуюся часть потока.
self._after_cr = separator == 13 # ASCII CR имеет код 13; следующий LF принадлежит этому же окончанию.
if separator == 7: # ASCII BEL имеет код 7 и сообщает об отклонении последней команды.
if self._waiting_command and self._command_reply is None: # Принимаем только первый ответ текущего этапа настройки.
self._command_reply = False # Сохраняем отказ для проверки в последовательном обмене командами.
continue # BEL не должен приклеиваться к следующему CAN-кадру.
if self.options.fd and raw[:1] in (b"d", b"D", b"b", b"B") and self._receive_fd(raw): # Только корректная FD-строка забирается отдельным маршрутом; версия CANable может начинаться с HEX-буквы.
continue # Даже FD с данными до восьми байтов не меняет тип при передаче подписчикам.
frame = decode_frame(raw) # Кадры STD/EXT декодируются прежним публичным кодеком.
if frame is not None: # Служебные строки и ответы прошивки не попадают в общий монитор CAN.
self._pending_frames.append((frame[0], frame[1], raw[:1] == b"T")) # Сохраняем данные и признак EXT, включая первые кадры во время открытия.
elif raw and self._query_prefix is not None and raw.startswith(self._query_prefix): # Диагностические строки распознаются только после исключения CAN-кадров.
self._query_reply = raw.decode("ascii", errors="replace") # Версия CANable не имеет префикса V, поэтому допускается пустой ожидаемый префикс.
elif not raw and self._waiting_command and self._command_reply is None: # Пустая строка CR означает ACK по Lawicel; строка с CAN-кадром не подтверждает команду.
self._command_reply = True # Разрешаем отправку следующей команды только после реального подтверждения.
if not self._initializing: # Первые кадры выдаются после возврата подключения, когда вкладка уже установила скорость CAN.
pending, self._pending_frames = self._pending_frames, [] # Отделяем очередь до вызова подписчиков, чтобы повторное чтение не дублировало кадры.
for can_id, data, extended in pending: # Выдаём все накопленные кадры в исходном порядке.
self.capture_received.emit(can_id, data, extended, False) # Полные метаданные наполняют общий CAN-монитор.
self.frame_received.emit(can_id, data) # Совместимый сигнал сохраняет работу существующих клиентов ProtoCAN.
def _error(self, error: QSerialPort.SerialPortError) -> None:
if error in (QSerialPort.SerialPortError.NoError,
QSerialPort.SerialPortError.NotOpenError):
return
if error in (QSerialPort.SerialPortError.NoError, # Сигнал NoError не означает потерю соединения.
QSerialPort.SerialPortError.NotOpenError, # Обращение после закрытия не требует повторного отключения.
QSerialPort.SerialPortError.TimeoutError): # Тайм-аут синхронного ожидания проверяет сама команда; порт при этом остаётся исправным.
return # Не превращаем отсутствие ответа на команду в ошибку физического соединения.
if self._serial.isOpen():
message = self._serial.errorString()
self._serial.close()
self._listen_only = False
self.connection_lost.emit(f"Связь с SLCAN прервана: {message}")
if not self._initializing: # При настройке исключение возвращает connect_channel, избегая повторного отключения из обработчика GUI.
self.connection_lost.emit(f"Связь с SLCAN прервана: {message}") # Во время обычного приёма уведомляем вкладку о настоящем обрыве.
def close(self) -> None:
self.disconnect_channel()
def _receive_fd(self, raw: bytes) -> bool: # Результат отличает корректный FD от диагностической строки с похожей первой буквой.
extended = raw[:1] in (b"D", b"B") # Заглавная буква означает идентификатор длиной восемь HEX-цифр.
width = 8 if extended else 3 # Стандартный ID занимает три HEX-цифры в ASCII-протоколе.
try: # Сохраняем устойчивость потока при повреждённых USB/COM-строках.
identifier = int(raw[1:1 + width], 16) # Извлекаем числовой ID без префикса команды.
length = FD_LENGTHS[int(raw[1 + width:2 + width], 16)] # Четырёхбитный DLC переводится в фактическое число байтов.
payload = bytes.fromhex(raw[2 + width:].decode("ascii")) # Для CANable FD не документирован дополнительный суффикс временной метки.
except (ValueError, UnicodeError, IndexError): # Неполный ID, DLC или HEX-пара отклоняют только текущую запись.
return False # Неподходящая строка ещё может оказаться ответом версии CANable.
if len(payload) != length or not 0 <= identifier <= (0x1FFFFFFF if extended else 0x7FF): # Не обрезаем лишние байты и не принимаем отрицательный ID или идентификатор другого формата.
return False # Повреждённые данные не должны выглядеть успешным приёмом.
record = (identifier, payload, extended, 2 | (4 if raw[:1] in (b"b", b"B") else 0), 0) # CANable не предоставляет аппаратную метку; ноль явно обозначает её отсутствие.
self.fd_history.append(("RX", record)) # История ограничена размером deque и доступна после открытия диагностики.
self.fd_received.emit(*record) # В мост и прикладной декодер Classic этот кадр не поступает.
return True # Вызывающий обработчик не должен повторно трактовать этот кадр как служебный ответ.
def send_fd(self, can_id: int, data: bytes, *, extended: bool = True, brs: bool = True) -> None: # Используем документированные команды расширения CANable 2.0.
if not self.connected or self._listen_only or not self.options.fd: # Проверка защищает передачу независимо от состояния кнопок GUI.
raise SlcanError("Передача FD требует активного подключения CAN FD") # Пассивный режим не допускает обхода через API.
if len(data) not in FD_LENGTHS or not 0 <= can_id <= (0x1FFFFFFF if extended else 0x7FF): # Разрешаем только точные длины DLC без скрытого дополнения.
raise SlcanError("Неверный CAN ID или длина CAN FD (0…8, 12, 16, 20, 24, 32, 48, 64)") # Сообщаем фактический набор допустимых размеров.
prefix = ("B" if extended else "b") if brs else ("D" if extended else "d") # BRS выбирается буквой команды, а не полем ASCII-пакета.
packet = f"{prefix}{can_id:0{8 if extended else 3}X}{FD_LENGTHS.index(len(data)):X}{data.hex().upper()}\r".encode("ascii") # ID, DLC и данные образуют одну завершённую строку SLCAN.
if self._serial.write(packet) != len(packet): # Частичная запись не гарантирует доставку последнего CR в прошивку.
raise SlcanError("SLCAN не передал CAN FD") # Причина остаётся видимой в диагностическом окне отправки.
self.fd_history.append(("TX queued", (can_id, bytes(data), extended, 2 | (4 if brs else 0), 0))) # Локальная запись не выдаётся за подтверждение передачи на CAN.
def diagnostics(self) -> str: # Явная диагностика не меняет режим CAN и не выполняется фоновым таймером.
if not self.connected: # Чтение версии не должно захватывать ещё не выбранный COM-порт.
raise SlcanError("Сначала подключите адаптер") # Настройка и жизненный цикл подключения остаются под управлением пользователя.
lines = [] # Ответы V и N читают сведения, F дополнительно сбрасывает индикатор ошибки согласно Lawicel.
canable = self.options.profile != "lawicel" # CANable возвращает V без буквенного префикса, а регистр E иногда без завершающего CR.
if canable: # Отсутствие подтверждения настроек сообщается независимо от успешного чтения версии.
lines.append(f"Профиль {self.options.profile}: команды конфигурации без ACK/NACK; применение настроек не подтверждается прошивкой.") # Диагностика не превращает ответ V в подтверждение предыдущих M/A/Y.
for command in (("V", "E") if canable else ("V", "N", "F")): # Набор фиксирован спецификацией выбранной прошивки.
self._read() # Сначала выдаём уже накопленные CAN-кадры, не смешивая их с ответом запроса.
self._query_prefix, self._query_reply = (b"" if canable else command.encode("ascii")), None # CANable не использует префикс V в строке версии.
elapsed = QElapsedTimer() # Ограничиваем полный запрос монотонным временем, а не числом фрагментов.
elapsed.start() # Тайм-аут включает отправку и все поступившие строки CAN.
try: # Состояние запроса очищается также при ошибке записи или обрыве порта.
packet = (command + "\r").encode("ascii") # Все команды завершаются одиночным CR протокола Lawicel.
if self._serial.write(packet) != len(packet): # Локальная ошибка передачи должна быть видна вызывающему коду.
raise SlcanError(f"Не удалось отправить {command}") # Указываем конкретный отказавший диагностический запрос.
while self._query_reply is None and self.connected: # Приём CAN-кадров продолжается тем же обработчиком.
remaining = self.options.command_timeout_ms - elapsed.elapsed() # Поток кадров не должен бесконечно продлевать ожидание версии.
if remaining <= 0 or not (self._serial.bytesAvailable() or self._serial.waitForReadyRead(remaining)): # Отсутствие ответа завершает только текущий запрос.
break # Следующие строки диагностики покажут, какая команда не поддерживается.
self._read() # Ответ распознаётся по префиксу, а CAN продолжает поступать обычным подписчикам.
if command == "E" and self._buffer.startswith(b"CANable Error Register:"): # Документированная исходная прошивка может возвращать E без CR.
self._query_reply = bytes(self._buffer).decode("ascii", errors="replace") # Такой ответ принимаем только после завершения ожидания, не обрезая поток CAN.
self._buffer.clear() # Нетерминированная служебная строка не должна приклеиваться к следующему CAN-кадру.
lines.append(self._query_reply or f"{command}: нет ответа") # Отсутствие данных не заменяем выдуманными версией или нулевыми ошибками.
finally: # Следующий обычный приём не должен сохранять посторонние строки как ответ старого запроса.
self._query_prefix = None # Завершаем явную диагностическую транзакцию.
return "\n".join(lines) # Текст можно скопировать для анализа состояния устройства.
@@ -1,5 +1,6 @@
"""Debounced viewport statistics shared by desktop charts."""
import time
from threading import Event # Сигнал отмены позволяет фоновому расчёту прекращать обработку старого окна при продолжении масштабирования.
from dataclasses import replace
try:
from PySide6.QtCore import QObject, QTimer, QThreadPool, Qt
@@ -16,6 +17,7 @@ class ViewportNavigation(QObject):
self.chart = chart
self._navigation_revision = 0
self._navigation_busy = False
self._navigation_cancel = Event() # Отдельное потокобезопасное событие относится к текущему фоновому расчёту измерений.
self._navigation_emitter = _NavigationSignals(self)
self._navigation_emitter.ready.connect(self._navigation_ready, Qt.ConnectionType.QueuedConnection)
self._navigation_timer = QTimer(self)
@@ -25,6 +27,7 @@ class ViewportNavigation(QObject):
def _navigation_changed(self):
self._navigation_revision += 1
self._navigation_cancel.set() # Сразу отменяем статистику предыдущего окна, чтобы она не конкурировала с новой отрисовкой миллионов отсчётов.
if max((len(s.values) for s in self.chart.plot._series), default=0) <= 20000:
self.chart._scope_measurements()
self.chart._refresh_processing()
@@ -43,15 +46,16 @@ class ViewportNavigation(QObject):
self._navigation_timer.start()
return
self._navigation_busy = True
self._navigation_cancel = Event() # После окончания перемещения создаём новый независимый сигнал отмены для актуального окна.
bounds = self.chart.plot.visible_bounds
panel = self.chart.processing
offset = self.chart._plot_time_offset if not self.chart.fft.isChecked() else 0.
# Navigation changes the next calculation's domain; retained curves keep
# their original domain. Avoid preparing millions of inputs on the UI.
panel._snapshot = replace(panel._snapshot, x_range=(bounds.left + offset, bounds.right + offset))
series = [(s.key, s.timestamps, s.values) for s in self.chart.plot._series if not s.discrete]
QThreadPool.globalInstance().start(_NavigationJob(self._navigation_revision, series, bounds,
self._navigation_emitter))
series = [(s.key, s.timestamps, s.values) for s in self.chart.plot._series if s.visible and not s.discrete] # Скрытые каналы не занимают процессор повторным расчётом статистики при перемещении графика.
QThreadPool.globalInstance().start(_NavigationJob(self._navigation_revision, series, bounds, # Фоновый расчёт использует полные исходные значения только актуального видимого окна.
self._navigation_emitter, self._navigation_cancel)) # Передаём событие отмены вместе с существующими ревизией и каналом результата.
def _navigation_ready(self, revision, stats, bounds):
self._navigation_busy = False