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

288 lines
33 KiB
Python

"""Reusable CAN monitor controls for host applications."""
from datetime import datetime
from pathlib import Path
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, 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
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. Для УМП / 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("Без парса")
layout.addWidget(self.raw)
self.reset_button = QPushButton("Сброс")
self.reset_button.clicked.connect(owner.clear)
self.reset_button.setToolTip("Очистить таблицы, счётчики и буфер; уже записанный CSV сохраняется")
layout.addWidget(self.reset_button)
self.pause = QPushButton("Пауза")
self.pause.clicked.connect(owner._toggle_pause)
self.pause.setToolTip("Пауза экрана; буфер сессии и запись продолжаются")
layout.addWidget(self.pause)
self.record = QPushButton("● Запись")
self.record.clicked.connect(self.toggle_record)
self.record.setToolTip("Записать буфер сессии и последующие кадры в CSV")
layout.addWidget(self.record)
self.save_button = QPushButton("Сохранить…")
self.save_button.setToolTip("Сохранить весь буфер сессии в CSV (данные HEX), включая кадры вне таблицы")
self.save_button.clicked.connect(self.save_session)
layout.addWidget(self.save_button)
self.data_format = QPushButton("Data: HEX")
self.data_format.setCheckable(True)
self.data_format.setToolTip("Переключить отображение байтов Data: HEX / DEC")
self.data_format.toggled.connect(self._change_data_format)
layout.addWidget(self.data_format)
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(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())
def _change_data_format(self, decimal):
self.data_format.setText("Data: DEC" if decimal else "Data: HEX")
self.owner._refresh_data_format()
self.refilter()
def filter_panel(self):
panel = QWidget()
layout = QVBoxLayout(panel)
layout.setContentsMargins(0, 0, 0, 0)
row = QHBoxLayout()
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("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)
headers = ("Последний приём",) + CanSession.HEADER[1:6] + ("Счётчик", "Данные", "Расшифровка")
self.history = QTableWidget(0, len(headers))
self.history.setHorizontalHeaderLabels(headers)
self.history.setToolTip("Уникальные кадры и число их повторений с момента сброса")
self.history.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.history.horizontalHeader().setStretchLastSection(True)
self.history.setColumnWidth(0, 190)
self.history.setColumnWidth(7, 220)
self.history.setColumnWidth(8, 500)
self.history.verticalHeader().setVisible(False)
self.history.setMinimumHeight(180)
self.history.setVisible(False)
layout.addWidget(self.history)
return panel
def ingest(self, identifier, data, extended, rtr=False, source="CAN", timestamp=None):
try:
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("● Запись")
self._dirty = True
def reset(self):
self.session.reset()
self._dirty = True
self.refilter()
self.status.setText("Буфер: 0")
def save_session(self):
if not self.session.counts:
QMessageBox.information(self, "Сохранение CAN", "Буфер сессии пуст.")
return
try:
directory = Path(self.directory())
directory.mkdir(parents=True, exist_ok=True)
suggested = directory / datetime.now().strftime("session_%Y%m%d_%H%M%S.csv")
path, _ = QFileDialog.getSaveFileName(
self, "Сохранить буфер CAN", str(suggested), "CSV (*.csv)")
if not path:
return
saved = self.session.save(path)
except (OSError, ValueError) as exc:
QMessageBox.critical(self, "Сохранение CAN", str(exc))
return
self.save_button.setToolTip("Сохранено: " + str(saved))
QMessageBox.information(self, "Сохранение CAN", "Сохранено: " + str(saved))
def toggle_record(self):
try:
if self.session.output is None:
path = self.session.start(self.directory())
self.record.setText("■ Стоп записи")
self.record.setToolTip(str(path))
else:
self.session.stop()
self.record.setText("● Запись")
except OSError as exc:
QMessageBox.critical(self, "Запись CAN", str(exc))
def refresh(self):
try:
self.session.flush()
except OSError as exc:
self.status.setText("Ошибка записи: " + str(exc))
return
if not self.owner._paused and self._dirty:
self._dirty = False
self.status.setText("Буфер: %d" % sum(self.session.counts.values()))
self._update_history(self._history_needs_reset) # Обычный такт обрабатывает лишь новые данные; полная сверка нужна только после ошибки записи.
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) # Готовая пачка отображается за одно обновление виджета.