Добавить очереди операций PBUS/SBUS, общую консоль, настройки Candle и SLCAN, декодеры DSView и обработку плотных графиков. Обновить протокол Бальзам и проверки общих компонентов.
965 lines
96 KiB
Python
965 lines
96 KiB
Python
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
|