From 0c4eab4615a464f53ef76f77dee983991bbe35e4 Mon Sep 17 00:00:00 2001 From: Andrey Date: Sat, 17 Oct 2026 14:43:13 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=BD?= =?UTF-8?q?=D1=81=D1=82=D1=80=D1=83=D0=BA=D1=82=D0=BE=D1=80=20CAN-=D0=BA?= =?UTF-8?q?=D0=B0=D0=B4=D1=80=D0=BE=D0=B2=20=D0=B4=D0=BB=D1=8F=20Qt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- python/README.md | 7 + .../set_devices/qt_ports/can_frame_builder.py | 175 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 python/set_devices/qt_ports/can_frame_builder.py diff --git a/python/README.md b/python/README.md index 4e2b50d..30ba13f 100644 --- a/python/README.md +++ b/python/README.md @@ -24,6 +24,13 @@ Saleae SDK подключается при обращении к устройс в приложении. Общая панель обработки графиков размещена в `qt_ports.plot_processing`: [контракт, подключение нового графика и примеры](../c/set-protocol/docs/PLOT_PROCESSING.md). +Конструкторы CAN-адаптера, CAN DevBoard и RS485 DevBoard используют общий +компонент `set_devices.qt_ports.can_frame_builder.CanFrameFields`. Он задаёт +одинаковые поля и исходный режим «Обычный CAN — ID / DLC / Data», сохраняет +альтернативный режим SETCAN и сообщает об изменениях сигналом `changed`. +`frame_values()` возвращает проверенные ID, IDE, Data и необязательный Body; +упаковка конкретного транспорта и отправка остаются у приложения. + Пути к нативным библиотекам задаёт потребитель перед импортом: `SETPROTOCOL_LIBRARY`, при необходимости `ALTERA_LOGIC_LIBRARY`, `CANDLE_LIBRARY`. Порты не ищут DLL в каталогах SETGUI. C99-ядро собирается из `c/set-protocol`, diff --git a/python/set_devices/qt_ports/can_frame_builder.py b/python/set_devices/qt_ports/can_frame_builder.py new file mode 100644 index 0000000..0e99116 --- /dev/null +++ b/python/set_devices/qt_ports/can_frame_builder.py @@ -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_ разделяют три независимых состояния.