Files
templates/python/set_devices/qt_ports/can_frame_builder.py

176 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Общие поля конструктора 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_ разделяют три независимых состояния.