commit c287274588eb5c38be0339163f27d57ea11426f2 Author: Razvalyaev Date: Tue Feb 10 11:32:38 2026 +0300 init commit Работа с сериальной шиной и макросами diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..29c5c60 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ + +/.vscode +/__pycache__ +/macros.json +/BusTerminal.exe diff --git a/build_and_clean.py b/build_and_clean.py new file mode 100644 index 0000000..6673e4f --- /dev/null +++ b/build_and_clean.py @@ -0,0 +1,124 @@ +import subprocess +import shutil +import os +from pathlib import Path +import PySide2 +from PyInstaller.utils.hooks import collect_data_files +# install: pip install PySide2 lxml nuitka pyinstaller +# - PyInstaller +# - nuitka +# - PySide2 +# - clang + +# === Конфигурация === +USE_NUITKA = True # True — сборка через Nuitka, False — через PyInstaller +MAIN_SCRIPT_NAME = "mainterm" +OUTPUT_NAME = "BusTerminal" +USE_ICON = False # True — использовать иконку, False — без иконки + + +SRC_PATH = Path("./") +SCRIPT_PATH = SRC_PATH / (MAIN_SCRIPT_NAME + ".py") + +DIST_PATH = Path("./").resolve() +WORK_PATH = Path("./build_temp").resolve() +SPEC_PATH = WORK_PATH +ICON_PATH = SRC_PATH / "icon.png" +ICON_ICO_PATH = SRC_PATH / "icon.ico" + +TEMP_FOLDERS = [ + "build_temp", + "__pycache__", + MAIN_SCRIPT_NAME + ".build", + MAIN_SCRIPT_NAME + ".onefile-build", + MAIN_SCRIPT_NAME + ".dist" +] +# === Пути к DLL и прочим зависимостям === +LIBS = { +} + +# === PySide2 плагины === +PySide2_path = Path(PySide2.__file__).parent +datas = [] +datas += collect_data_files('PySide2', includes=['plugins/platforms/*']) +datas += collect_data_files('PySide2', includes=['plugins/styles/*']) +datas += collect_data_files('PySide2', includes=['plugins/imageformats/*']) + +add_data_list = [f"{src};{dest}" for src, dest in datas] + +# Проверка наличия DLL и добавление +add_binary_list = [] +for name, path in LIBS.items(): + if path.exists(): + add_binary_list.append(f"{str(path)};{name}") + else: + print(f"WARNING: {path.name} не найден — он не будет включён в сборку") + +def clean_temp(): + for folder in TEMP_FOLDERS: + path = Path(folder) + if path.exists(): + shutil.rmtree(path, ignore_errors=True) + +if USE_NUITKA: + # Формируем include-data-file только для DLL + include_data_files = [f"--include-data-file={str(path)}={name}" for name, path in LIBS.items() if path.exists()] + + # Добавляем опциональную иконку как встроенный ресурс + if USE_ICON and ICON_ICO_PATH.exists(): + include_data_files.append(f"--include-data-file={ICON_ICO_PATH}=icon.ico") + + cmd = [ + "python", "-m", "nuitka", + "--standalone", + "--onefile", + "--enable-plugin=pyside2", + "--windows-console-mode=disable", + f"--output-dir={DIST_PATH}", + f"--output-filename={OUTPUT_NAME}.exe", + ] + + # Добавляем параметр иконки только если она нужна и существует + if USE_ICON and ICON_ICO_PATH.exists(): + cmd.append(f"--windows-icon-from-ico={ICON_ICO_PATH}") + + cmd.extend(include_data_files) + cmd.append(str(SCRIPT_PATH)) + +else: + # PyInstaller + cmd = [ + "pyinstaller", + "--name", OUTPUT_NAME, + "--distpath", str(DIST_PATH), + "--workpath", str(WORK_PATH), + "--specpath", str(SPEC_PATH), + "--windowed", + "--hidden-import=PySide2.QtWidgets", + "--hidden-import=PySide2.QtGui", + "--hidden-import=PySide2.QtCore", + *[arg for b in add_binary_list for arg in ("--add-binary", b)], + *[arg for d in add_data_list for arg in ("--add-data", d)], + ] + + # Добавляем иконку только если она нужна и существует + if USE_ICON and ICON_ICO_PATH.exists(): + cmd.append(f"--icon={ICON_ICO_PATH}") + else: + print("WARNING: Иконка не используется или не найдена") + + cmd.append(str(SCRIPT_PATH)) + +# === Запуск сборки === +print("Выполняется сборка с помощью " + ("Nuitka" if USE_NUITKA else "PyInstaller")) +print(" ".join(cmd)) + +try: + subprocess.run(cmd, check=True) + print("\nСборка успешно завершена!") + + # Удаление временных папок после сборки + clean_temp() + +except subprocess.CalledProcessError: + print("\nОшибка при сборке.") \ No newline at end of file diff --git a/mainterm.py b/mainterm.py new file mode 100644 index 0000000..9b81255 --- /dev/null +++ b/mainterm.py @@ -0,0 +1,203 @@ +import sys +import serial.tools.list_ports +from PySide2.QtWidgets import * +from PySide2.QtCore import * +from PySide2.QtGui import * + +from rawprotocol import RawProtocol, RawProtocolWidget +from serialbus import SerialBus, SerialTab + + + +class MainWindow(QMainWindow): + """Главное окно приложения""" + + def __init__(self): + super().__init__() + # Создаем низкоуровневый протокол + self.raw_protocol = RawProtocol() + # Создаем высокоуровневый протокол, передавая ему low-level + self.serial_bus = SerialBus(self.raw_protocol) + self.init_ui() + + def init_ui(self): + self.setWindowTitle("Терминал") + self.setGeometry(100, 100, 800, 600) + + # Главный виджет и компоновка + main_widget = QWidget() + main_layout = QVBoxLayout() + + # Центральный виджет с вкладками + self.tab_widget = QTabWidget() + + # Виджет для RAW протокола (всегда внизу) + self.raw_widget = RawProtocolWidget(self.raw_protocol) + + # Вкладка для серийной шины + self.serial_tab = SerialTab(self.serial_bus, self.raw_protocol, self.raw_widget) + self.tab_widget.addTab(self.serial_tab, "Серийная шина") + + main_layout.addWidget(self.tab_widget) + + main_layout.addWidget(self.raw_widget) + + main_widget.setLayout(main_layout) + self.setCentralWidget(main_widget) + + # Создание меню + self.create_menu() + + # Создание панели инструментов + self.create_toolbar() + + def create_menu(self): + menubar = self.menuBar() + + # Меню Файл + file_menu = menubar.addMenu("Файл") + + exit_action = QAction("Выход", self) + exit_action.triggered.connect(self.close) + file_menu.addAction(exit_action) + + # Меню Помощь + help_menu = menubar.addMenu("Помощь") + + protocol_action = QAction("Информация о протоколе", self) + protocol_action.triggered.connect(self.show_protocol_info) + help_menu.addAction(protocol_action) + + def create_toolbar(self): + toolbar = self.addToolBar("Подключение") + + # Выбор COM порта + toolbar.addWidget(QLabel("COM порт:")) + + self.port_combo = QComboBox() + self.port_combo.setMinimumWidth(150) + self.refresh_ports() + toolbar.addWidget(self.port_combo) + + # Кнопка обновления портов + refresh_btn = QToolButton() + refresh_btn.setText("⟳") + refresh_btn.setToolTip("Обновить список портов") + refresh_btn.clicked.connect(self.refresh_ports) + toolbar.addWidget(refresh_btn) + + # Кнопка подключения/отключения + self.connect_btn = QPushButton("Подключить") + self.connect_btn.clicked.connect(self.toggle_connection) + toolbar.addWidget(self.connect_btn) + + # Индикатор статуса + self.status_label = QLabel("Отключено") + self.status_label.setStyleSheet("color: red;") + toolbar.addWidget(self.status_label) + + toolbar.addSeparator() + + # Настройки скорости + toolbar.addWidget(QLabel("Скорость:")) + self.baud_combo = QComboBox() + self.baud_combo.addItems(["9600", "19200", "38400", "57600", "115200", "230400", "460800", "921600"]) + self.baud_combo.setCurrentText("115200") + toolbar.addWidget(self.baud_combo) + + def refresh_ports(self): + """Обновление списка доступных COM портов""" + self.port_combo.clear() + ports = serial.tools.list_ports.comports() + for port in ports: + self.port_combo.addItem(f"{port.device} - {port.description}") + + if not ports: + self.port_combo.addItem("Порты не найдены") + + def toggle_connection(self): + """Подключение/отключение от COM порта""" + if self.raw_protocol.is_connected: + # Отключение + self.raw_protocol.disconnect() + self.connect_btn.setText("Подключить") + self.status_label.setText("Отключено") + self.status_label.setStyleSheet("color: red;") + + # Блокировка элементов управления + self.serial_tab.bus_write_btn.setEnabled(False) + self.serial_tab.bus_read_btn.setEnabled(False) + self.raw_widget.raw_send_btn.setEnabled(False) + self.raw_widget.raw_read_btn.setEnabled(False) + else: + # Подключение + port_text = self.port_combo.currentText() + if " - " in port_text: + port_name = port_text.split(" - ")[0] + else: + port_name = port_text + + try: + baudrate = int(self.baud_combo.currentText()) + if self.raw_protocol.connect(port_name, baudrate): + self.connect_btn.setText("Отключить") + self.status_label.setText("Подключено") + self.status_label.setStyleSheet("color: green;") + + # Разблокировка элементов управления + self.serial_tab.bus_write_btn.setEnabled(True) + self.serial_tab.bus_read_btn.setEnabled(True) + self.raw_widget.raw_send_btn.setEnabled(True) + self.raw_widget.raw_read_btn.setEnabled(True) + else: + QMessageBox.warning(self, "Ошибка", "Не удалось подключиться к порту") + except Exception as e: + QMessageBox.warning(self, "Ошибка", f"Ошибка подключения: {e}") + + def show_protocol_info(self): + """Показать информацию о протоколе""" + info = """ + ИНФОРМАЦИЯ О ПРОТОКОЛЕ: + + На основе кода контроллера: + + 1. ФОРМАТ СООБЩЕНИЙ: + [адрес][команда][данные][CRC16-IBM] + + 2. КЛЮЧЕВЫЕ АДРЕСА: + - 0x200A: Регистр данных шины (запись) + - 0x200B: Регистр управления шиной + - 0x200F: Регистр данных шины (чтение) + + 3. ПРОТОКОЛ ШИНЫ: + ЗАПИСЬ: + 1. POKE 0x200A <данные> + 2. POKE 0x200B 0x80A0 | (адрес << 4) + + ЧТЕНИЕ: + 1. POKE 0x200B 0x00A0 | (адрес << 4) + 2. PEEK 0x200F + + 4. ЧТО НУЖНО УТОЧНИТЬ: + - Точные коды команд CMD_RS232_PEEK и CMD_RS232_POKE + - Длину полей в сообщениях + - Формат ответов от контроллера + """ + + QMessageBox.information(self, "Информация о протоколе", info) + + +def main(): + app = QApplication(sys.argv) + + # Установка стиля + app.setStyle("Fusion") + + window = MainWindow() + window.show() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/rawprotocol.py b/rawprotocol.py new file mode 100644 index 0000000..4683682 --- /dev/null +++ b/rawprotocol.py @@ -0,0 +1,350 @@ +import serial +import serial.tools.list_ports +import time +from PySide2.QtWidgets import * +from PySide2.QtCore import * +from PySide2.QtGui import * + + +class RawProtocol: + """НИЗКОУРОВНЕВЫЙ ПРОТОКОЛ PEEK/POKE с полной реализацией протокола контроллера""" + + def __init__(self): + self.serial_port = None + self.is_connected = False + + # Коды команд из кода контроллера + self.CMD_PEEK = 56 # CMD_RS232_PEEK + self.CMD_POKE = 57 # CMD_RS232_POKE + + def connect(self, port_name, baudrate=115200): + """Подключение к COM порту""" + try: + self.serial_port = serial.Serial( + port=port_name, + baudrate=baudrate, + bytesize=serial.EIGHTBITS, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + timeout=1 + ) + self.is_connected = True + return True + except Exception as e: + print(f"Ошибка подключения: {e}") + return False + + def disconnect(self): + """Отключение от COM порта""" + if self.serial_port and self.serial_port.is_open: + self.serial_port.close() + self.is_connected = False + + def calculate_crc(self, data, init=0xFFFF): + """CRC16-IBM (aka CRC-16/ANSI, polynomial 0xA001 reflected).""" + crc = init + for b in data: + crc ^= b + for _ in range(8): + if crc & 1: + crc = (crc >> 1) ^ 0xA001 + else: + crc >>= 1 + return crc & 0xFFFF + + def send_command(self, address, command, data_bytes): + """Отправка команды по протоколу контроллера""" + if not self.is_connected: + return None + + try: + # Формируем сообщение без CRC + message = bytearray() + message.append(address) # Адрес устройства + message.append(command) # Код команды + message.extend(data_bytes) # Данные команды + + # Добавляем CRC + crc = self.calculate_crc(message) + message.extend(crc.to_bytes(2, 'little')) + + # Отправляем + self.serial_port.write(message) + self.serial_port.flush() + + # Формируем hex строку с пробелами между байтами + hex_str = ' '.join(f'{b:02X}' for b in message) + print(f"Отправлено: {hex_str}") + + return message + + except Exception as e: + print(f"Ошибка отправки команды: {e}") + return None + + def validate_response(self, response_bytes): + """Проверка валидности ответа от контроллера""" + if len(response_bytes) < 4: # минимум адрес+команда+CRC + return False, "Слишком короткий ответ" + + # Проверяем CRC + received_crc = int.from_bytes(response_bytes[-2:], 'little') + calculated_crc = self.calculate_crc(response_bytes[:-2]) + + if received_crc != calculated_crc: + return False, f"Ошибка CRC: получено 0x{received_crc:04X}, ожидалось 0x{calculated_crc:04X}" + + # Извлекаем данные из ответа + address = response_bytes[0] + command = response_bytes[1] + data = response_bytes[2:-2] + + return True, { + 'address': address, + 'command': command, + 'data': data, + 'raw': response_bytes + } + + def receive_response(self, timeout=1.0): + """Прием ответа от контроллера""" + if not self.is_connected: + return None + + try: + # Читаем данные с таймаутом + self.serial_port.timeout = timeout + response = self.serial_port.read(1024) + + if response: + # Формируем hex строку с пробелами между байтами + hex_str = ' '.join(f'{b:02X}' for b in response) + print(f"Получено: {hex_str}") + return response + return None + + except Exception as e: + print(f"Ошибка приема: {e}") + return None + + def send_and_receive(self, address, command, data_bytes, timeout=1.0): + """Отправить команду и получить ответ от TMS""" + # Отправляем команду + sent = self.send_command(address, command, data_bytes) + if not sent: + return False, "Не удалось отправить команду" + + # Ждем ответ + time.sleep(0.01) # небольшая пауза + response = self.receive_response(timeout) + if not response: + return False, "Нет ответа от устройства" + + # Проверяем ответ + return self.validate_response(response) + + def poke(self, mem_address, data_value, device_address=1): + """ПОЛНАЯ команда POKE - запись в память с CRC и анализом ответа""" + # Формируем данные команды POKE + data = bytearray() + data.extend(mem_address.to_bytes(4, 'little')) + data.extend(data_value.to_bytes(4, 'little')) + + # Отправляем и получаем ответ + success, result = self.send_and_receive(device_address, self.CMD_POKE, data) + + if success: + # Ответ успешен, проверяем что это ответ на POKE + response_data = result['data'] + if len(response_data) >= 1: + # В ответе POKE обычно есть подтверждение + return True, result + return False, result + + def peek(self, mem_address, device_address=1): + """ПОЛНАЯ команда PEEK - чтение из памяти с CRC и анализом ответа""" + # Формируем данные команды PEEK + data = bytearray() + data.extend(mem_address.to_bytes(4, 'little')) + + # Отправляем и получаем ответ + success, result = self.send_and_receive(device_address, self.CMD_PEEK, data) + + if success: + # Извлекаем данные из ответа PEEK + response_data = result['data'] + if len(response_data) >= 2: + # Предполагаем что данные идут первыми 2 байтами + read_value = int.from_bytes(response_data[:2], 'little') + return True, {'value': read_value, 'raw_response': result} + return False, result + + def send_raw(self, hex_data): + """Отправка сырых данных (для отладки)""" + try: + hex_str = hex_data.replace(" ", "") + data = bytes.fromhex(hex_str) + + if self.is_connected and self.serial_port: + self.serial_port.write(data) + self.serial_port.flush() + return True, f"Отправлено: {hex_str}" + else: + return False, "Порт не подключен" + + except ValueError: + return False, "Некорректные hex данные" + + def receive_raw(self, max_bytes=1024): + """Чтение сырых данных (для отладки)""" + if self.is_connected and self.serial_port: + data = self.serial_port.read(max_bytes) + if data: + return True, data.hex() + else: + return False, "Нет данных" + else: + return False, "Порт не подключен" + + + +class RawProtocolWidget(QWidget): + """Виджет для отладки RAW протокола (всегда виден внизу окна)""" + + def __init__(self, raw_protocol): + super().__init__() + self.raw_protocol = raw_protocol + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout() + + # Группа для ПРЯМОГО ДОСТУПА К ПАМЯТИ (PEEK/POKE) + mem_group = QGroupBox("Прямой доступ к памяти (PEEK/POKE)") + mem_layout = QGridLayout() + + mem_layout.addWidget(QLabel("Адрес памяти (hex):"), 0, 0) + self.mem_addr_edit = QLineEdit("2000") + self.mem_addr_edit.setMaximumWidth(100) + mem_layout.addWidget(self.mem_addr_edit, 0, 1) + + mem_layout.addWidget(QLabel("Данные (hex):"), 1, 0) + self.mem_data_edit = QLineEdit("0000") + self.mem_data_edit.setMaximumWidth(100) + mem_layout.addWidget(self.mem_data_edit, 1, 1) + + self.mem_write_btn = QPushButton("Записать (POKE)") + self.mem_write_btn.clicked.connect(self.memory_write) + mem_layout.addWidget(self.mem_write_btn, 0, 2) + + self.mem_read_btn = QPushButton("Прочитать (PEEK)") + self.mem_read_btn.clicked.connect(self.memory_read) + mem_layout.addWidget(self.mem_read_btn, 1, 2) + + self.mem_result_label = QLabel("---") + mem_layout.addWidget(self.mem_result_label, 1, 3) + + mem_group.setLayout(mem_layout) + layout.addWidget(mem_group) + + # Группа для RAW отправки + raw_group = QGroupBox("RAW отправка/чтение") + raw_layout = QVBoxLayout() + + self.raw_send_edit = QLineEdit() + self.raw_send_edit.setPlaceholderText("Введите hex данные для отправки") + raw_layout.addWidget(self.raw_send_edit) + + hbox = QHBoxLayout() + self.raw_send_btn = QPushButton("Отправить RAW") + self.raw_send_btn.clicked.connect(self.send_raw_data) + hbox.addWidget(self.raw_send_btn) + + self.raw_read_btn = QPushButton("Прочитать RAW") + self.raw_read_btn.clicked.connect(self.read_raw_data) + hbox.addWidget(self.raw_read_btn) + + + raw_layout.addLayout(hbox) + + self.raw_output = QTextEdit() + self.raw_output.setReadOnly(True) + self.raw_output.setMaximumHeight(100) + raw_layout.addWidget(self.raw_output) + + + self.raw_clear_btn = QPushButton("Очистить логи") + self.raw_clear_btn.clicked.connect(self.clear_logs) + raw_layout.addWidget(self.raw_clear_btn) + + raw_group.setLayout(raw_layout) + layout.addWidget(raw_group) + + self.setLayout(layout) + def memory_write(self): + """Запись в память (POKE)""" + try: + device_addr = 10 + mem_addr = int(self.mem_addr_edit.text(), 16) + data = int(self.mem_data_edit.text(), 16) + + success, result = self.raw_protocol.poke(mem_addr, data, device_addr) + + if success: + self.raw_output.append(f"POKE 0x{mem_addr:04X} = 0x{data:04X}") + self.raw_output.append(f"Ответ: {result['raw'].hex()}") + else: + self.raw_output.append(f"Ошибка POKE: {result}") + + except ValueError as e: + self.raw_output.append(f"Ошибка в данных: {e}") + + def memory_read(self): + """Чтение из памяти (PEEK)""" + try: + device_addr = 10 + mem_addr = int(self.mem_addr_edit.text(), 16) + + success, result = self.raw_protocol.peek(mem_addr, device_addr) + + if success: + value = result['value'] + self.mem_result_label.setText(f"0x{value:04X} ({value})") + self.raw_output.append(f"PEEK 0x{mem_addr:04X} = 0x{value:04X}") + self.raw_output.append(f"Ответ: {result['raw_response']['raw'].hex()}") + else: + self.mem_result_label.setText(f"Ошибка: {result}") + self.raw_output.append(f"Ошибка PEEK: {result}") + + except ValueError as e: + self.mem_result_label.setText(f"Ошибка: {e}") + self.raw_output.append(f"Ошибка в данных: {e}") + + def send_raw_data(self): + """Отправка сырых данных""" + hex_str = self.raw_send_edit.text() + success, result = self.raw_protocol.send_raw(hex_str) + + if success: + self.raw_output.append(result) + else: + self.raw_output.append(f"Ошибка: {result}") + + def read_raw_data(self): + """Чтение сырых данных""" + success, result = self.raw_protocol.receive_raw() + + if success: + self.raw_output.append(f"Получено: {result}") + else: + self.raw_output.append(f"Ошибка: {result}") + + def append_text(self, text): + """Добавить текст в вывод (используется SerialTab)""" + if text: + self.raw_output.append(text) + + + def clear_logs(self): + """Очистить лог""" + self.raw_output.clear() \ No newline at end of file diff --git a/serialbus.py b/serialbus.py new file mode 100644 index 0000000..898a322 --- /dev/null +++ b/serialbus.py @@ -0,0 +1,659 @@ +import json +import os +from functools import partial +from PySide2.QtWidgets import * +from PySide2.QtCore import * +from PySide2.QtGui import * + +class SerialBus: + """ВЫСОКОУРОВНЕВЫЙ протокол для работы с серийной шиной (только через регистры)""" + + def __init__(self, raw_protocol): + self.raw = raw_protocol # Используем низкоуровневый протокол + + def write_bus(self, data_word, address_tetrad, register_num=0, device_address=1): + """ + Запись в шину по протоколу: + 1. POKE 0x200A data_word + 2. POKE 0x200B (0x80AR | (address_tetrad << 4)), где R - номер регистра + """ + # 1. Запись данных в регистр шины + success1, result1 = self.raw.poke(0x200A, data_word, device_address) + if not success1: + return False, f"Ошибка записи данных: {result1}" + + # 2. Запись управляющего слова + control_word = 0x8000 | ((address_tetrad & 0xF) << 4) | (register_num & 0xF) + success2, result2 = self.raw.poke(0x200B, control_word, device_address) + + if success2: + return True, f"Запись успешна: управляющее слово=0x{control_word:04X}" + else: + return False, f"Ошибка записи управляющего слова: {result2}" + + def read_bus(self, address_tetrad, register_num=0, device_address=1): + """ + Чтение из шины по протоколу: + 1. POKE 0x200B (0x00AR | (address_tetrad << 4)), где R - номер регистра + 2. PEEK 0x200F + """ + # 1. Запись управляющего слова для чтения + control_word = 0x0000 | ((address_tetrad & 0xF) << 4) | (register_num & 0xF) + success1, result1 = self.raw.poke(0x200B, control_word, device_address) + if not success1: + return False, f"Ошибка записи управляющего слова: {result1}" + + # 2. Чтение данных из регистра шины + success2, result2 = self.raw.peek(0x200F, device_address) + + if success2: + return True, result2 + else: + return False, result2 + + +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.addItems(["Запись", "Чтение"]) + type_layout.addWidget(self.cmd_type) + layout.addLayout(type_layout) + + # Адрес устройства + addr_layout = QHBoxLayout() + addr_layout.addWidget(QLabel("Адрес устройства (0-14):")) + 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) + + # Загружаем данные команды, если она передана + if self.command: + if self.command.get('type') == 'write': + self.cmd_type.setCurrentText("Запись") + 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}") + else: + self.cmd_type.setCurrentText("Чтение") + self.device_addr.setValue(self.command.get('tetrad', 0)) + self.register_num.setValue(self.command.get('register', 0)) + + # Подключаем изменение типа команды + 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) + 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_cmd_type_changed(self, text): + """Показать/скрыть поле данных в зависимости от типа команды""" + if text == "Запись": + self.data_label.show() + self.data_edit.show() + else: + self.data_label.hide() + self.data_edit.hide() + + def get_command(self): + """Получить команду из диалога""" + cmd_type = 'write' if self.cmd_type.currentText() == "Запись" else 'read' + command = { + 'type': cmd_type, + 'tetrad': self.device_addr.value(), + 'register': self.register_num.value() + } + + if cmd_type == 'write': + try: + command['data'] = int(self.data_edit.text(), 16) + except ValueError: + command['data'] = 0 + + return command + + +class MacroEditorDialog(QDialog): + """Диалог для создания/редактирования макроса""" + + def __init__(self, parent=None, macro=None): + super().__init__(parent) + self.macro = 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) + + # Список команд + 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['type'] == 'write': + text = f"Запись: устр.{cmd['tetrad']}, рег.{cmd['register']}, 0x{cmd['data']:04X}" + else: + text = f"Чтение: устр.{cmd['tetrad']}, рег.{cmd['register']}" + self.commands_list.addItem(text) + + 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(), + 'commands': self.macro['commands'].copy() + } + + + +class SerialTab(QWidget): + """Вкладка для работы с серийной шиной (ТОЛЬКО операции с шиной)""" + + MACROS_FILE = "macros.json" # Файл для сохранения макросов + + def __init__(self, serial_bus, raw_protocol, raw_widget): + super().__init__() + self.serial_bus = serial_bus + self.raw_protocol = raw_protocol + self.raw_widget = raw_widget + self.macros = [] # Список макросов + self.macro_buttons = [] # Список кнопок макросов + + # Загружаем сохраненные макросы + self.load_macros() + + self.init_ui() + + + def init_ui(self): + layout = QVBoxLayout() + + # Группа для операций с шиной + bus_group = QGroupBox("Операции с шиной (через регистры 0x200A/0x200B/0x200F)") + bus_layout = QGridLayout() + + bus_layout.addWidget(QLabel("Данные для шины (hex):"), 0, 0) + self.bus_data_edit = QLineEdit("0000") + self.bus_data_edit.setMaximumWidth(100) + bus_layout.addWidget(self.bus_data_edit, 0, 1) + + bus_layout.addWidget(QLabel("Адреса (0-14):"), 0, 2) + self.bus_tetrad_edit = QLineEdit("0") + self.bus_tetrad_edit.setMaximumWidth(50) + bus_layout.addWidget(self.bus_tetrad_edit, 0, 3) + + bus_layout.addWidget(QLabel("Регистр (0-15):"), 0, 4) + self.bus_register_edit = QLineEdit("0") + self.bus_register_edit.setMaximumWidth(50) + bus_layout.addWidget(self.bus_register_edit, 0, 5) + + self.bus_write_btn = QPushButton("Записать в шину") + self.bus_write_btn.clicked.connect(self.write_to_bus) + bus_layout.addWidget(self.bus_write_btn, 1, 0, 1, 3) + + self.bus_read_btn = QPushButton("Прочитать из шину") + self.bus_read_btn.clicked.connect(self.read_from_bus) + bus_layout.addWidget(self.bus_read_btn, 1, 3, 1, 3) + + bus_group.setLayout(bus_layout) + layout.addWidget(bus_group) + + # --- Группа для макросов --- + macros_group = QGroupBox("Макросы") + macros_layout = QVBoxLayout() + + # Кнопка создания нового макроса + self.create_macro_btn = QPushButton("Создать новый макрос") + self.create_macro_btn.clicked.connect(self.create_macro) + macros_layout.addWidget(self.create_macro_btn) + + # Прокручиваемая область для кнопок макросов + self.macros_scroll_area = QScrollArea() + self.macros_scroll_area.setWidgetResizable(True) + # Устанавливаем фиксированную высоту, чтобы вместить 3-4 строки макросов + self.macros_scroll_area.setMinimumHeight(150) # Высота для 3-4 строк + + self.macros_scroll_content = QWidget() + self.macros_scroll_layout = QGridLayout() + self.macros_scroll_content.setLayout(self.macros_scroll_layout) + self.macros_scroll_area.setWidget(self.macros_scroll_content) + self.macros_scroll_layout.setAlignment(Qt.AlignTop) + macros_layout.addWidget(self.macros_scroll_area) + + macros_group.setLayout(macros_layout) + layout.addWidget(macros_group) + + # --- ВНЕ ГРУППЫ: Результаты чтения --- + results_layout = QGridLayout() + + # Считанное слово + results_layout.addWidget(QLabel("Считанное слово:"), 0, 0) + self.bus_read_value_label = QLabel("---") + self.bus_read_value_label.setStyleSheet("font-weight: bold; font-size: 14px; border: 1px solid #ccc; padding: 5px;") + results_layout.addWidget(self.bus_read_value_label, 0, 1) + + # Ответ контроллера + results_layout.addWidget(QLabel("Ответ контроллера:"), 1, 0) + self.bus_result_label = QLabel("---") + self.bus_result_label.setStyleSheet("border: 1px solid #ccc; padding: 5px; font-family: monospace;") + self.bus_result_label.setWordWrap(True) + results_layout.addWidget(self.bus_result_label, 1, 1) + + # Создаем фрейм для результатов + results_frame = QFrame() + results_frame.setFrameShape(QFrame.StyledPanel) + results_frame.setLayout(results_layout) + layout.addWidget(results_frame) + + layout.addStretch() + self.setLayout(layout) + + # После создания интерфейса создаем кнопки для загруженных макросов + # ВАЖНО: Используем enumerate для правильных индексов + for i, macro in enumerate(self.macros): + self.add_macro_button_with_index(macro, i) + + def write_to_bus(self): + """Запись в шину через регистры""" + try: + device_addr = 10 + data = int(self.bus_data_edit.text(), 16) + tetrad = int(self.bus_tetrad_edit.text()) + register_num = int(self.bus_register_edit.text()) + + success, result = self.serial_bus.write_bus(data, tetrad, register_num, device_addr) + + if success: + self.raw_widget.append_text(f"Запись в шину: адрес={tetrad}, рег={register_num}, данные=0x{data:04X} - OK") + self.last_result = f"Запись в шину: данные=0x{data:04X}, адрес=0x{tetrad:X}, регистр=0x{register_num:X}" + self.last_result += f"\n{result}" + else: + self.raw_widget.append_text(f"Запись в шину: адрес={tetrad}, рег={register_num} - Ошибка: {result}") + self.last_result = f"Ошибка записи в шину: {result}" + + except ValueError as e: + self.raw_widget.append_text(f"Ошибка в данных: {e}") + self.last_result = f"Ошибка в данных: {e}" + + def read_from_bus(self): + """Чтение из шины через регистры""" + try: + device_addr = 10 + tetrad = int(self.bus_tetrad_edit.text()) + register_num = int(self.bus_register_edit.text()) + + success, result = self.serial_bus.read_bus(tetrad, register_num, device_addr) + + if success: + # result теперь словарь с данными + hex_str = ' '.join(f'{b:02X}' for b in result['raw_response']['raw']) + self.bus_result_label.setText(f"{hex_str}") + + # Извлекаем считанное слово + read_word = result['value'] + self.bus_read_value_label.setText(f"0x{read_word:04X} ({read_word})") + + self.raw_widget.append_text(f"Чтение из шины: адрес={tetrad}, рег={register_num} = 0x{read_word:04X}") + + self.last_result = f"Чтение из шины: адрес=0x{tetrad:X}, регистр=0x{register_num:X}" + self.last_result += f"\nСчитано: 0x{read_word:04X} ({read_word})" + self.last_result += f"\nОтвет контроллера: {hex_str}" + else: + self.bus_result_label.setText(f"Ошибка: {result}") + self.bus_read_value_label.setText("---") + self.raw_widget.append_text(f"Чтение из шины: адрес={tetrad}, рег={register_num} - Ошибка: {result}") + self.last_result = f"Ошибка чтения из шины: {result}" + + except ValueError as e: + self.bus_result_label.setText(f"Ошибка: {e}") + self.bus_read_value_label.setText("---") + self.raw_widget.append_text(f"Ошибка в данных: {e}") + self.last_result = f"Ошибка в данных: {e}" + + except ValueError as e: + self.bus_result_label.setText(f"Ошибка: {e}") + self.bus_read_value_label.setText("---") + error_text = f"Ошибка в данных: {e}" + self.raw_widget.append_text(f"✗ {error_text}") + self.last_result = error_text + + 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): + """Добавить кнопку макроса в интерфейс с указанным индексом""" + # Изменяем расчет строк и колонок для 3 колонок + items_per_row = 3 # Теперь 3 кнопки в строке + row = len(self.macro_buttons) // items_per_row + col = len(self.macro_buttons) % items_per_row + + # Создаем контейнер для кнопки и кнопки редактирования + container = QWidget() + container_layout = QHBoxLayout() + container_layout.setContentsMargins(0, 0, 0, 0) + + # Основная кнопка макроса + macro_btn = QPushButton(macro['name']) + macro_btn.setMinimumHeight(40) + macro_btn.setStyleSheet(""" + QPushButton { + font-weight: bold; + text-align: left; + padding: 10px; + } + QPushButton:hover { + background-color: #e0e0e0; + } + """) + + # Используем partial для фиксации индекса + macro_btn.clicked.connect(partial(self.execute_macro_by_index, index)) + + # Кнопка редактирования + edit_btn = QPushButton("✎") + edit_btn.setFixedSize(30, 30) + edit_btn.setToolTip("Редактировать макрос") + edit_btn.clicked.connect(partial(self.edit_macro_by_index, index)) + + # Кнопка удаления + delete_btn = QPushButton("✕") + delete_btn.setFixedSize(30, 30) + 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) + container.setLayout(container_layout) + + self.macros_scroll_layout.addWidget(container, row, col, 1, 1) # span изменен на 1 + 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.execute_macro_commands(self.macros[macro_index]['commands']) + + 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() + self.macros[macro_index] = edited_macro + + # Обновляем кнопку - находим ее по индексу + 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']) + + 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: + # Удаляем макрос из списка + del self.macros[macro_index] + + # Полностью перестраиваем layout + self.rebuild_macros_layout() + + # Сохраняем изменения + self.save_macros() + + def execute_macro_commands(self, commands): + """Выполнить список команд макроса""" + last_read_result = None + + for i, cmd in enumerate(commands): + try: + if cmd['type'] == 'write': + success, result = self.serial_bus.write_bus( + cmd['data'], + cmd['tetrad'], + cmd['register'], + 10 + ) + if success: + self.raw_widget.append_text(f"Макрос: запись устр.{cmd['tetrad']}, рег.{cmd['register']}, 0x{cmd['data']:04X} - OK") + else: + self.raw_widget.append_text(f"Макрос: запись устр.{cmd['tetrad']}, рег.{cmd['register']} - Ошибка: {result}") + + else: # read + success, result = self.serial_bus.read_bus( + cmd['tetrad'], + cmd['register'], + 10 + ) + if success: + # Сохраняем последний результат чтения для отображения + last_read_result = result + hex_str = ' '.join(f'{b:02X}' for b in result['raw_response']['raw']) + read_word = result['value'] + self.raw_widget.append_text(f"Макрос: чтение устр.{cmd['tetrad']}, рег.{cmd['register']} = 0x{read_word:04X}") + else: + self.raw_widget.append_text(f"Макрос: чтение устр.{cmd['tetrad']}, рег.{cmd['register']} - Ошибка: {result}") + + except Exception as e: + self.raw_widget.append_text(f"Макрос команда {i+1}: ошибка - {e}") + + # Обновляем интерфейс с результатами последнего чтения + if last_read_result: + hex_str = ' '.join(f'{b:02X}' for b in last_read_result['raw_response']['raw']) + read_word = last_read_result['value'] + self.bus_result_label.setText(hex_str) + self.bus_read_value_label.setText(f"0x{read_word:04X} ({read_word})") + + + 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 i in reversed(range(self.macros_scroll_layout.count())): + item = self.macros_scroll_layout.itemAt(i) + if item and item.widget(): + item.widget().setParent(None) + + # Очищаем список кнопок + 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 \ No newline at end of file