import json import os import weakref from functools import partial from copy import deepcopy from PySide6.QtWidgets import * from PySide6.QtCore import * from PySide6.QtGui import * from .bus_operations import validate_command, table_entries_after, PBUS_ERROR_LIMIT # Обе шины и общий макрос используют один проверенный алгоритм адресации. from .pbus_panel import PbusPanel, CompactControls, _FlowLayout # Макросы используют существующий перенос элементов слева направо. from .exchange_queue import exchange_queue # Один протокол разделяет одну очередь между шинами, макросами и памятью. class _MacroActionIcon(QIconEngine): """Scalable action icons, painted with the button's current theme color.""" def __init__(self, action, button): super().__init__() self.action = action self.button = weakref.ref(button) def clone(self): return _MacroActionIcon(self.action, self.button()) def paint(self, painter, rect, mode, state): button = self.button() palette = button.palette() if button is not None else QApplication.palette() group = QPalette.Disabled if mode == QIcon.Disabled else QPalette.Active painter.save() painter.setRenderHint(QPainter.Antialiasing) side = min(rect.width(), rect.height()) painter.translate(rect.x() + (rect.width() - side) / 2, rect.y() + (rect.height() - side) / 2) painter.scale(side / 24, side / 24) painter.setPen(QPen(palette.color(group, QPalette.ButtonText), 1.8, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) painter.setBrush(Qt.NoBrush) if self.action == "edit": path = QPainterPath(QPointF(4, 16)) for x, y in ((15, 5), (19, 9), (8, 20), (3, 21), (4, 16)): path.lineTo(x, y) painter.drawPath(path) painter.drawLine(QPointF(13, 7), QPointF(17, 11)) else: painter.drawLine(QPointF(4, 6), QPointF(20, 6)) painter.drawRoundedRect(QRectF(6, 6, 12, 15), 1.5, 1.5) painter.drawPolyline(QPolygonF([QPointF(9, 6), QPointF(9, 3), QPointF(15, 3), QPointF(15, 6)])) painter.drawLine(QPointF(10, 10), QPointF(10, 17)) painter.drawLine(QPointF(14, 10), QPointF(14, 17)) painter.restore() def pixmap(self, size, mode, state): pixmap = QPixmap(size) pixmap.fill(Qt.transparent) painter = QPainter(pixmap) self.paint(painter, QRect(QPoint(), size), mode, state) painter.end() return pixmap class SerialBus: """ВЫСОКОУРОВНЕВЫЙ протокол для работы с серийной шиной (только через регистры)""" def __init__(self, raw_protocol): self.raw = raw_protocol # Используем низкоуровневый протокол def _execute(self, write, tetrad, register, data, device_address): from protocan.tms_terminal import bus_steps try: steps = bus_steps(write, tetrad, register, data) except (ValueError, RuntimeError) as error: return False, str(error) result = None for address, value in steps: success, result = (self.raw.peek(address, device_address) if value is None else self.raw.poke(address, value, device_address)) if not success: return False, result return True, result # Сохраняем пакет последнего POKE также для отображения подтверждения записи в собственном журнале SBUS. def write_bus(self, data_word, address_tetrad, register_num=0, device_address=1): return self._execute(True, address_tetrad, register_num, data_word, device_address) def read_bus(self, address_tetrad, register_num=0, device_address=1): return self._execute(False, address_tetrad, register_num, 0, device_address) class MacroCommandDialog(QDialog): """Диалог для добавления/редактирования команды макроса""" def __init__(self, parent=None, command=None): super().__init__(parent) self.command = command if command else {} self.init_ui() def init_ui(self): self.setWindowTitle("Команда макроса") self.setMinimumWidth(400) layout = QVBoxLayout() # Тип команды type_layout = QHBoxLayout() type_layout.addWidget(QLabel("Тип команды:")) self.cmd_type = QComboBox() self.cmd_type.addItem("Запись", "write") # Сохраняем код операции отдельно от русской подписи редактора. self.cmd_type.addItem("Чтение", "read") # Код чтения используется общим макросом независимо от языка интерфейса. type_layout.addWidget(self.cmd_type) layout.addLayout(type_layout) bus_layout = QHBoxLayout() # Отдельный выбор шины позволяет смешивать PBUS и SBUS в одном макросе. bus_layout.addWidget(QLabel("Шина:")) # Подпись объясняет назначение переключателя для каждой команды. self.bus_type = QComboBox() # Выбор хранится в команде, независимо от последней ручной операции. self.bus_type.addItem("SBUS (Сериальная шина)", "sbus") # Существующая последовательная шина остаётся первой в выборе общей команды. self.bus_type.addItem("PBUS (Параллельная шина)", "pbus") # Вторая шина управляет тем же общим макросом и таблицей регистров справа. self.bus_type.setCurrentIndex(self.bus_type.findData(self.command.get("bus", "sbus"))) # Старые команды без поля bus сохраняют принадлежность SBUS. bus_layout.addWidget(self.bus_type) # Размещаем выбор шины перед адресом платы и регистра. layout.addLayout(bus_layout) # Добавляем строку выбора в общую форму редактора. # Адрес устройства addr_layout = QHBoxLayout() self.device_addr_label = QLabel("Адрес устройства (0–14):") # Диапазон подписи расширяется до 0–15 при выборе новой PBUS. addr_layout.addWidget(self.device_addr_label) # Сохраняем подпись для синхронизации с допустимым адресом. self.device_addr = QSpinBox() self.device_addr.setRange(0, 14) addr_layout.addWidget(self.device_addr) layout.addLayout(addr_layout) # Регистр reg_layout = QHBoxLayout() reg_layout.addWidget(QLabel("Регистр (0-15):")) self.register_num = QSpinBox() self.register_num.setRange(0, 15) reg_layout.addWidget(self.register_num) layout.addLayout(reg_layout) # Данные (только для записи) self.data_label = QLabel("Данные (hex):") self.data_edit = QLineEdit("0000") self.data_edit.setMaximumWidth(100) data_layout = QHBoxLayout() data_layout.addWidget(self.data_label) data_layout.addWidget(self.data_edit) layout.addLayout(data_layout) table_form = QGridLayout() # Для PBUS пользователь задаёт источник; индексы и длину таблицы рассчитывает панель автоматически. self.pbus_error_label, self.pbus_errors = QLabel("Порог ошибок (1–15):"), QSpinBox() # Единственный дополнительный параметр соответствует порогу x_parallel_bus.h. self.pbus_errors.setRange(1, 15) # Порог занимает четыре бита и не допускает нуля, который объявил бы любой статус ошибкой. self.pbus_errors.setValue(self.command.get("errors", PBUS_ERROR_LIMIT)) # Новая команда использует порог 0xA, явно сохранённое значение существующего макроса сохраняется. table_form.addWidget(self.pbus_error_label, 0, 0) # Подпись описывает аппаратное условие остановки опроса PBUS. table_form.addWidget(self.pbus_errors, 0, 1) # Поле применяется к добавлению, удалению и обновлению таблицы. layout.addLayout(table_form) # Дополнительный параметр находится под адресом платы и регистра. self.bus_type.currentIndexChanged.connect(self.on_bus_changed) # Выбор шины меняет набор команд в общем редакторе, не создавая отдельного макроса. self.on_bus_changed() # Применяем диапазоны и допустимые операции до восстановления сохранённой команды. if self.command: # При редактировании восстанавливаем источник и код операции из общего JSON. operation = self.command.get("type", "read") # Старые макросы SBUS продолжают использовать коды read и write. if self.bus_type.currentData() == "pbus": # Ранее сохранённые одиночные операции PBUS переводятся к действиям таблицы. operation = {"write": "add", "read": "poll"}.get(operation, operation) # Запись соответствует добавлению, чтение — обновлению значений общей таблицы. self.cmd_type.setCurrentIndex(self.cmd_type.findData(operation)) # Выбираем операцию по коду, без зависимости от порядка или русской подписи. self.device_addr.setValue(self.command.get("tetrad", 0)) # Восстанавливаем адрес платы для добавления или удаления регистра. self.register_num.setValue(self.command.get("register", 0)) # Восстанавливаем номер наблюдаемого регистра. self.data_edit.setText(f"{self.command.get('data', 0):04X}") # Данные прежней команды записи SBUS сохраняются без изменения. # Подключаем изменение типа команды self.cmd_type.currentTextChanged.connect(self.on_cmd_type_changed) self.on_cmd_type_changed(self.cmd_type.currentText()) # Кнопки button_layout = QHBoxLayout() self.ok_btn = QPushButton("OK") self.ok_btn.clicked.connect(self._accept_command) self.cancel_btn = QPushButton("Отмена") self.cancel_btn.clicked.connect(self.reject) button_layout.addWidget(self.ok_btn) button_layout.addWidget(self.cancel_btn) layout.addLayout(button_layout) self.setLayout(layout) def on_bus_changed(self): # Переключаем отдельную команду внутри одного общего макроса PBUS и SBUS. parallel = self.bus_type.currentData() == "pbus" # У PBUS доступны операции над общей таблицей наблюдаемых регистров. maximum = 15 if parallel else 14 # PBUS использует четырёхбитный адрес, SBUS сохраняет прежний диапазон 0–14. self.device_addr.setRange(0, maximum) # Исключаем адреса, неподдерживаемые алгоритмом выбранной шины. self.device_addr_label.setText(f"Адрес устройства (0–{maximum}):") # Подпись соответствует реальному ограничению адресного поля. previous = self.cmd_type.currentData() # Сохраняем код прежней операции независимо от надписи в списке. self.cmd_type.blockSignals(True) # Не обновляем форму для промежуточного пустого набора операций. self.cmd_type.clear() # Удаляем операции предыдущей шины перед добавлением нового набора. operations = (("Добавить в таблицу", "add"), ("Убрать из таблицы", "remove"), ("Обновить значения", "poll"), ("Запуск опроса", "start"), ("Остановка опроса", "stop")) if parallel else (("Запись", "write"), ("Чтение", "read")) # PBUS работает со списком, SBUS сохраняет ручное чтение и запись слова. for caption, code in operations: # Коды действий хранятся отдельно от русских названий элементов интерфейса. self.cmd_type.addItem(caption, code) # Общий редактор использует один список команд выбранной шины. index = self.cmd_type.findData(previous) # Проверяем, поддерживается ли прежняя операция новой шиной. self.cmd_type.setCurrentIndex(max(0, index)) # Если прежнее действие недоступно, выбираем первое допустимое действие. self.cmd_type.blockSignals(False) # Возвращаем обработку обычного изменения операции пользователем. self.pbus_error_label.setVisible(parallel) # Порог ошибок PBUS не загромождает редактирование команды SBUS. self.pbus_errors.setVisible(parallel) # Поле сохраняется в команде только для параллельной шины. self.on_cmd_type_changed(self.cmd_type.currentText()) # Видимость данных согласуется с окончательным выбором операции. def on_cmd_type_changed(self, text): """Показать/скрыть поле данных в зависимости от типа команды""" if self.bus_type.currentData() == "sbus" and self.cmd_type.currentData() == "write": # Данные периферии доступны только при записи существующей SBUS. self.data_label.show() self.data_edit.show() else: self.data_label.hide() self.data_edit.hide() def _accept_command(self): if self.bus_type.currentData() == "sbus" and self.cmd_type.currentData() == "write": # Проверяем HEX-слово лишь для операции, которая действительно передаёт данные. try: value = int(self.data_edit.text(), 16) if not 0 <= value <= 0xFFFF: raise ValueError() except ValueError: QMessageBox.warning(self, "Ошибка", "Данные должны быть в диапазоне 0000…FFFF") return try: # Проверяем взаимосвязь строки и длины таблицы до сохранения команды макроса. validate_command(self.get_command()) # Одна проверка используется редактором и исполнителем команд обеих шин. except (KeyError, ValueError, TypeError) as error: # Некорректная команда остаётся открытой для исправления. QMessageBox.warning(self, "Ошибка", str(error)) # Показываем конкретный неверный параметр пользователю редактора. return # Не сохраняем команду, которая остановит выполнение макроса. self.accept() # Закрываем редактор только после полной проверки команды. def get_command(self): """Получить команду из диалога""" cmd_type = self.cmd_type.currentData() # Код выбранного действия сохраняется в общем JSON независимо от русской подписи. command = { 'type': cmd_type, 'tetrad': self.device_addr.value(), 'register': self.register_num.value() } if cmd_type == 'write' and self.bus_type.currentData() == "sbus": # Поле data относится только к записи данных последовательной шины. try: command['data'] = int(self.data_edit.text(), 16) except ValueError: command['data'] = 0 command['bus'] = self.bus_type.currentData() # Сохраняем шину в JSON, чтобы повторный запуск и редактирование использовали тот же алгоритм. if command["bus"] == "pbus": # Запоминаем настройки таблицы независимо от последнего состояния ручной панели. command["errors"] = self.pbus_errors.value() # Номера строк и размер таблицы вычисляются автоматически из общего списка регистров PBUS. return command # Возвращаем адрес, регистр, данные и выбранную шину как одну команду макроса. class MacroEditorDialog(QDialog): """Диалог для создания/редактирования макроса""" def __init__(self, parent=None, macro=None): super().__init__(parent) self.macro = deepcopy(macro) if macro else {'name': '', 'commands': []} self.init_ui() def init_ui(self): self.setWindowTitle("Редактор макроса") self.setMinimumSize(500, 400) layout = QVBoxLayout() # Имя макроса name_layout = QHBoxLayout() name_layout.addWidget(QLabel("Имя макроса:")) self.macro_name = QLineEdit(self.macro.get('name', 'Новый макрос')) name_layout.addWidget(self.macro_name) layout.addLayout(name_layout) period_layout = QHBoxLayout() # Каждый макрос сохраняет собственный период повторения в редакторе. period_layout.addWidget(QLabel("Период повторения:")) # Подпись обозначает время между началами повторных запусков. self.repeat_period = QSpinBox(self) # Значение хранится в настройках данного макроса, независимо от шин. self.repeat_period.setRange(1, 3600000) # Период задаётся в миллисекундах от одной до одного часа. self.repeat_period.setSuffix(" мс") # Единица измерения доступна непосредственно при редактировании. self.repeat_period.setValue(self.macro.get("period_ms", 1000)) # Старые макросы получают период одной секунды без изменения команд. period_layout.addWidget(self.repeat_period) # Поле расположено рядом с подписью в одной строке. layout.addLayout(period_layout) # Период относится ко всему макросу и расположен перед списком команд. # Список команд layout.addWidget(QLabel("Команды:")) self.commands_list = QListWidget() layout.addWidget(self.commands_list) # Кнопки управления командами cmd_buttons_layout = QHBoxLayout() self.add_cmd_btn = QPushButton("Добавить команду") self.add_cmd_btn.clicked.connect(self.add_command) cmd_buttons_layout.addWidget(self.add_cmd_btn) self.edit_cmd_btn = QPushButton("Редактировать") self.edit_cmd_btn.clicked.connect(self.edit_command) cmd_buttons_layout.addWidget(self.edit_cmd_btn) self.remove_cmd_btn = QPushButton("Удалить") self.remove_cmd_btn.clicked.connect(self.remove_command) cmd_buttons_layout.addWidget(self.remove_cmd_btn) self.move_up_btn = QPushButton("Вверх") self.move_up_btn.clicked.connect(self.move_command_up) cmd_buttons_layout.addWidget(self.move_up_btn) self.move_down_btn = QPushButton("Вниз") self.move_down_btn.clicked.connect(self.move_command_down) cmd_buttons_layout.addWidget(self.move_down_btn) layout.addLayout(cmd_buttons_layout) # Загружаем команды, если они есть self.load_commands() # Кнопки сохранения/отмены button_layout = QHBoxLayout() self.save_btn = QPushButton("Сохранить") self.save_btn.clicked.connect(self.accept) self.cancel_btn = QPushButton("Отмена") self.cancel_btn.clicked.connect(self.reject) button_layout.addWidget(self.save_btn) button_layout.addWidget(self.cancel_btn) layout.addLayout(button_layout) self.setLayout(layout) def load_commands(self): """Загрузить команды в список""" self.commands_list.clear() for cmd in self.macro['commands']: if cmd.get("bus", "sbus") == "pbus": # Команды таблицы требуют собственного описания вместо HEX-данных SBUS. action = {"add": "Добавить в таблицу", "remove": "Убрать из таблицы", "poll": "Обновить значения", "write": "Добавить в таблицу", "read": "Обновить значения", "start": "Запуск опроса", "stop": "Остановка опроса"}.get(cmd["type"], "Неизвестная команда") # Общий макрос явно показывает действия со списком параллельной шины. text = f"{action}: плата {cmd['tetrad']}, регистр {cmd['register']}" if cmd["type"] in ("add", "remove", "write") else action # Добавление и удаление задают источник; остальные операции относятся ко всему списку PBUS. elif cmd['type'] == 'write': # Существующие команды записи SBUS сохраняют прежнее отображение данных. text = f"Запись: устр.{cmd['tetrad']}, рег.{cmd['register']}, 0x{cmd['data']:04X}" else: text = f"Чтение: устр.{cmd['tetrad']}, рег.{cmd['register']}" self.commands_list.addItem(f"{cmd.get('bus', 'sbus').upper()}: {text}") # Показываем шину каждой строки; старые команды обозначаем существовавшей SBUS. def add_command(self): """Добавить новую команду""" dialog = MacroCommandDialog(self) if dialog.exec(): command = dialog.get_command() self.macro['commands'].append(command) self.load_commands() def edit_command(self): """Редактировать выбранную команду""" current_row = self.commands_list.currentRow() if current_row >= 0 and current_row < len(self.macro['commands']): dialog = MacroCommandDialog(self, self.macro['commands'][current_row]) if dialog.exec(): self.macro['commands'][current_row] = dialog.get_command() self.load_commands() def remove_command(self): """Удалить выбранную команду""" current_row = self.commands_list.currentRow() if current_row >= 0 and current_row < len(self.macro['commands']): del self.macro['commands'][current_row] self.load_commands() def move_command_up(self): """Переместить команду вверх""" current_row = self.commands_list.currentRow() if current_row > 0: self.macro['commands'][current_row], self.macro['commands'][current_row-1] = \ self.macro['commands'][current_row-1], self.macro['commands'][current_row] self.load_commands() self.commands_list.setCurrentRow(current_row-1) def move_command_down(self): """Переместить команду вниз""" current_row = self.commands_list.currentRow() if current_row >= 0 and current_row < len(self.macro['commands']) - 1: self.macro['commands'][current_row], self.macro['commands'][current_row+1] = \ self.macro['commands'][current_row+1], self.macro['commands'][current_row] self.load_commands() self.commands_list.setCurrentRow(current_row+1) def get_macro(self): """Получить макрос из диалога""" return { 'name': self.macro_name.text(), # Имя используется кнопкой выполнения и сохраняется при редактировании. 'period_ms': self.repeat_period.value(), # Независимый период сохраняется в JSON данного макроса в миллисекундах. 'commands': self.macro['commands'].copy() } class SerialTab(QWidget): """Два независимых блока PBUS и SBUS с общей областью управления макросами.""" # Историческое имя класса сохранено для совместимости встраиваемых панелей. MACROS_FILE = "macros.json" # Файл для сохранения макросов def __init__(self, serial_bus, raw_protocol, raw_widget, parent=None, *, device_address=10, macros_file=None): super().__init__(parent) self.device_address = device_address self.serial_bus = serial_bus self.raw_protocol = raw_protocol self.raw_widget = raw_widget self.macros = [] # Список макросов self.macro_buttons = [] # Список кнопок макросов self._macro_running = False self._macro_generation = 0 self._background = False # Фоновые чтения таблицы отличаются от общего макроса и уступают ручным действиям. self._macro_is_macro = False # Совместимое состояние показывает наличие выполняемого макроса. self.queue = exchange_queue(raw_protocol) # Все панели данного соединения используют один планировщик UART. self._runs, self._macro_cycles = [], {} # Запуски и независимые таймеры сохраняют собственное состояние. self._sbus_key, self._pbus_key = (id(self), "sbus"), (id(self), "pbus") # По одному ключу на каждое периодическое задание исключают дубликаты. from pathlib import Path self.MACROS_FILE = str(macros_file if macros_file is not None else Path(QStandardPaths.writableLocation(QStandardPaths.AppDataLocation)) / "pysetterminal" / "macros.json") Path(self.MACROS_FILE).parent.mkdir(parents=True, exist_ok=True) # Загружаем сохраненные макросы self.load_macros() self.init_ui() def _build_bus_group(self, bus, title): # Создаём левую область существующей SBUS с собственными результатами. group = QGroupBox(title) # Русская подпись однозначно обозначает последовательную шину. group.setObjectName(bus + "Group") # Стабильное имя сохраняет поиск блока для встраивающего приложения. outer, panel = QVBoxLayout(group), {} # Широкий журнал и компактные поля получают независимые области компоновки. outer.setContentsMargins(10, 14, 10, 10) # Отступы в пикселях отделяют содержимое от рамки, сохраняя компактность панели. outer.setSpacing(8) # Между формой, статусом и журналом оставляем одинаковые небольшие промежутки. outer.setProperty("fixedPanelLayout", True) # Автоматическое преобразование панелей не должно растягивать эту пользовательскую форму. controls = QWidget(group) # Отдельный контейнер не делит столбцы полей с широким журналом результатов. controls.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Форма занимает только необходимую ширину и высоту даже в полноэкранном доке. form = QGridLayout(controls) # Подпись и её поле остаются рядом в двух компактных столбцах. form.setContentsMargins(0, 0, 0, 0) # У вложенной формы нет лишнего второго слоя отступов. form.setHorizontalSpacing(10) # Между подписью и полем оставляем десять пикселей вместо свободной ширины всей панели. form.setVerticalSpacing(6) # Строки параметров образуют плотный визуально связанный блок. form.setProperty("fixedPanelLayout", True) # Поля остаются формой при изменении размера и применении общего менеджера панелей. fields = (("Данные (HEX):", "data_edit", "0000"), ("Адрес (0–14):", "tetrad_edit", "0"), ("Регистр (0–15):", "register_edit", "0")) # Сохраняем прежние параметры и допустимые диапазоны SBUS. for row, (caption, name, default) in enumerate(fields): # Размещаем параметры в едином порядке без изменения аппаратных операций. form.addWidget(QLabel(caption), row, 0) # Подпись находится непосредственно слева от соответствующего поля. field = QLineEdit(default) # Исходные нули сохраняют прежнее поведение ручных команд. field.setFixedWidth(100) # Сто пикселей достаточно для шестнадцатибитного HEX и двухзначного адреса. field.setValidator(QRegularExpressionValidator(QRegularExpression("[0-9A-Fa-f]{1,4}"), field) if name == "data_edit" else QIntValidator(0, 15 if name == "register_edit" else 14, field)) # Данные и адреса проверяются до аппаратной передачи. panel[name] = field # Прежние ссылки bus_* остаются совместимыми с существующими вкладками SETGUI. form.addWidget(field, row, 1) # Значение остаётся рядом с подписью независимо от ширины журнала. buttons = QHBoxLayout() # Действия следуют сразу за компактными параметрами одной шины. buttons.setSpacing(6) # Кнопки чтения и записи воспринимаются как одна группа действий. for write, name, caption in ((True, "write_btn", "Записать"), (False, "read_btn", "Прочитать")): # Каждая кнопка сохраняет своё направление аппаратной операции. button = QPushButton(caption) # Краткие подписи не добавляют лишнего текста в рабочую форму. button.clicked.connect(lambda checked=False, writing=write: self._manual_bus_operation("sbus", writing)) # Выполнение по-прежнему использует существующий протокол SBUS. panel[name] = button # Публичные элементы и блокировка общего макроса остаются прежними. buttons.addWidget(button) # Оба действия расположены под адресными полями. buttons.addStretch(1) # Свободная ширина располагается после кнопок, не раздвигая их друг от друга. form.addLayout(buttons, 3, 0, 1, 2) # Кнопки занимают ширину только компактной формы управления. self.sbus_cycle = QCheckBox("Циклическое чтение", group) # Повторное чтение работает одновременно с доступными ручными кнопками. self.sbus_interval = QSpinBox(group) # Период SBUS задаётся отдельно от PBUS и макросов. self.sbus_interval.setRange(1, 3600000) # Миллисекундный период допускает значения до одного часа. self.sbus_interval.setValue(1000) # Начальный период повторения составляет одну секунду. self.sbus_interval.setSuffix(" мс") # Поле явно обозначает единицу измерения времени. self.sbus_interval.setFixedWidth(120) # Форма сохраняет компактную ширину при растяжении панели. cycle_controls = CompactControls((self.sbus_cycle, self.sbus_interval), group) # Галочка и период используют ширину панели, а не узкую двухколоночную форму адресов. self.sbus_timer = QTimer(self) # Таймер порождает задания общего планировщика без параллельного обмена. self.sbus_timer.setTimerType(Qt.PreciseTimer) # Таймер не начинает повтор раньше выбранного интервала в миллисекундах. self.sbus_timer.timeout.connect(self._sbus_tick) # Новый цикл снимает актуальный адрес и регистр из полей панели. self.sbus_cycle.toggled.connect(self._sbus_cycle_changed) # Выключение отменяет будущие циклы и неотправленные шаги. self.sbus_interval.valueChanged.connect(self._sbus_period_changed) # Изменение периода применяется к дальнейшему планированию. card = QFrame(group) # Считанное слово выделяется отдельной карточкой внутри SBUS. card.setObjectName("sbusReadCard") # Локальный стиль не изменяет остальные подписи и результаты приложения. card.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Карточка остаётся рядом с параметрами и не превращается в широкую пустую область. card.setMinimumWidth(260) # Ширина 260 пикселей вмещает крупное шестнадцатибитное слово и его десятичное представление. card.setStyleSheet("QFrame#sbusReadCard { background: #0d2a29; border: 1px solid #287e6a; border-radius: 8px; } QLabel { background: transparent; border: none; color: #b6d7cf; } QLabel#sbusReadValue { color: #90aaa3; font-size: 24px; font-weight: 600; } QLabel#sbusReadValue[busValueState=ready] { color: #61f0bc; } QLabel#sbusReadValue[busValueState=error] { color: #ff8b8b; }") # Тёмный зелёный фон и яркое слово сохраняют высокий контраст в светлой и тёмной теме; размер значения — 24 пикселя. card_layout = QVBoxLayout(card) # Краткая подпись и крупное слово образуют одну заметную область результата. card_layout.setContentsMargins(14, 10, 14, 12) # Внутренние отступы отделяют цифры от рамки без избыточной пустоты. card_layout.setSpacing(4) # Подпись расположена близко к считанному значению. card_layout.addWidget(QLabel("Считано", card)) # Короткая подпись описывает последнее успешное чтение. panel["read_value_label"] = QLabel("---", card) # До первого ответа не показываем фиктивное нулевое значение. panel["read_value_label"].setObjectName("sbusReadValue") # Стиль выделяет только считанное слово этой шины. panel["read_value_label"].setProperty("busValueState", "empty") # Пустой результат имеет спокойный цвет до успешного чтения. panel["read_value_label"].setFont(QFont("Consolas")) # Моноширинные цифры упрощают сравнение HEX-слов при повторном считывании. panel["read_value_label"].setTextInteractionFlags(Qt.TextSelectableByMouse) # Крупное значение остаётся доступным для выделения и копирования. card_layout.addWidget(panel["read_value_label"]) # Считанное слово отображается сразу под подписью внутри контрастной карточки. panel["read_card"] = card # Сохраняем карточку для проверки её расположения и визуальной доступности. self.sbus_header = CompactControls((controls, card), group, spacing=14) # Форма и карточка остаются рядом в широком доке и переносятся целиком в узком. outer.addWidget(self.sbus_header) # Параметры и карточка не делят столбцы с широким журналом. outer.addWidget(cycle_controls) # Настройки циклического чтения располагаются одной компактной строкой сразу под параметрами и карточкой результата. panel["result_label"] = QLabel("Нет операций") # Под карточкой сохраняем краткую диагностику текущей операции. panel["result_label"].setWordWrap(True) # Ошибка переносится в пределах своего блока без растягивания формы. outer.addWidget(panel["result_label"]) # Статус располагается сразу после связанных параметров и значения. panel["output"] = QPlainTextEdit() # Журнал использует оставшуюся область SBUS и не определяет расстояния между полями. panel["output"].setReadOnly(True) # История ответов сохраняет возможность копирования без редактирования. panel["output"].setMaximumBlockCount(200) # Повторное выполнение команд не увеличивает память журнала без ограничений. panel["output"].setMinimumHeight(100) # Несколько строк ответа видны и в компактном доке. outer.addWidget(panel["output"], 1) # Свободная высота выделяется истории, а не промежуткам между параметрами. self.bus_panels[bus] = panel # Регистрируем единственный набор элементов последовательной шины. return group # Владелец размещает этот блок слева от таблицы PBUS. def _show_bus_result(self, bus, message, result=None, *, failed=False): # Единая точка отображения предотвращает смешивание ответов PBUS и SBUS. panel = self.bus_panels[bus] # Выбираем только элементы шины, выполнившей текущую команду. panel["result_label"].setText(message) # Последний статус или ошибка видны без открытия RAW. if bus == "pbus": # Параллельная шина отображает все значения в единственной таблице наблюдаемых регистров. if failed: # Аппаратная ошибка делает неподтверждённые слова таблицы недостоверными. self.pbus_group.show_error(message) # Причина отказа остаётся в каждой строке и в правом блоке PBUS. elif isinstance(result, dict) and "entries" in result: # Успешный цикл возвращает список источников и отдельные ответы их буфера. self.pbus_group.show_result(result) # Обновляем HEX, DEC и статус каждой строки общей таблицы PBUS. elif failed: # После неудачной SBUS прежнее слово не должно выглядеть новым результатом. panel["read_value_label"].setText("---") # Убираем недостоверное слово только у затронутой последовательной шины. panel["read_value_label"].setProperty("busValueState", "error") # Цвет карточки сообщает об отказе вместо сохранения прежнего успешного значения. elif result is not None and "value" in result: # SBUS сохраняет одиночное считанное слово непосредственно в своём блоке. value = result["value"] # Сохраняем декодированное 16-битное слово ответа контроллера. panel["read_value_label"].setText(f"0x{value:04X} ({value})") # HEX помогает сверять маски, десятичное представление — числовые значения. panel["read_value_label"].setProperty("busValueState", "ready") # Успешное чтение выделяется ярким зелёным словом на контрастном фоне. if bus == "sbus": # Локальный стиль результата должен обновиться при переходе между пустым, успешным и ошибочным состоянием. label = panel["read_value_label"] # Перерисовываем только считанное слово, сохраняя остальные стили приложения. label.style().unpolish(label) # Снимаем прежнее оформление динамического свойства состояния. label.style().polish(label) # Применяем контрастный цвет, соответствующий текущему результату. label.update() # Обновлённый результат становится видимым сразу после ответа контроллера. raw = result.get("raw_response", result).get("raw", b"") if isinstance(result, dict) else b"" # Поддерживаем структуру ответов чтения и подтверждений записи RawProtocol. text = message + ("\nRX: " + raw.hex(" ").upper() if raw else "") # Пакет ответа остаётся в журнале своей шины вместе с смыслом операции. panel["output"].appendPlainText(text) # История позволяет видеть все команды смешанного макроса по соответствующим шинам. self.last_result = text # Сохраняем совместимость с вызывающими приложениями, запрашивающими последний результат. self.raw_widget.append_text(text) # Общая отладочная RAW-панель продолжает получать журнал обмена. def init_ui(self): layout = QVBoxLayout() self.bus_panels = {} # Каждая шина владеет собственными полями, результатом и журналом операций. buses_layout = QHBoxLayout() # Горизонтальный контейнер удерживает PBUS и SBUS рядом в верхней части окна. self.sbus_group = self._build_bus_group("sbus", "SBUS (Сериальная шина)") # Существующая последовательная шина располагается слева согласно выбранному пользователем порядку. self.pbus_group = PbusPanel(self) # Таблица наблюдаемых регистров PBUS становится правой частью той же панели SETGUI. self.bus_panels["pbus"] = self.pbus_group.controls # Результаты и действия PBUS доступны общему исполнителю без второго набора макросов. buses_layout.addWidget(self.sbus_group, 1) # Левый блок SBUS получает половину доступной ширины. buses_layout.addWidget(self.pbus_group, 1) # Правый блок PBUS получает такую же долю ширины. layout.addLayout(buses_layout) # Размещаем оба блока перед общей областью макросов. for name, widget in self.bus_panels["sbus"].items(): # Прежние публичные поля относятся к существовавшей последовательной шине. setattr(self, "bus_" + name, widget) # Имена bus_data_edit, bus_read_btn и других полей остаются совместимыми. for name, widget in self.bus_panels["sbus"].items(): # Явные имена SBUS позволяют отличать существующую шину от добавленной PBUS. setattr(self, "sbus_" + name, widget) # Поля SBUS никогда не ссылаются на результат PBUS. for name, widget in self.bus_panels["pbus"].items(): # Новая параллельная шина имеет независимые доступные интеграциям элементы. setattr(self, "pbus_" + name, widget) # Поля pbus_* не меняют старые ссылки bus_* последовательной шины. # --- Группа для макросов --- macros_group = QGroupBox("Макросы") # Краткий заголовок обозначает управление макросами без пояснений внутренней организации команд. macros_layout = QVBoxLayout() # Кнопка создания нового макроса self.create_macro_btn = QPushButton("Создать новый макрос") self.create_macro_btn.clicked.connect(self.create_macro) self.stop_macro_btn = QPushButton("Остановить макрос") self.stop_macro_btn.setEnabled(True) # Постоянная доступность остановки исключает мигание кнопки во время опроса. self.stop_macro_btn.clicked.connect(self.stop_macro) macro_actions = QHBoxLayout() macro_actions.setSpacing(6) macro_actions.addWidget(self.create_macro_btn) macro_actions.addWidget(self.stop_macro_btn) macro_actions.addStretch(1) macros_layout.addLayout(macro_actions) self.macros_status = QLabel("Макросы готовы к запуску") # Общий статус сообщает запуск, завершение и остановку, не дублируя результаты шин. self.macros_status.setWordWrap(True) # Длинная ошибка макроса переносится в пределах общей панели. macros_layout.addWidget(self.macros_status) # Статус располагается внутри управления макросами, над их списком. for button in (self.bus_write_btn, self.bus_read_btn, self.pbus_add_btn, self.pbus_remove_btn, self.create_macro_btn, self.stop_macro_btn): # Применяем одинаковый размер ко всем действиям обеих шин и макросов. button.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) button.setFixedHeight(30) button.setStyleSheet("QPushButton { padding: 3px 10px; }") self.macros_scroll_area = QScrollArea() # Единственная область содержит все макросы независимо от используемых шин. self.macros_scroll_area.setAccessibleName("Макросы") # Доступное имя повторяет краткий заголовок, который видит пользователь. self.macros_scroll_area.setWidgetResizable(True) # Общий список занимает всю доступную ширину под обоими блоками. self.macros_scroll_area.setMinimumHeight(120) # Список остаётся доступным для прокрутки в компактном окне. self.macros_scroll_content = QWidget() # Все кнопки макросов принадлежат одному контейнеру. self.macros_scroll_layout = _FlowLayout(self.macros_scroll_content, 8) # Перенос выполняется по ширине, промежуток между макросами составляет восемь пикселей. self.macros_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) # При недостаточной высоте показывается вертикальная прокрутка. self.macros_scroll_layouts = [self.macros_scroll_layout] # Сохраняем совместимый доступ к компоновкам, теперь с единственным общим списком. self.macros_scroll_area.setWidget(self.macros_scroll_content) # Встраиваем общий контейнер в прокручиваемую область. macros_layout.addWidget(self.macros_scroll_area, 1) # Вся оставшаяся ширина и высота доступны общей области макросов. macros_group.setLayout(macros_layout) layout.addWidget(macros_group, 1) self.setLayout(layout) # После создания интерфейса создаем кнопки для загруженных макросов # ВАЖНО: Используем enumerate для правильных индексов for i, macro in enumerate(self.macros): self.add_macro_button_with_index(macro, i) def write_to_bus(self): # Прежний публичный метод продолжает выполнять запись существующей SBUS. self._manual_bus_operation("sbus", True) # Выбираем последовательную шину явно, независимо от последней операции PBUS. def read_from_bus(self): # Прежний публичный метод сохраняет чтение последовательной шины для вызывающих вкладок. self._manual_bus_operation("sbus", False) # Результат направляется в SBUS и не изменяет область PBUS. def _manual_bus_operation(self, bus, write): # Фиксируем поля при нажатии и ставим логическую операцию в общую очередь. try: # Неверный ввод отклоняется до постановки задания и не влияет на выполняющийся обмен. command = self._sbus_command(write) # Адрес, регистр и данные сохраняются одним снимком. self._start_commands([command], macro=False) # Ручные чтение и запись имеют приоритет перед следующим фоновым заданием. except (KeyError, ValueError, TypeError, RuntimeError) as error: # Ошибка поля отображается только в собственной области SBUS. self._show_bus_result(bus, f"SBUS: ошибка: {error}", failed=True) # Протокол не получает неверную команду. def _sbus_command(self, write=False): # Параметры периодического чтения берутся заново для каждого следующего запроса. panel = self.bus_panels["sbus"] # Используем существующие поля последовательной шины. command = {"bus": "sbus", "type": "write" if write else "read", "tetrad": int(panel["tetrad_edit"].text()), "register": int(panel["register_edit"].text())} # Адрес и регистр фиксируются независимо от последующих изменений формы. if write: # Данные участвуют только в ручной записи SBUS. command["data"] = int(panel["data_edit"].text(), 16) # Шестнадцатибитное значение считывается в момент нажатия. validate_command(command) # Сохраняем прежние диапазоны и формат последовательного протокола. return command # Возвращаем готовый неизменяемый для данного запуска снимок параметров. def _sbus_cycle_changed(self, checked): # Переключение управляет только циклическим источником SBUS. if checked: # Включение запускает первый запрос сразу, остальные — по выбранному периоду. self.sbus_timer.start(self.sbus_interval.value()) # Время между началами задано в миллисекундах. self._sbus_tick() # Начальное чтение не требует ожидания целого периода. else: # Выключение не прерывает уже переданный пакет UART. self.sbus_timer.stop() # Будущие повторения не назначаются. self._cancel_source(self._sbus_key) # Неотправленные команды задания снимаются с общей очереди. def _sbus_period_changed(self, value): # Новый период относится только к дальнейшим повторениям SBUS. if self.sbus_cycle.isChecked(): # Не запускаем таймер выключенного задания изменением поля. self.sbus_timer.start(value) # Перезапускаем интервал в миллисекундах без накопления пропущенных циклов. def _sbus_tick(self): # Один такт ставит не более одного чтения выбранной пары устройство/регистр. if not self.sbus_cycle.isChecked() or self._source_pending(self._sbus_key): # Ожидающий или выполняющийся цикл исключает ещё один запуск. return # Пропущенное повторение не добавляется в очередь задним числом. try: # Изменённые пользователем поля применяются к следующему успешно созданному запросу. self._start_commands([self._sbus_command()], macro=False, background=True, key=self._sbus_key) # Фоновое чтение уступает уже ожидающим ручным действиям. except (ValueError, TypeError, KeyError) as error: # Промежуточный неверный ввод не вызывает неверного пакета UART. self.bus_result_label.setText(f"SBUS: проверьте адрес и регистр: {error}") # Диагностика остаётся в области последовательной шины. def _command_summary(self, command, result): # Формируем единое понятное описание ручной операции и команды макроса. if isinstance(result, dict) and "entries" in result: # PBUS возвращает целую таблицу источников и результатов вместо одного прочитанного слова. action = {"add": "Регистр добавлен", "remove": "Выделенные регистры удалены", "poll": "Значения обновлены", "configure": "Таблица применена", "start": "Опрос запущен"}.get(command["type"], "Таблица обновлена") # Статус описывает действие над единственным списком PBUS. if not result["entries"]: # Успешная пустая таблица означает остановленную шину, даже при исходной команде start. action = "таблица пуста, опрос остановлен" # Сообщение не должно объявлять запуск аппаратуры, которой отправили только остановку. return f"PBUS: {action}; регистров {len(result['entries'])}" # Значения отдельных источников остаются в соответствующих строках таблицы. bus, operation = command.get("bus", "sbus"), command["type"] # Старые макросы без поля bus продолжают принадлежать SBUS. if bus == "pbus": # Остановка аппаратной таблицы возвращает подтверждение без отдельного считанного слова. return f"PBUS: опрос остановлен; регистров {len(self.pbus_group.entries)}" # Состояние относится к текущему видимому списку, а не к прежнему ручному числу строк. context = f"плата={command['tetrad']}, регистр={command['register']}" # Показываем источник данных в каждой записи журнала соответствующей шины. if operation == "read": # Успешное чтение всегда включает считанное слово и его источник. return f"{bus.upper()}: {context}, считано 0x{result['value']:04X}" # Представление HEX удобно для сопоставления с документацией регистров. if operation == "write": # Запись последовательной шины и подготовка таблицы PBUS имеют разные аппаратные результаты. return f"SBUS: {context}, запись 0x{command['data']:04X} подтверждена" # Данные и подтверждение последовательной записи остаются в собственной области результатов. def create_macro(self): """Создать новый макрос""" dialog = MacroEditorDialog(self) if dialog.exec(): macro = dialog.get_macro() if not macro['name']: macro['name'] = f"Макрос {len(self.macros) + 1}" self.macros.append(macro) self.add_macro_button(macro) self.save_macros() # Сохраняем после добавления dialog.deleteLater() QApplication.processEvents() # Обрабатываем события def add_macro_button_with_index(self, macro, index): """Добавить кнопку макроса в интерфейс с указанным индексом""" # Создаем контейнер для кнопки и кнопки редактирования container = QWidget() container_layout = QHBoxLayout() container_layout.setContentsMargins(0, 0, 0, 0) container_layout.setSpacing(4) container.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Элемент макроса сохраняет естественную ширину у левого края панели. # Основная кнопка макроса macro_btn = QPushButton(macro['name']) macro_btn.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Fixed) # Название макроса и его действия остаются рядом вместо растягивания кнопки на всю ширину окна. macro_btn.setFixedHeight(30) macro_btn.setMinimumWidth(120) macro_btn.setToolTip(macro["name"]) macro_btn.setStyleSheet(""" QPushButton { font-weight: bold; text-align: left; padding: 3px 8px; } """) # Используем partial для фиксации индекса macro_btn.clicked.connect(partial(self.execute_macro_by_index, index)) # Кнопка редактирования edit_btn = QPushButton() edit_btn.setIcon(QIcon(_MacroActionIcon("edit", edit_btn))) edit_btn.setIconSize(QSize(16, 16)) edit_btn.setFixedSize(30, 30) edit_btn.setStyleSheet("QPushButton { padding: 0; }") edit_btn.setAccessibleName("Редактировать макрос") edit_btn.setToolTip("Редактировать макрос") edit_btn.clicked.connect(partial(self.edit_macro_by_index, index)) # Кнопка удаления delete_btn = QPushButton() delete_btn.setIcon(QIcon(_MacroActionIcon("delete", delete_btn))) delete_btn.setIconSize(QSize(16, 16)) delete_btn.setFixedSize(30, 30) delete_btn.setStyleSheet("QPushButton { padding: 0; }") delete_btn.setAccessibleName("Удалить макрос") delete_btn.setToolTip("Удалить макрос") delete_btn.clicked.connect(partial(self.delete_macro, index)) container_layout.addWidget(macro_btn) container_layout.addWidget(edit_btn) container_layout.addWidget(delete_btn) cycle = QCheckBox("Циклически", container) # Каждый макрос имеет независимый переключатель повторения. cycle.setChecked(id(macro) in self._macro_cycles) # Перестроение списка сохраняет состояние таймера по объекту макроса. cycle.toggled.connect(lambda checked, item=macro: self._macro_cycle_changed(item, checked)) # Связь с объектом сохраняется при удалении соседних макросов. container_layout.addWidget(cycle) # Галочка расположена рядом с кнопками выполнения, редактирования и удаления. container.setLayout(container_layout) self.macros_scroll_layout.addWidget(container) # FlowLayout размещает элемент без растягивания и пересчитывает перенос при изменении ширины. self.macro_buttons.append(container) def add_macro_button(self, macro): """Добавить кнопку макроса в интерфейс (при создании нового)""" # При создании нового макроса используем последний индекс index = len(self.macros) - 1 self.add_macro_button_with_index(macro, index) def execute_macro_by_index(self, macro_index): """Выполнить макрос по индексу""" if 0 <= macro_index < len(self.macros): self._start_commands(self.macros[macro_index]["commands"], macro=True, source=self.macros[macro_index]) # Однократный запуск снимает параметры при нажатии независимо от режима повторения. def edit_macro_by_index(self, macro_index): """Редактировать макрос по индексу""" if 0 <= macro_index < len(self.macros): dialog = MacroEditorDialog(self, self.macros[macro_index]) if dialog.exec(): edited_macro = dialog.get_macro() original = self.macros[macro_index] # Сохраняем идентичность объекта, с которым связан таймер повторения. original.update(edited_macro) # Уже запущенные команды используют снимок, следующие получают отредактированные значения. self._restart_macro_timer(original) # Применяем новый период только к этому макросу. # Обновляем кнопку - находим ее по индексу if macro_index < len(self.macro_buttons): container = self.macro_buttons[macro_index] macro_btn = container.layout().itemAt(0).widget() if macro_btn: macro_btn.setText(edited_macro['name']) # Обновляем название без изменения размера остальных элементов. container.updateGeometry() # Длина названия влияет на естественную ширину элемента. self.macros_scroll_layout.invalidate() # Пересчитываем перенос следующих макросов по доступной ширине. self.save_macros() dialog.deleteLater() QApplication.processEvents() # Обрабатываем события # Добавим метод для удаления макроса def delete_macro(self, macro_index): """Удалить макрос""" if 0 <= macro_index < len(self.macros): # Спросим подтверждение reply = QMessageBox.question( self, "Удаление макроса", f"Удалить макрос '{self.macros[macro_index]['name']}'?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply == QMessageBox.Yes: # Удаление действует только после подтверждения пользователя. self._macro_cycle_changed(self.macros[macro_index], False) # Отменяем повторы и неотправленные команды удаляемого макроса. # Удаляем макрос из списка del self.macros[macro_index] # Полностью перестраиваем layout self.rebuild_macros_layout() # Сохраняем изменения self.save_macros() def execute_macro_commands(self, commands): # Сохраняем публичный запуск макроса, добавляя возможность смешивать PBUS и SBUS. self._start_commands(commands, macro=True) # Ручной PBUS и макросы используют одинаковое выполнение и защиту от поздних ответов. def _sbus_sequence(self, command): # Адаптируем существующий двухшаговый SBUS к общему асинхронному исполнителю. from protocan.tms_terminal import bus_steps # Планирование SBUS остаётся в прежнем общем C-ядре протокола TMS2812. result = None # Итогом станет ответ последнего запроса чтения или подтверждения записи. for request in bus_steps(command["type"] == "write", command["tetrad"], command["register"], command.get("data", 0)): # Формируем те же адреса и управляющие слова, что до добавления PBUS. result = yield request # Ответ передаётся генератору только после завершения текущего запроса транспорта. if command["type"] == "read" and (not isinstance(result, dict) or not isinstance(result.get("value"), int) or not 0 <= result["value"] <= 0xFFFF): # ReadMemory существующей SBUS должен вернуть одно шестнадцатибитное слово. raise ValueError("SBUS: некорректный ответ чтения памяти") # Повреждённый ответ прекращает только текущую последовательность. return result # Возвращаем финальное слово или подтверждение для области результатов SBUS. def _pbus_sequence(self, command): # Все пути PBUS используют полную таблицу, сохранённые параметры остановки и адреса CD результатов. if command["type"] == "stop": # Остановка не вычисляет N-1 из новой или пустой таблицы интерфейса. return (yield from self.pbus_group.stop_sequence(command)) # Единственная запись сохраняет подтверждённые параметры активного аппаратного опроса. return (yield from self.pbus_group.sequence(command)) # Настройка и чтение исполняются общей очередью без чередования пакетов с другой операцией. def cancel_background(self): # Прежний публичный вызов больше не обрывает пакет фонового чтения. return # Ручное действие получает приоритет очереди после завершения текущей логической операции. def _source_pending(self, key): # Учитываем целый макрос между его отдельными логическими командами. return any(run["key"] == key for run in self._runs) or self.queue.contains(key) # Одно задание не имеет одновременно ожидающего и повторного запуска. def _sync_running(self): # Совместимые признаки состояния остаются доступны встраивающему SETGUI. self._macro_running = bool(self._runs) # Признак показывает активные и ожидающие последовательности без блокировки интерфейса. self._macro_is_macro = any(run["macro"] for run in self._runs) # Макросы отличаются от ручного чтения или записи своей последовательностью. self._background = bool(self._runs) and all(run["background"] for run in self._runs) # Признак фонового режима не влияет на доступность кнопок. def _start_commands(self, commands, *, macro, background=False, key=None, source=None): # Параметры фиксируются до ожидания общей очереди соединения. if key is not None and self._source_pending(key): # Периодические задания не накапливаются, пока прежний запуск не завершён. return False # Пропущенный цикл не создаёт отложенного повторения. try: # Проверяем весь макрос до его первой записи и сохраняем независимый снимок. jobs = deepcopy(list(commands)) # Редактирование макроса или полей не меняет уже поставленное действие. planned = deepcopy(self.pbus_group.entries) # Начальное состояние используется для проверки максимальной ёмкости таблицы. for existing in self._runs: # Учитываем ещё не выполненные изменения таблицы из ранее принятых нажатий. for command in existing["jobs"][existing["index"]:]: # Изменения применяются в ожидаемом порядке пользовательских действий. if command.get("bus", "sbus") == "pbus": # SBUS не изменяет список строк параллельной шины. planned = table_entries_after(planned, command) # Проверка сохраняет ограничение буфера из шестидесяти четырёх слов. for command in jobs: # Ошибка даже последней команды не должна допускать частичного исполнения макроса. validate_command(command) # Старые макросы без поля bus продолжают работать через SBUS. if command.get("bus", "sbus") == "pbus": # Совместимые одиночные команды переводятся в существующие действия таблицы. command.setdefault("_form_errors", self.pbus_group.error_limit.value()) # Фиксируем состояние поля при постановке команды, чтобы поздний результат макроса не затёр последующий ввод. command["type"] = {"write": "add", "read": "add"}.get(command["type"], command["type"]) # Источник сохраняется без введения второго списка PBUS. planned = table_entries_after(planned, command) # Проверяем диапазоны адресов, удаление и вместимость до первого запроса. controller = self.device_address() if callable(self.device_address) else self.device_address # Контроллер фиксируется в момент нажатия или создания цикла. except (KeyError, ValueError, TypeError, RuntimeError) as error: # Неверные параметры не затрагивают активную операцию соединения. self.macros_status.setText(f"Макрос остановлен: {error}") # Указываем конкретную причину отказа валидации. self.raw_widget.append_text(str(error)) # Сохраняем диагностический текст в прежнем журнале. return False # Ни одного пакета данного задания не было отправлено. if not jobs: # Пустой макрос не создаёт операции очереди и не запускает фоновых повторов. self.macros_status.setText("Макрос пуст") # Показываем состояние в общей области макросов. return False # Нет команд для передачи контроллеру. run = {"jobs": jobs, "index": 0, "controller": controller, "macro": macro, "background": background, "key": key if key is not None else object(), "source": source, "cancelled": False, "started": False} # Каждый запуск сохраняет своё положение и владельца отмены. self._runs.append(run) # Несколько принятых нажатий не заменяют друг друга. self._sync_running() # Обновляем признаки выполнения, оставляя все элементы доступными. if not background: # Фоновый цикл не подменяет последний итог явно запущенного макроса. self.macros_status.setText("Макрос в очереди" if macro else "Команда в очереди") # Нажатие подтверждается даже при занятом соединении. self._queue_command(run) # Первая логическая операция получает соответствующий приоритет. return True # Периодический источник знает, что предыдущий запуск ещё ожидает или выполняется. def _queue_command(self, run): # Макрос передаёт команды по порядку, уступая порт между логическими операциями. if run["cancelled"] or run not in self._runs: # Остановка запрещает позднее назначение следующей команды. return # Завершённый или снятый запуск больше не использует UART. command = run["jobs"][run["index"]] # Снимок команды принадлежит именно этому запуску. self.queue.submit(lambda: self._begin_command(run, command), run["controller"], lambda ok, result: self._command_done(run, command, ok, result), key=run["key"], background=run["background"]) # Все пакеты выбранной команды выполняются атомарно относительно других операций. def _begin_command(self, run, command): # Фабрика вызывается после получения владения соединением общей очередью. if run["key"] == self._sbus_key and not run["started"]: # Ожидающий циклический SBUS берёт актуальные поля при фактическом начале запроса. command.update(self._sbus_command()) # Устройство и регистр следующего запроса меняются до отправки, параметры текущего генератора сохраняются. self._macro_controller = run["controller"] # Существующая таблица PBUS получает зафиксированный контроллер текущей операции. if not run["started"]: # Период измеряется от реального начала первого шага данного запуска. run["started"] = True # Продолжения внутри макроса не меняют время начала повторения. self._repeat_started(run) # Следующий таймер отсчитывается от начала обмена, а не завершения макроса. return self._pbus_sequence(command) if command.get("bus", "sbus") == "pbus" else self._sbus_sequence(command) # Существующие алгоритмы шин сохраняют формат своих WriteMemory и ReadMemory. def _command_done(self, run, command, success, result): # Результат завершается только после всей логической операции регистра. if run not in self._runs or run["cancelled"]: # Callback остановленного задания не продолжает его макрос. return # Ответ отправленного пакета уже обработан протоколом безопасно для следующего обмена. bus = command.get("bus", "sbus") # Ответ относится к шине снимка команды, независимо от изменённых полей. if not success: # Таймаут, неверный ответ и отключение прекращают дальнейшие команды текущего макроса. self._show_bus_result(bus, f"{bus.upper()}: ошибка: {result}", failed=True) # Реальная транспортная ошибка остаётся видна в своей области результатов. self._finish_run(run, f"Макрос остановлен: {result}") # Освобождаем состояние запуска без отмены других независимых заданий. return # После ошибки не выполняются следующие записи этого макроса. self._show_bus_result(bus, self._command_summary(command, result), result) # Значения выводятся только в области соответствующей шины. run["index"] += 1 # Продвигаем макрос после подтверждения всех пакетов текущей команды. if run["index"] < len(run["jobs"]): # Следующая команда сохраняет заданный порядок внутри макроса. self._queue_command(run) # Ручные действия смогут обслуживаться перед следующим фоновым шагом. else: # Последняя команда завершила весь однократный или циклический запуск. self._finish_run(run, "Макрос выполнен" if run["macro"] else "Команда выполнена") # Повторение планирует независимый таймер, не цепочка немедленных запусков. def _finish_run(self, run, message): # Удаляем состояние только завершённого запуска, сохраняя остальные задания. if run in self._runs: # Повторный callback отмены не должен удалять чужой запуск. self._runs.remove(run) # Периодический источник снова может поставить следующий цикл. self._sync_running() # Состояние учитывает другие ожидающие ручные действия и независимые макросы. if not run["background"]: # Циклическое чтение не меняет явный итог последнего макроса. self.macros_status.setText(message) # Показываем результат явно запущенного действия. self.raw_widget.append_text(message) # Прежний общий журнал сохраняет итог выполнения. def _cancel_source(self, key): # Остановка источника запрещает будущие команды всех его незавершённых запусков. for run in list(self._runs): # Снимок позволяет безопасно удалить несколько запусков выбранного источника. if run["key"] == key: # Остальные макросы и чтения шин не затрагиваются. run["cancelled"] = True # Позднее подтверждение уже отправленного пакета не продолжит последовательность. self._finish_run(run, "Остановлено") # Освобождаем состояние задания до получения транспортного ответа. self.queue.cancel(key) # Очередь снимет неотправленные шаги и дождётся ответа текущего пакета. def _repeat_started(self, run): # Период отсчитывается между реальными началами повторов. if run["key"] == self._sbus_key and self.sbus_cycle.isChecked(): # Старт чтения SBUS задаёт начало следующего периода только этой шины. self.sbus_timer.start(self.sbus_interval.value()) # Таймер не зависит от продолжительности текущего обмена. elif run["key"] == self._pbus_key and self.pbus_group.auto_update.isChecked(): # Циклический PBUS использует собственное поле периода. self.pbus_group.timer.start(self.pbus_group.interval.value()) # Отсчёт начинается от начала чтения таблицы. elif run["source"] is not None and run["background"]: # Циклический макрос получает независимый таймер своего владельца. self._restart_macro_timer(run["source"]) # Пропущенные повторы не создают дополнительного запуска. def _restart_macro_timer(self, macro): # Период одного макроса применяется отдельно от всех других заданий. timer = self._macro_cycles.get(id(macro)) # Таймер сохраняется по идентичности макроса при переносе и перестроении списка. if timer is not None: # Редактирование выключенного макроса не запускает циклическое выполнение. timer.start(int(macro.get("period_ms", 1000))) # Старые JSON-макросы получают штатный период одной секунды. def _macro_cycle_changed(self, macro, checked): # Галочка управляет повторениями только выбранного макроса. identity, key = id(macro), (id(self), "macro", id(macro)) # Общая очередь получает независимый ключ источника повторов. if checked and identity not in self._macro_cycles: # Повторное включение не создаёт второго таймера. timer = QTimer(self) # Таймер принадлежит существующей панели и не зависит от положения дока. timer.setTimerType(Qt.PreciseTimer) # Индивидуальный период макроса отсчитывается без преждевременного срабатывания. timer.timeout.connect(lambda item=macro: self._macro_tick(item)) # Следующий цикл берёт актуальную сохранённую последовательность макроса. self._macro_cycles[identity] = timer # Сохраняем таймер до выключения галочки или удаления макроса. self._restart_macro_timer(macro) # Период берётся из индивидуальных настроек этого макроса. self._macro_tick(macro) # Первый запуск происходит сразу после включения. elif not checked: # Выключение немедленно запрещает будущие повторы и ещё не переданные команды. timer = self._macro_cycles.pop(identity, None) # Удаляем только таймер выбранного макроса. if timer is not None: # Галочка может выключаться также при удалении никогда не запускавшегося макроса. timer.stop() # Новые периодические события больше не назначаются. timer.deleteLater() # Освобождаем объект Qt после обработки текущих событий. self._cancel_source(key) # Текущий пакет завершится корректно, остальные шаги задания отменяются. def _macro_tick(self, macro): # Периодический запуск не допускает перекрытия с незавершённым запуском того же макроса. key = (id(self), "macro", id(macro)) # Ключ одинаков для всех повторов одного выбранного макроса. if id(macro) not in self._macro_cycles or any(run["source"] is macro for run in self._runs) or self.queue.contains(key): # Учитываем также ещё завершающийся пакет остановленного задания. return # Пропущенные циклы не копятся ни в таймере, ни в очереди. self._start_commands(macro["commands"], macro=True, background=True, key=key, source=macro) # Команды исполняются в заданном порядке с фоновым приоритетом. def stop_macro(self): # Общая кнопка останавливает макросы, оставляя ручное чтение шин доступным. for macro in list(self.macros): # Галочки снимаются у всех сохранённых циклических макросов. self._macro_cycle_changed(macro, False) # Отменяем будущие повторения выбранного источника безопасно для текущего пакета. for run in list(self._runs): # Однократные макросы также могут ожидать или выполнять очередную команду. if run["macro"]: # Ручные действия SBUS и PBUS не являются остановленным макросом. self._cancel_source(run["key"]) # Отменяем только ещё не переданные шаги данного запуска. for container in self.macro_buttons: # Видимое состояние галочек отражает остановку независимых таймеров. container.findChild(QCheckBox).setChecked(False) # Повторный обработчик выключения безопасен и не запускает обмен. def stop(self): # Закрытие страницы или отключение порта очищает очередь всего соединения. self.sbus_cycle.setChecked(False) # Циклический SBUS больше не ставит запросов отключаемому порту. self.sbus_timer.stop() # Таймер останавливается также при уже выключенной галочке. self.pbus_group.stop_updates() # Прекращаем дальнейшие чтения аппаратного буфера PBUS. self.stop_macro() # Макросы не продолжаются после отключения соединения. for run in list(self._runs): # Снимаем оставшиеся ручные действия панели. self._cancel_source(run["key"]) # Поздние ответы не продвигают отключённые задания. self.queue.clear() # Операции памяти и остальных панелей того же соединения также отменяются. self.raw_protocol.cancel_async() # При закрытии соединения текущий транспортный запрос завершается отказом. def load_macros(self): """Загрузить макросы из файла""" if os.path.exists(self.MACROS_FILE): try: with open(self.MACROS_FILE, 'r', encoding='utf-8') as f: self.macros = json.load(f) print(f"Загружено {len(self.macros)} макросов") except Exception as e: print(f"Ошибка загрузки макросов: {e}") self.macros = [] else: self.macros = [] def save_macros(self): """Сохранить макросы в файл""" try: with open(self.MACROS_FILE, 'w', encoding='utf-8') as f: json.dump(self.macros, f, ensure_ascii=False, indent=2) print(f"Сохранено {len(self.macros)} макросов") except Exception as e: print(f"Ошибка сохранения макросов: {e}") def rebuild_macros_layout(self): """Перестроить layout макросов""" # Очищаем layout for grid in self.macros_scroll_layouts: while grid.count(): item = grid.takeAt(0) if item.widget(): item.widget().deleteLater() # Очищаем список кнопок self.macro_buttons.clear() # Пересоздаем все кнопки с правильными индексами for i, macro in enumerate(self.macros): self.add_macro_button_with_index(macro, i) def get_last_result(self): """Получить последний результат операции""" result = getattr(self, 'last_result', '') return result