"""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) # Готовая пачка отображается за одно обновление виджета.