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

965 lines
96 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.
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