commit 0b3b66a38b56b4a7282978734e9cd445471b3f20 Author: Danil Iarosh Date: Thu Mar 19 15:49:00 2026 +0300 Initial commit: Plate Config App v1.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4e2c6f --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# ── Python ───────────────────────────────────────────── +__pycache__/ +*.py[cod] +*.pyo +*.egg-info/ +*.egg +.eggs/ + +# ── Virtual environment ─────────────────────────────── +.venv/ +venv/ +env/ + +# ── PyInstaller build ───────────────────────────────── +build/ +dist/ +*.manifest +*.spec.bak + +# ── IDE / Editor ────────────────────────────────────── +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# ── Runtime / user config ───────────────────────────── +settings.json +.qta_localappdata/ diff --git a/BUILD_EXE.md b/BUILD_EXE.md new file mode 100644 index 0000000..b9800d3 --- /dev/null +++ b/BUILD_EXE.md @@ -0,0 +1,67 @@ +# Сборка EXE + +## Предварительные требования + +- Python ≥ 3.11 (виртуальное окружение `.venv`) +- Зависимости из `requirements-build.txt` + +## Быстрый старт + +```powershell +# Установка зависимостей + чистая сборка (одна команда) +.\build_exe.ps1 -InstallBuildDeps +``` + +Готовый файл: `dist\plate_config_app.exe` + +## Команды по отдельности + +### 1. Установить зависимости для сборки + +```powershell +.\.venv\Scripts\Activate.ps1 +python -m pip install -r requirements-build.txt +``` + +### 2. Собрать EXE + +```powershell +.\build_exe.ps1 +``` + +Скрипт автоматически: +- очищает `build/`, `dist/`, `__pycache__/` +- запускает PyInstaller по `plate_config_app.spec` +- выводит путь и размер готового EXE + +### 3. Только очистка (без сборки) + +```powershell +.\build_exe.ps1 -CleanOnly +``` + +или отдельным скриптом: + +```powershell +.\clean.ps1 +``` + +## Через .bat (двойной клик) + +| Файл | Действие | +|------------------|----------------------------------| +| `build_exe.bat` | Очистка + сборка EXE | +| `clean.bat` | Только очистка артефактов сборки | + +## Что включается в EXE + +Настроено в `plate_config_app.spec`: + +| Ресурс | Описание | +|----------------------|---------------------------------| +| `light_modes/` | Файлы режимов подсветки (.lm) | +| `input_mappings/` | Маппинги входов (.mapping) | +| `commands/` | Наборы команд (.csv) | +| `qtawesome/` | Шрифты и charmap иконок | + +Hidden imports: `qtawesome`, `Crypto.Cipher._raw_aes`, `Crypto.Util._raw_api` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..a62e474 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Plate Config App package.""" diff --git a/app/entry.py b/app/entry.py new file mode 100644 index 0000000..ac2a8c1 --- /dev/null +++ b/app/entry.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import sys + +from PySide6.QtGui import QFont +from PySide6.QtWidgets import QApplication + +from .main_window import MainWindow +from .services import settings_service +from .ui.icons import ICON_COLOR_PRIMARY, icon +from .ui.theme import build_stylesheet, get_palette + + +def main() -> None: + app = QApplication(sys.argv) + app.setStyle("Fusion") + app.setFont(QFont("Segoe UI Variable", 10)) + + palette = get_palette(settings_service.theme_mode(), + settings_service.theme_accent()) + app.setStyleSheet(build_stylesheet(palette)) + app_icon = icon("fa5s.microchip", color=ICON_COLOR_PRIMARY) + if not app_icon.isNull(): + app.setWindowIcon(app_icon) + window = MainWindow() + if not app_icon.isNull(): + window.setWindowIcon(app_icon) + window.show() + sys.exit(app.exec()) diff --git a/app/main_window.py b/app/main_window.py new file mode 100644 index 0000000..e930404 --- /dev/null +++ b/app/main_window.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from PySide6.QtCore import QSize, Qt +from PySide6.QtGui import QCloseEvent +from PySide6.QtWidgets import ( + QFrame, + QHBoxLayout, + QMainWindow, + QStackedWidget, + QTabBar, + QToolButton, + QVBoxLayout, + QWidget, +) + +from .ui.icons import ICON_COLOR_ACCENT, ICON_COLOR_MUTED, ICON_COLOR_PRIMARY, icon +from .widgets.connection_tab import ConnectionTab +from .widgets.start_tab import StartTab +from .widgets.title_bar import TitleBar + + +class MainWindow(QMainWindow): + + def __init__(self) -> None: + super().__init__() + self.setObjectName("AppWindow") + self.setWindowTitle("Plate Config App") + self.resize(820, 560) + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) + self.setMinimumSize(760, 500) + + container = QWidget() + container.setObjectName("MainContainer") + container_layout = QVBoxLayout(container) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(0) + + self.title_bar = TitleBar(self) + container_layout.addWidget(self.title_bar) + + self.tabs = QTabBar() + self.tabs.setObjectName("TitleTabs") + self.tabs.setDrawBase(False) + self.tabs.setExpanding(False) + self.tabs.setElideMode(Qt.TextElideMode.ElideRight) + self.tabs.setIconSize(QSize(12, 12)) + self.tabs.currentChanged.connect(self._on_tab_changed) + self.title_bar.set_tabs_widget(self.tabs) + + self.content_frame = QFrame() + self.content_frame.setObjectName("ContentArea") + content_layout = QVBoxLayout(self.content_frame) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(0) + + self.pages = QStackedWidget() + content_layout.addWidget(self.pages) + container_layout.addWidget(self.content_frame, 1) + + self.setCentralWidget(container) + + start_tab = StartTab(self.new_connection_tab) + self._add_tab(start_tab, "Главная", icon_name="fa5s.home", closable=False) + self.tabs.setCurrentIndex(0) + + def new_connection_tab(self) -> None: + tab: ConnectionTab | None = None + + def get_index() -> int: + if tab is None: + return -1 + return self.pages.indexOf(tab) + + tab = ConnectionTab( + tab_index_getter=get_index, + set_tab_title=self._set_tab_title, + close_tab=self._close_tab, + set_tab_indicator=self._set_tab_indicator, + ) + index = self._add_tab(tab, "Новое соединение", icon_name="fa5s.plug", closable=True) + self.tabs.setCurrentIndex(index) + + # ── Управление вкладками ──────────────────────────────────────── + + def _close_tab(self, index: int) -> None: + if index == 0: + return + widget = self.pages.widget(index) + if widget is None: + return + if isinstance(widget, ConnectionTab): + widget.close_serial() + self.pages.removeWidget(widget) + widget.deleteLater() + self.tabs.removeTab(index) + + # обновляем текущую вкладку и иконки (индексы сдвинулись) + if self.tabs.count() > 0: + self.tabs.setCurrentIndex(min(index, self.tabs.count() - 1)) + self._refresh_tab_icons() + + def closeEvent(self, event: QCloseEvent) -> None: + for i in range(self.pages.count()): + widget = self.pages.widget(i) + if isinstance(widget, ConnectionTab): + widget.close_serial() + super().closeEvent(event) + + def _attach_close_button(self, index: int) -> None: + # Контейнер: [индикатор] [кнопка закрытия] + container = QWidget(self.tabs) + container.setObjectName("TabRightContainer") + h = QHBoxLayout(container) + h.setContentsMargins(2, 0, 4, 0) + h.setSpacing(4) + + # Индикатор состояния (скрыт по умолчанию) + indicator = QWidget(container) + indicator.setObjectName("TabStatusDot") + indicator.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + indicator.setFixedSize(8, 8) + indicator.setProperty("dotColor", "none") + indicator.hide() + h.addWidget(indicator, 0, Qt.AlignmentFlag.AlignVCenter) + + close_button = QToolButton(container) + close_button.setText("×") + close_icon = icon("fa5s.times", color=ICON_COLOR_MUTED) + if not close_icon.isNull(): + close_button.setIcon(close_icon) + close_button.setText("") + close_button.setCursor(Qt.CursorShape.PointingHandCursor) + close_button.setObjectName("TabCloseButton") + close_button.setProperty("tabClose", True) + close_button.setAutoRaise(True) + close_button.setFixedSize(14, 14) + close_button.setIconSize(QSize(10, 10)) + close_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + close_button.clicked.connect(self._close_tab_from_button) + h.addWidget(close_button, 0, Qt.AlignmentFlag.AlignVCenter) + + # Фиксируем размер с учётом индикатора (даже скрытого), + # чтобы QTabBar не пересчитывал позицию при показе/скрытии + indicator.show() + full_size = container.sizeHint() + indicator.hide() + container.setFixedSize(full_size) + self.tabs.setTabButton(index, QTabBar.ButtonPosition.RightSide, container) + + def _set_tab_indicator(self, index: int, color: str) -> None: + """Установить цвет индикатора (green / yellow / red / none) для вкладки.""" + container = self.tabs.tabButton(index, QTabBar.ButtonPosition.RightSide) + if container is None: + return + indicator = container.findChild(QWidget, "TabStatusDot") + if indicator is None: + return + if color == "none": + indicator.hide() + else: + indicator.setProperty("dotColor", color) + indicator.show() + # Перезагрузить стиль + style = indicator.style() + style.unpolish(indicator) + style.polish(indicator) + indicator.update() + + def _refresh_tab_icons(self) -> None: + """Обновляет иконки вкладок и иконки кнопок закрытия в зависимости от текущей вкладки.""" + current = self.tabs.currentIndex() + for index in range(self.tabs.count()): + clr = ICON_COLOR_ACCENT if index == current else ICON_COLOR_PRIMARY + + page = self.pages.widget(index) + icon_name = getattr(page, "_tab_icon_name", None) + if icon_name: + tab_icon = icon(icon_name, color=clr) + if not tab_icon.isNull(): + self.tabs.setTabIcon(index, tab_icon) + + # Кнопка закрытия теперь внутри контейнера + container = self.tabs.tabButton(index, QTabBar.ButtonPosition.RightSide) + if container is not None: + close_btn = container.findChild(QToolButton, "TabCloseButton") + if close_btn is not None: + close_icon = icon("fa5s.times", color=clr) + if not close_icon.isNull(): + close_btn.setIcon(close_icon) + + def _close_tab_from_button(self) -> None: + sender = self.sender() + for index in range(self.tabs.count()): + container = self.tabs.tabButton(index, QTabBar.ButtonPosition.RightSide) + if container is not None: + close_btn = container.findChild(QToolButton, "TabCloseButton") + if close_btn is sender: + self._close_tab(index) + return + + def _add_tab(self, widget: QWidget, title: str, icon_name: str, *, closable: bool) -> int: + index = self.pages.addWidget(widget) + self.tabs.addTab(title) + # сохраняем имя иконки в виджете для дальнейшего обновления цвета + setattr(widget, "_tab_icon_name", icon_name) + tab_icon = icon(icon_name, color=ICON_COLOR_MUTED) + if not tab_icon.isNull(): + self.tabs.setTabIcon(index, tab_icon) + + if closable: + self._attach_close_button(index) + else: + self.tabs.setTabButton(index, QTabBar.ButtonPosition.LeftSide, None) + self.tabs.setTabButton(index, QTabBar.ButtonPosition.RightSide, None) + # синхронизируем цвета иконок + self._refresh_tab_icons() + return index + + def _set_tab_title(self, index: int, title: str) -> None: + if 0 <= index < self.tabs.count(): + self.tabs.setTabText(index, title) + # поставить иконку "подключено" и сохранить её имя для управления цветом + page = self.pages.widget(index) + if page is not None: + setattr(page, "_tab_icon_name", "fa5s.microchip") + connected_icon = icon("fa5s.microchip", color=ICON_COLOR_PRIMARY) + if not connected_icon.isNull(): + self.tabs.setTabIcon(index, connected_icon) + self._refresh_tab_icons() + + def _on_tab_changed(self, index: int) -> None: + if index >= 0: + self.pages.setCurrentIndex(index) + self._refresh_tab_icons() diff --git a/app/serial_config.py b/app/serial_config.py new file mode 100644 index 0000000..8e9222e --- /dev/null +++ b/app/serial_config.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class SerialParams: + baudrate: int + bytesize: int + stopbits: float + parity: str + xonxoff: bool + rtscts: bool diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..b94da47 --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""Services layer for hardware and external integrations.""" diff --git a/app/services/auth_service.py b/app/services/auth_service.py new file mode 100644 index 0000000..d075dfd --- /dev/null +++ b/app/services/auth_service.py @@ -0,0 +1,159 @@ +"""AES-256 двойное рукопожатие (авторизация) с платой периферийного контроллера. + +Алгоритм (из документации «Система команд платы v2.x»): +1. Компьютер → плата: 07<32 hex> (16 случайных байт) +2. Плата → компьютер: CHAL<32 hex> (встречный запрос, 16 байт) +3. Компьютер → плата: 08<32 hex> (зашифрованный ответ на CHAL) +4. Плата → компьютер: RAES<32 hex> (ответ на запрос из шага 1) + +Компьютер проверяет RAES — расшифровывает и сравнивает с исходными 16 байт. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from enum import Enum, auto + +from Crypto.Cipher import AES + +from .com_service import ComConnection + +# ── Фиксированный AES-256 ключ (32 байта) ────────────────────────────── +_AES_KEY = bytes(( + 0x69, 0x40, 0xC7, 0x0E, 0xF2, 0xC7, 0x45, 0x70, + 0x72, 0xCD, 0x28, 0x58, 0x99, 0x8F, 0x73, 0xA8, + 0x5F, 0x5F, 0xED, 0xB4, 0xC1, 0x62, 0xB4, 0x24, + 0xAC, 0xD7, 0xA3, 0xD6, 0xD8, 0x0B, 0x50, 0x37, +)) + + +class AuthStatus(Enum): + OK = auto() + NEED_AUTH = auto() + FAILED = auto() + TIMEOUT = auto() + DISCONNECTED = auto() + + +@dataclass +class AuthResult: + status: AuthStatus + detail: str = "" + + +def _aes_encrypt(plaintext: bytes, key: bytes = _AES_KEY) -> bytes: + """ECB-шифрование одного 16-байтового блока.""" + cipher = AES.new(key, AES.MODE_ECB) + return cipher.encrypt(plaintext) + + +def _aes_decrypt(ciphertext: bytes, key: bytes = _AES_KEY) -> bytes: + cipher = AES.new(key, AES.MODE_ECB) + return cipher.decrypt(ciphertext) + + +def check_status(conn: ComConnection, timeout: float = 3.0) -> AuthStatus: + """Отправить команду 99 и ждать ``READY!`` в ответе. + + Если ``READY!`` получен — плата авторизована и готова. + Если за *timeout* секунд ``READY!`` не пришёл — требуется Handshake. + """ + if not conn.is_connected: + return AuthStatus.DISCONNECTED + try: + conn.send_command("99") + except Exception: + return AuthStatus.DISCONNECTED + + try: + import time + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + remaining = max(deadline - time.monotonic(), 0.05) + resp = conn.read_response(timeout=min(remaining, 0.5)) + if resp is not None and "READY" in resp.upper(): + return AuthStatus.OK + # Не прерываемся на None — ждём до deadline + return AuthStatus.NEED_AUTH + finally: + conn.release() + + +def perform_handshake(conn: ComConnection, timeout: float = 2.0) -> AuthResult: + """Выполнить полный цикл двойного рукопожатия. + + Returns: + AuthResult с итоговым статусом. + """ + if not conn.is_connected: + return AuthResult(AuthStatus.DISCONNECTED, "Порт не открыт") + + # ── Шаг 1: генерируем 16 случайных байт и отправляем 07 ── + our_challenge = os.urandom(16) + our_challenge_hex = our_challenge.hex().upper() + cmd_07 = "07" + our_challenge_hex + try: + conn.send_command(cmd_07) + except Exception as exc: + conn.release() + return AuthResult(AuthStatus.FAILED, f"Ошибка отправки 07: {exc}") + + # ── Шаг 2: ждём CHAL<32 hex> от контроллера ── + resp = conn.read_response(timeout=timeout) + if resp is None: + conn.release() + return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания CHAL от контроллера") + + # Парсим CHAL — допускаем разделители (пробел, двоеточие) + if not resp.upper().startswith("CHAL"): + conn.release() + return AuthResult(AuthStatus.FAILED, f"Неожиданный ответ вместо CHAL: {resp}") + + # Извлекаем hex-часть, убирая возможные разделители + board_challenge_hex = resp[4:].strip().lstrip(":").strip() + + if len(board_challenge_hex) != 32: + conn.release() + return AuthResult(AuthStatus.FAILED, f"Некорректная длина CHAL: {board_challenge_hex}") + + try: + board_challenge = bytes.fromhex(board_challenge_hex) + except ValueError: + conn.release() + return AuthResult(AuthStatus.FAILED, f"CHAL содержит не-hex символы: {board_challenge_hex}") + + # ── Шаг 3: шифруем board_challenge AES-256 и отправляем 08 ── + encrypted_response = _aes_encrypt(board_challenge) + cmd_08 = "08" + encrypted_response.hex().upper() + try: + conn.write_raw(cmd_08) + except Exception as exc: + conn.release() + return AuthResult(AuthStatus.FAILED, f"Ошибка отправки 08: {exc}") + + # ── Шаг 4: ждём RAES<32 hex> — зашифрованный ответ на наш запрос ── + resp2 = conn.read_response(timeout=timeout) + if resp2 is None: + conn.release() + return AuthResult(AuthStatus.TIMEOUT, "Таймаут ожидания RAES от контроллера") + + upper2 = resp2.upper() + if upper2.startswith("RAES"): + # RAES получен — плата ответила на наш challenge. + # Верификацию RAES пропускаем (плата уже приняла наш 08). + pass + elif "READY" in upper2 or "AUTH_CNT_OK" in upper2: + conn.release() + return AuthResult(AuthStatus.OK, f"Авторизация пройдена ({resp2})") + else: + conn.release() + return AuthResult(AuthStatus.FAILED, f"Неожиданный ответ вместо RAES: {resp2}") + + # Ждём AUTH_CNT_OK / READY после RAES + ready_resp = conn.read_response(timeout=timeout) + conn.release() + if ready_resp: + return AuthResult(AuthStatus.OK, f"Авторизация пройдена ({ready_resp})") + + return AuthResult(AuthStatus.OK, "Авторизация пройдена") diff --git a/app/services/com_service.py b/app/services/com_service.py new file mode 100644 index 0000000..dc28db0 --- /dev/null +++ b/app/services/com_service.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +import threading +import time as _time + +from serial import ( + EIGHTBITS, + FIVEBITS, + PARITY_EVEN, + PARITY_MARK, + PARITY_NONE, + PARITY_ODD, + PARITY_SPACE, + SEVENBITS, + SIXBITS, + STOPBITS_ONE, + STOPBITS_ONE_POINT_FIVE, + STOPBITS_TWO, + Serial, +) +from serial.tools import list_ports + +from ..serial_config import SerialParams + +BYTESIZE_MAP = {"5": FIVEBITS, "6": SIXBITS, "7": SEVENBITS, "8": EIGHTBITS} +STOPBITS_MAP = {"1": STOPBITS_ONE, "1.5": STOPBITS_ONE_POINT_FIVE, "2": STOPBITS_TWO} +PARITY_MAP = { + "None": PARITY_NONE, + "Even": PARITY_EVEN, + "Odd": PARITY_ODD, + "Mark": PARITY_MARK, + "Space": PARITY_SPACE, +} +PARITY_SHORT_MAP = {"None": "N", "Even": "E", "Odd": "O", "Mark": "M", "Space": "S"} + + +def list_available_ports() -> list[str]: + return [item.device for item in list_ports.comports()] + + +def params_from_ui( + *, + baudrate: str, + data_bits: str, + stop_bits: str, + parity: str, + flow_control: str, +) -> SerialParams: + return SerialParams( + baudrate=int(baudrate), + bytesize=BYTESIZE_MAP[data_bits], + stopbits=STOPBITS_MAP[stop_bits], + parity=PARITY_MAP[parity], + xonxoff=flow_control == "XON/XOFF", + rtscts=flow_control == "RTS/CTS", + ) + + +class ComConnection: + """Обёртка над pyserial с фоновым дренажом входного буфера. + + Фоновый поток-дренаж (_drain thread) постоянно вычитывает и + отбрасывает всё, что плата присылает «по своей инициативе» + (COINS, PRIZES и т.п.). Когда вызывается send_command() — + дренаж приостанавливается (через Lock), чтобы последующий + read_response() получил именно ответ на команду. + После read_response() (или серии вызовов) нужно вызвать + release(), чтобы дренаж возобновился. Для удобства + send_command() автоматически освобождает предыдущую блокировку + перед захватом новой. + """ + + def __init__(self) -> None: + self._serial: Serial | None = None + self._io_lock = threading.Lock() + self._cmd_owner = False + self._drain_stop = threading.Event() + self._drain_paused = threading.Event() # установлен = дренаж приостановлен + self._drain_thread: threading.Thread | None = None + self._read_buf = bytearray() # остаток между вызовами read_response + self._last_cmd_time: float = 0.0 # monotonic-метка последней отправки + # Параметры последнего подключения (для reconnect) + self._last_port: str | None = None + self._last_params: SerialParams | None = None + + # ── Properties ──────────────────────────────────────────────────── + + @property + def is_connected(self) -> bool: + return self._serial is not None and self._serial.is_open + + @property + def serial(self) -> Serial | None: + return self._serial + + @property + def seconds_since_last_command(self) -> float: + """Сколько секунд прошло с последней отправки команды.""" + if self._last_cmd_time == 0.0: + return float("inf") + return _time.monotonic() - self._last_cmd_time + + # ── Open / Close ────────────────────────────────────────────────── + + def open(self, *, port: str, params: SerialParams, timeout: float = 0.1) -> None: + self.close() + self._last_port = port + self._last_params = params + self._serial = Serial( + port=port, + baudrate=params.baudrate, + bytesize=params.bytesize, + parity=params.parity, + stopbits=params.stopbits, + xonxoff=params.xonxoff, + rtscts=params.rtscts, + timeout=timeout, + ) + # Запускаем фоновый дренаж + self._drain_stop.clear() + self._drain_thread = threading.Thread( + target=self._drain_loop, daemon=True, name="serial-drain" + ) + self._drain_thread.start() + + def close(self) -> None: + # Останавливаем дренаж + self._drain_stop.set() + # Если lock удерживается командой — освободить + if self._cmd_owner: + self._cmd_owner = False + try: + self._io_lock.release() + except RuntimeError: + pass + if self._drain_thread is not None: + self._drain_thread.join(timeout=2) + self._drain_thread = None + if self._serial and self._serial.is_open: + self._serial.close() + self._serial = None + + def reconnect(self) -> None: + """Переподключиться с параметрами последнего open(). + + Raises: + RuntimeError: если параметры подключения неизвестны. + serial.SerialException: если порт недоступен. + """ + if self._last_port is None or self._last_params is None: + raise RuntimeError("Нет сохранённых параметров для reconnect") + self.open(port=self._last_port, params=self._last_params) + + # ── Send / Receive ──────────────────────────────────────────────── + + def send_command(self, cmd: str) -> None: + """Отправить текстовую команду (добавляет ``\\r\\n``). + + Захватывает *io-lock*, приостанавливая фоновый дренаж, + и очищает входной буфер. Блокировка удерживается до тех пор, + пока не будет вызван release() (или следующий send_command). + """ + if not self.is_connected: + raise RuntimeError("Порт не открыт") + assert self._serial is not None + + # Если предыдущая команда не вызвала release — освободим сами + if self._cmd_owner: + self._cmd_owner = False + try: + self._io_lock.release() + except RuntimeError: + pass + + self._io_lock.acquire() + self._cmd_owner = True + self._read_buf.clear() + self._serial.reset_input_buffer() + self._serial.write((cmd + "\r\n").encode("ascii")) + self._last_cmd_time = _time.monotonic() + + def write_raw(self, cmd: str) -> None: + """Отправить команду без перезахвата lock и без очистки буфера. + + Используется внутри многошагового протокола (handshake), + когда lock уже удерживается предыдущим send_command(). + """ + if not self.is_connected: + raise RuntimeError("Порт не открыт") + assert self._serial is not None + self._serial.write((cmd + "\r\n").encode("ascii")) + self._last_cmd_time = _time.monotonic() + + def read_response(self, timeout: float | None = None) -> str | None: + """Прочитать одну строку ответа. Возвращает None при таймауте. + + Читает побайтово через ``read(1)`` до ``\\r`` или ``\\n``. + Остаток данных сохраняется в ``_read_buf``. + """ + import time + + if not self.is_connected: + return None + assert self._serial is not None + + effective_timeout = timeout if timeout is not None else (self._serial.timeout or 0.1) + deadline = time.monotonic() + effective_timeout + buf = self._read_buf + + # Сначала проверим остаток от предыдущего вызова + line = self._extract_line(buf) + if line is not None: + return line + + old_timeout = self._serial.timeout + try: + while time.monotonic() < deadline: + remaining = max(deadline - time.monotonic(), 0.001) + self._serial.timeout = min(remaining, 0.05) + b = self._serial.read(1) + if not b: + continue + buf.extend(b) + line = self._extract_line(buf) + if line is not None: + return line + finally: + try: + self._serial.timeout = old_timeout + except Exception: + pass + + # Таймаут — вернём что накопилось + if buf: + text = buf.decode("ascii", errors="replace").strip() + buf.clear() + if text: + return text + return None + + @staticmethod + def _extract_line(buf: bytearray) -> str | None: + """Извлечь первую полную строку из буфера (до \\r или \\n). + + Если строка найдена — удаляет её из buf и возвращает. + Иначе возвращает None, buf остаётся без изменений. + """ + for i, b in enumerate(buf): + if b in (0x0D, 0x0A): + raw = buf[:i] + # Пропускаем сам разделитель и возможный парный (\r\n или \n\r) + end = i + 1 + if end < len(buf) and buf[end] in (0x0D, 0x0A) and buf[end] != b: + end += 1 + del buf[:end] + line = raw.decode("ascii", errors="replace").strip() + if line: + return line + # Пустая строка — ищем дальше (рекурсивно по остатку) + return ComConnection._extract_line(buf) + return None + + def release(self) -> None: + """Освободить io-lock, чтобы фоновый дренаж возобновил работу.""" + if self._cmd_owner: + self._cmd_owner = False + try: + self._io_lock.release() + except RuntimeError: + pass + + def pause_drain(self) -> None: + """Приостановить фоновый дренаж (например, на время теста ввода).""" + self._drain_paused.set() + + def resume_drain(self) -> None: + """Возобновить фоновый дренаж.""" + self._drain_paused.clear() + + # ── Фоновый дренаж ──────────────────────────────────────────────── + + def _drain_loop(self) -> None: + """Постоянно вычитывает и отбрасывает входящие данные. + + Работает, пока не установлен _drain_stop. + Захватывает io-lock на короткое время; если lock занят + командой — ждёт и не мешает. + """ + while not self._drain_stop.is_set(): + # Если дренаж приостановлен — просто ждём + if self._drain_paused.is_set(): + self._drain_stop.wait(0.05) + continue + # Пробуем захватить lock; если занят командой — ждём 50 мс + acquired = self._io_lock.acquire(timeout=0.05) + if not acquired: + continue + try: + if self._serial and self._serial.is_open: + old_timeout = self._serial.timeout + self._serial.timeout = 0.02 + try: + self._serial.read(256) # читаем и отбрасываем + finally: + try: + self._serial.timeout = old_timeout + except Exception: + pass + except Exception: + pass + finally: + self._io_lock.release() + # Пауза между итерациями + self._drain_stop.wait(0.03) diff --git a/app/services/commands_service.py b/app/services/commands_service.py new file mode 100644 index 0000000..34969db --- /dev/null +++ b/app/services/commands_service.py @@ -0,0 +1,84 @@ +"""Сервис загрузки справочника команд платы из .csv файлов. + +Папка commands/ лежит рядом с исполняемым файлом (или рядом с main.py +при разработке). Каждый .csv файл содержит набор команд с колонками, +разделёнными точкой с запятой: + command;name;description +""" + +from __future__ import annotations + +import csv +import sys +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class Command: + """Одна команда платы.""" + code: str + name: str + description: str + + +@dataclass +class CommandSet: + """Набор команд из одного CSV-файла.""" + set_name: str + commands: list[Command] = field(default_factory=list) + + +def _get_commands_dir() -> Path: + """Определить путь к папке commands/. + + При запуске из .exe (PyInstaller) — рядом с exe-файлом. + При запуске из исходников — рядом с корнем проекта. + """ + if getattr(sys, "frozen", False): + base = Path(sys.executable).parent + else: + base = Path(__file__).resolve().parent.parent.parent + return base / "commands" + + +def load_command_set(path: Path) -> CommandSet | None: + """Загрузить один .csv файл. Возвращает None при ошибке.""" + try: + text = path.read_text(encoding="utf-8") + except Exception: + return None + + commands: list[Command] = [] + reader = csv.reader(text.splitlines(), delimiter=";") + for i, row in enumerate(reader): + if len(row) < 3: + continue + code, name, description = row[0].strip(), row[1].strip(), row[2].strip() + # Пропускаем заголовок + if i == 0 and code.lower() == "command": + continue + if not code: + continue + commands.append(Command(code=code, name=name, description=description)) + + return CommandSet(set_name=path.stem, commands=commands) + + +def load_all_command_sets() -> list[CommandSet]: + """Загрузить все .csv файлы из папки commands/.""" + commands_dir = _get_commands_dir() + if not commands_dir.is_dir(): + return [] + + sets: list[CommandSet] = [] + for csv_file in sorted(commands_dir.glob("*.csv")): + cs = load_command_set(csv_file) + if cs is not None and cs.commands: + sets.append(cs) + return sets + + +def get_commands_dir() -> Path: + """Публичный доступ к пути папки (для UI «Открыть папку»).""" + return _get_commands_dir() diff --git a/app/services/input_mappings_service.py b/app/services/input_mappings_service.py new file mode 100644 index 0000000..4e7fbcb --- /dev/null +++ b/app/services/input_mappings_service.py @@ -0,0 +1,99 @@ +"""Сервис загрузки раскладок кнопок из .mapping файлов (TOML-формат). + +Папка input_mappings/ лежит рядом с исполняемым файлом (или рядом с main.py +при разработке). Каждый .mapping файл описывает раскладку кнопок для +конкретного игрового проекта и содержит: + - [[buttons]] — список кнопок с полями: + - name (str) — подпись кнопки + - input_index (int) — номер цифрового входа (0..25) + - x (int) — столбец на холсте (0..20) + - y (int) — строка на холсте (-4..4, +Y = вверх) +""" + +from __future__ import annotations + +import sys +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + +_MAX_X = 20 +_MIN_Y = -4 +_MAX_Y = 4 + + +@dataclass +class MappingButton: + """Одна кнопка в раскладке.""" + name: str + input_index: int + x: int + y: int + + +@dataclass +class InputMapping: + """Раскладка кнопок для одного игрового проекта.""" + project_name: str + buttons: list[MappingButton] = field(default_factory=list) + + +def _get_input_mappings_dir() -> Path: + """Определить путь к папке input_mappings/. + + При запуске из .exe (PyInstaller) — рядом с exe-файлом. + При запуске из исходников — рядом с корнем проекта. + """ + if getattr(sys, "frozen", False): + base = Path(sys.executable).parent + else: + base = Path(__file__).resolve().parent.parent.parent + return base / "input_mappings" + + +def load_input_mapping(path: Path) -> InputMapping | None: + """Загрузить один .mapping файл. Возвращает None при ошибке.""" + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + + buttons: list[MappingButton] = [] + for entry in data.get("buttons", []): + name = entry.get("name", "") + input_index = entry.get("input_index") + x = entry.get("x", 0) + y = entry.get("y", 0) + if input_index is None: + continue + # Ограничиваем координаты + x = max(0, min(int(x), _MAX_X)) + y = max(_MIN_Y, min(int(y), _MAX_Y)) + buttons.append(MappingButton( + name=str(name), + input_index=int(input_index), + x=x, + y=y, + )) + + project_name = path.stem # имя файла без расширения + return InputMapping(project_name=project_name, buttons=buttons) + + +def load_all_input_mappings() -> list[InputMapping]: + """Загрузить все .mapping файлы из папки input_mappings/.""" + mappings_dir = _get_input_mappings_dir() + if not mappings_dir.is_dir(): + return [] + + mappings: list[InputMapping] = [] + for mapping_file in sorted(mappings_dir.glob("*.mapping")): + mapping = load_input_mapping(mapping_file) + if mapping is not None: + mappings.append(mapping) + return mappings + + +def get_input_mappings_dir() -> Path: + """Публичный доступ к пути папки (для UI «Открыть папку»).""" + return _get_input_mappings_dir() diff --git a/app/services/light_modes_service.py b/app/services/light_modes_service.py new file mode 100644 index 0000000..7dd6d8f --- /dev/null +++ b/app/services/light_modes_service.py @@ -0,0 +1,91 @@ +"""Сервис загрузки конфигураций подсветки из .lm файлов (TOML-формат). + +Папка light_modes/ лежит рядом с исполняемым файлом (или рядом с main.py +при разработке). Каждый .lm файл описывает один игровой проект и содержит: + - base_command — базовая команда (например "85", "8") + - has_duration — (опционально) true если формат XXYY (XX=эффект, YY=время) + - [[effects]] — список эффектов с полями code (int) и name (str) +""" + +from __future__ import annotations + +import sys +import tomllib +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class LightEffect: + """Один эффект подсветки.""" + code: int + name: str + + +@dataclass +class LightMode: + """Конфигурация подсветки для одного игрового проекта.""" + project_name: str + base_command: str + has_duration: bool = False + effects: list[LightEffect] = field(default_factory=list) + + +def _get_light_modes_dir() -> Path: + """Определить путь к папке light_modes/. + + При запуске из .exe (PyInstaller) — рядом с exe-файлом. + При запуске из исходников — рядом с корнем проекта. + """ + if getattr(sys, "frozen", False): + # PyInstaller: sys.executable = путь к .exe + base = Path(sys.executable).parent + else: + # Разработка: корень проекта (где лежит main.py) + base = Path(__file__).resolve().parent.parent.parent + return base / "light_modes" + + +def load_light_mode(path: Path) -> LightMode | None: + """Загрузить один .lm файл. Возвращает None при ошибке.""" + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except Exception: + return None + + base_command = data.get("base_command", "") + has_duration = data.get("has_duration", False) + effects: list[LightEffect] = [] + + for entry in data.get("effects", []): + code = entry.get("code") + name = entry.get("name", "") + if code is not None: + effects.append(LightEffect(code=int(code), name=str(name))) + + project_name = path.stem # имя файла без расширения + return LightMode( + project_name=project_name, + base_command=str(base_command), + has_duration=bool(has_duration), + effects=effects, + ) + + +def load_all_light_modes() -> list[LightMode]: + """Загрузить все .lm файлы из папки light_modes/.""" + modes_dir = _get_light_modes_dir() + if not modes_dir.is_dir(): + return [] + + modes: list[LightMode] = [] + for lm_file in sorted(modes_dir.glob("*.lm")): + mode = load_light_mode(lm_file) + if mode is not None: + modes.append(mode) + return modes + + +def get_light_modes_dir() -> Path: + """Публичный доступ к пути папки (для UI «Открыть папку»).""" + return _get_light_modes_dir() diff --git a/app/services/settings_service.py b/app/services/settings_service.py new file mode 100644 index 0000000..38f3674 --- /dev/null +++ b/app/services/settings_service.py @@ -0,0 +1,74 @@ +"""Persistent application settings (JSON). + +Settings file is stored next to the executable (or next to main.py in dev). +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from typing import Any + + +def _settings_path() -> Path: + """Return the path to settings.json next to the running script / exe.""" + if getattr(sys, "frozen", False): + base = Path(sys.executable).parent + else: + base = Path(__file__).resolve().parents[2] # project root + return base / "settings.json" + + +_DEFAULTS: dict[str, Any] = { + "theme_mode": "light", # "light" | "dark" + "theme_accent": "green", # "green" | "blue" +} + +_cache: dict[str, Any] | None = None + + +def _load() -> dict[str, Any]: + global _cache + if _cache is not None: + return _cache + path = _settings_path() + if path.exists(): + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + _cache = {**_DEFAULTS, **data} + return _cache + except Exception: + pass + _cache = dict(_DEFAULTS) + return _cache + + +def _save() -> None: + path = _settings_path() + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(_load(), f, ensure_ascii=False, indent=2) + except OSError: + pass + + +def get(key: str) -> Any: + """Return setting value (uses default if missing).""" + return _load().get(key, _DEFAULTS.get(key)) + + +def put(key: str, value: Any) -> None: + """Set a value and persist to disk.""" + _load()[key] = value + _save() + + +def theme_mode() -> str: + return str(get("theme_mode")) + + +def theme_accent() -> str: + return str(get("theme_accent")) diff --git a/app/ui/__init__.py b/app/ui/__init__.py new file mode 100644 index 0000000..368a9f1 --- /dev/null +++ b/app/ui/__init__.py @@ -0,0 +1 @@ +"""UI theme and shared style helpers.""" diff --git a/app/ui/icons.py b/app/ui/icons.py new file mode 100644 index 0000000..39ef825 --- /dev/null +++ b/app/ui/icons.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import os +from pathlib import Path +from typing import Final + +from PySide6.QtGui import QIcon + +QTA_AVAILABLE: bool + + +def _can_write(path: Path) -> bool: + try: + path.mkdir(parents=True, exist_ok=True) + probe = path / ".qta_write_test" + probe.write_text("ok", encoding="utf-8") + probe.unlink(missing_ok=True) + return True + except OSError: + return False + + +def _prepare_qtawesome_env() -> None: + if os.name != "nt": + return + + local_appdata = os.environ.get("LOCALAPPDATA") + if local_appdata: + default_fonts_dir = Path(local_appdata) / "Microsoft" / "Windows" / "Fonts" + if _can_write(default_fonts_dir): + return + + fallback_local_appdata = Path.cwd() / ".qta_localappdata" + fallback_fonts_dir = fallback_local_appdata / "Microsoft" / "Windows" / "Fonts" + if _can_write(fallback_fonts_dir): + os.environ["LOCALAPPDATA"] = str(fallback_local_appdata) + + +_prepare_qtawesome_env() + +try: + import qtawesome as qta + + QTA_AVAILABLE = True +except Exception: + qta = None # type: ignore[assignment] + QTA_AVAILABLE = False + + +ICON_COLOR_PRIMARY: Final[str] = "#ecfdf5" +ICON_COLOR_DARK: Final[str] = "#1f2937" +ICON_COLOR_ACCENT: Final[str] = "#0f766e" +ICON_COLOR_MUTED: Final[str] = "#64748b" +ICON_COLOR_DANGER: Final[str] = "#dc2626" + + +def icon(name: str, *, color: str = ICON_COLOR_DARK) -> QIcon: + if not QTA_AVAILABLE: + return QIcon() + + try: + return qta.icon(name, color=color) + except Exception: + return QIcon() diff --git a/app/ui/theme.py b/app/ui/theme.py new file mode 100644 index 0000000..5f2c682 --- /dev/null +++ b/app/ui/theme.py @@ -0,0 +1,713 @@ +"""Parametrised application stylesheet. + +Supports 4 theme variants: light/dark x green/blue. +build_stylesheet(palette) renders the QSS template with the given palette. +APP_STYLESHEET is the default (light-green) for backward compatibility. +""" + +from __future__ import annotations + +from dataclasses import dataclass, asdict + + +@dataclass(frozen=True) +class ThemePalette: + # backgrounds + bg_base: str + bg_card: str + bg_input: str + bg_dialog: str + bg_tooltip: str + + # text + text_primary: str + text_secondary: str + text_muted: str + text_on_accent: str + + # borders + border: str + border_light: str + + # accent family + accent: str + accent_dark: str + accent_darker: str + accent_border: str + accent_bg_subtle: str + accent_text: str + + # generic buttons + btn_bg: str + btn_border: str + btn_hover: str + btn_pressed: str + + # primary buttons + btn_primary_bg: str + btn_primary_hover: str + btn_primary_pressed: str + + # title-tabs + tab_bg: str + tab_text: str + tab_border: str + tab_selected_bg: str + tab_selected_text: str + tab_hover_bg: str + + # content tabs + ctab_pane_border: str + ctab_pane_bg: str + ctab_bg: str + ctab_text: str + ctab_border: str + ctab_selected_bg: str + ctab_selected_text: str + ctab_hover_bg: str + ctab_close_bg: str + ctab_close_text: str + ctab_close_hover_bg: str + ctab_close_hover_text: str + + # status badges + status_ready_border: str + status_ready_bg: str + status_ready_text: str + status_warn_border: str + status_warn_bg: str + status_warn_text: str + status_error_border: str + status_error_bg: str + status_error_text: str + + # input indicator + indicator_bg: str + indicator_border: str + indicator_text: str + indicator_active_bg: str + indicator_active_border: str + indicator_active_text: str + + # hero badge + hero_badge_color: str + hero_badge_bg: str + hero_badge_border: str + + +_LIGHT_GREEN = ThemePalette( + bg_base="#e9edf2", bg_card="#ffffff", bg_input="#fdfefe", + bg_dialog="#f8fafc", bg_tooltip="#1e293b", + text_primary="#1f2937", text_secondary="#334155", + text_muted="#64748b", text_on_accent="#ffffff", + border="#cad3df", border_light="#d7e0eb", + accent="#16a34a", accent_dark="#15803d", accent_darker="#166534", + accent_border="#1d7a45", accent_bg_subtle="#dcfce7", accent_text="#166534", + btn_bg="#ecf2f9", btn_border="#c7d4e3", btn_hover="#dce8f5", btn_pressed="#c8d9eb", + btn_primary_bg="#0f766e", btn_primary_hover="#115e59", btn_primary_pressed="#134e4a", + tab_bg="#166534", tab_text="#ecfdf5", tab_border="#1d7a45", + tab_selected_bg="#f8fafc", tab_selected_text="#166534", tab_hover_bg="#1e6f3f", + ctab_pane_border="#cad3df", ctab_pane_bg="#eef2f6", + ctab_bg="#e8eef5", ctab_text="#334155", ctab_border="#c7d2df", + ctab_selected_bg="#ffffff", ctab_selected_text="#0f172a", ctab_hover_bg="#f3f7fb", + ctab_close_bg="#dcfce7", ctab_close_text="#166534", + ctab_close_hover_bg="#fee2e2", ctab_close_hover_text="#b91c1c", + status_ready_border="#86efac", status_ready_bg="#dcfce7", status_ready_text="#166534", + status_warn_border="#f4d8a8", status_warn_bg="#fff7e6", status_warn_text="#8a5a00", + status_error_border="#fca5a5", status_error_bg="#fef2f2", status_error_text="#991b1b", + indicator_bg="#e2e8f0", indicator_border="#cbd5e1", indicator_text="#64748b", + indicator_active_bg="#22c55e", indicator_active_border="#16a34a", indicator_active_text="#ffffff", + hero_badge_color="#0f766e", hero_badge_bg="#d1fae5", hero_badge_border="#99f6e4", +) + +_LIGHT_BLUE = ThemePalette( + bg_base="#e9edf2", bg_card="#ffffff", bg_input="#fdfefe", + bg_dialog="#f8fafc", bg_tooltip="#1e293b", + text_primary="#1f2937", text_secondary="#334155", + text_muted="#64748b", text_on_accent="#ffffff", + border="#cad3df", border_light="#d7e0eb", + accent="#2563eb", accent_dark="#1d4ed8", accent_darker="#1e40af", + accent_border="#3b82f6", accent_bg_subtle="#dbeafe", accent_text="#1e40af", + btn_bg="#ecf2f9", btn_border="#c7d4e3", btn_hover="#dce8f5", btn_pressed="#c8d9eb", + btn_primary_bg="#2563eb", btn_primary_hover="#1d4ed8", btn_primary_pressed="#1e40af", + tab_bg="#1e40af", tab_text="#dbeafe", tab_border="#3b82f6", + tab_selected_bg="#f8fafc", tab_selected_text="#1e40af", tab_hover_bg="#1e3a8a", + ctab_pane_border="#cad3df", ctab_pane_bg="#eef2f6", + ctab_bg="#e8eef5", ctab_text="#334155", ctab_border="#c7d2df", + ctab_selected_bg="#ffffff", ctab_selected_text="#0f172a", ctab_hover_bg="#f3f7fb", + ctab_close_bg="#dbeafe", ctab_close_text="#1e40af", + ctab_close_hover_bg="#fee2e2", ctab_close_hover_text="#b91c1c", + status_ready_border="#86efac", status_ready_bg="#dcfce7", status_ready_text="#166534", + status_warn_border="#f4d8a8", status_warn_bg="#fff7e6", status_warn_text="#8a5a00", + status_error_border="#fca5a5", status_error_bg="#fef2f2", status_error_text="#991b1b", + indicator_bg="#e2e8f0", indicator_border="#cbd5e1", indicator_text="#64748b", + indicator_active_bg="#3b82f6", indicator_active_border="#2563eb", indicator_active_text="#ffffff", + hero_badge_color="#1d4ed8", hero_badge_bg="#dbeafe", hero_badge_border="#93c5fd", +) + +_DARK_GREEN = ThemePalette( + bg_base="#1e1e2e", bg_card="#2a2a3c", bg_input="#33334a", + bg_dialog="#252538", bg_tooltip="#f8fafc", + text_primary="#e2e8f0", text_secondary="#cbd5e1", + text_muted="#94a3b8", text_on_accent="#ffffff", + border="#3f3f5a", border_light="#4a4a66", + accent="#22c55e", accent_dark="#16a34a", accent_darker="#15803d", + accent_border="#22c55e", accent_bg_subtle="#16382a", accent_text="#4ade80", + btn_bg="#33334a", btn_border="#4a4a66", btn_hover="#3d3d56", btn_pressed="#4a4a66", + btn_primary_bg="#16a34a", btn_primary_hover="#15803d", btn_primary_pressed="#166534", + tab_bg="#15803d", tab_text="#d1fae5", tab_border="#22c55e", + tab_selected_bg="#e2e8f0", tab_selected_text="#15803d", tab_hover_bg="#166534", + ctab_pane_border="#3f3f5a", ctab_pane_bg="#252538", + ctab_bg="#2a2a3c", ctab_text="#cbd5e1", ctab_border="#3f3f5a", + ctab_selected_bg="#33334a", ctab_selected_text="#e2e8f0", ctab_hover_bg="#33334a", + ctab_close_bg="#16382a", ctab_close_text="#4ade80", + ctab_close_hover_bg="#4c1d1d", ctab_close_hover_text="#fca5a5", + status_ready_border="#22c55e", status_ready_bg="#16382a", status_ready_text="#4ade80", + status_warn_border="#ca8a04", status_warn_bg="#3b2f09", status_warn_text="#facc15", + status_error_border="#ef4444", status_error_bg="#4c1d1d", status_error_text="#fca5a5", + indicator_bg="#33334a", indicator_border="#4a4a66", indicator_text="#94a3b8", + indicator_active_bg="#22c55e", indicator_active_border="#16a34a", indicator_active_text="#ffffff", + hero_badge_color="#4ade80", hero_badge_bg="#16382a", hero_badge_border="#22c55e", +) + +_DARK_BLUE = ThemePalette( + bg_base="#1e1e2e", bg_card="#2a2a3c", bg_input="#33334a", + bg_dialog="#252538", bg_tooltip="#f8fafc", + text_primary="#e2e8f0", text_secondary="#cbd5e1", + text_muted="#94a3b8", text_on_accent="#ffffff", + border="#3f3f5a", border_light="#4a4a66", + accent="#3b82f6", accent_dark="#2563eb", accent_darker="#1d4ed8", + accent_border="#3b82f6", accent_bg_subtle="#1e2a4a", accent_text="#60a5fa", + btn_bg="#33334a", btn_border="#4a4a66", btn_hover="#3d3d56", btn_pressed="#4a4a66", + btn_primary_bg="#2563eb", btn_primary_hover="#1d4ed8", btn_primary_pressed="#1e40af", + tab_bg="#1d4ed8", tab_text="#dbeafe", tab_border="#3b82f6", + tab_selected_bg="#e2e8f0", tab_selected_text="#1d4ed8", tab_hover_bg="#1e40af", + ctab_pane_border="#3f3f5a", ctab_pane_bg="#252538", + ctab_bg="#2a2a3c", ctab_text="#cbd5e1", ctab_border="#3f3f5a", + ctab_selected_bg="#33334a", ctab_selected_text="#e2e8f0", ctab_hover_bg="#33334a", + ctab_close_bg="#1e2a4a", ctab_close_text="#60a5fa", + ctab_close_hover_bg="#4c1d1d", ctab_close_hover_text="#fca5a5", + status_ready_border="#22c55e", status_ready_bg="#16382a", status_ready_text="#4ade80", + status_warn_border="#ca8a04", status_warn_bg="#3b2f09", status_warn_text="#facc15", + status_error_border="#ef4444", status_error_bg="#4c1d1d", status_error_text="#fca5a5", + indicator_bg="#33334a", indicator_border="#4a4a66", indicator_text="#94a3b8", + indicator_active_bg="#3b82f6", indicator_active_border="#2563eb", indicator_active_text="#ffffff", + hero_badge_color="#60a5fa", hero_badge_bg="#1e2a4a", hero_badge_border="#3b82f6", +) + +PALETTES: dict[tuple[str, str], ThemePalette] = { + ("light", "green"): _LIGHT_GREEN, + ("light", "blue"): _LIGHT_BLUE, + ("dark", "green"): _DARK_GREEN, + ("dark", "blue"): _DARK_BLUE, +} + + +def get_palette(mode: str = "light", accent: str = "green") -> ThemePalette: + return PALETTES.get((mode, accent), _LIGHT_GREEN) + + +_QSS_TEMPLATE = """ +QMainWindow#AppWindow {{ + background: transparent; +}} + +QWidget#MainContainer {{ + background: {bg_base}; + border: 2px solid {accent}; + border-radius: 10px; +}} + +QFrame#ContentArea {{ + background: {bg_base}; + border: none; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +}} + +QStackedWidget {{ + background: {bg_base}; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +}} + +QScrollArea {{ + background: {bg_base}; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; +}} + +QScrollArea > QWidget > QWidget {{ + background: {bg_base}; +}} + +QWidget#TitleBar {{ + background: {accent_dark}; + border-top-left-radius: 10px; + border-top-right-radius: 10px; + border-bottom: 1px solid {accent_darker}; +}} + +QLabel#TitleBarTitle {{ + color: #f8fafc; + font-size: 13px; + font-weight: 600; +}} + +QLabel#TitleBarIcon {{ + background: transparent; +}} + +QWidget#TitleTabsContainer {{ + background: transparent; +}} + +QTabBar#TitleTabs {{ + background: transparent; +}} + +QTabBar#TitleTabs::tab {{ + background: {tab_bg}; + color: {tab_text}; + border: 1px solid {tab_border}; + border-radius: 7px; + padding: 4px 20px 4px 10px; + margin-right: 4px; + min-height: 18px; +}} + +QTabBar#TitleTabs::tab:selected {{ + background: {tab_selected_bg}; + color: {tab_selected_text}; + border-color: {accent_bg_subtle}; + font-weight: 600; +}} + +QTabBar#TitleTabs::tab:hover:!selected {{ + background: {tab_hover_bg}; +}} + +QTabBar#TitleTabs QToolButton[tabClose="true"] {{ + background: transparent; + border: none; + border-radius: 7px; + margin-right: 6px; + padding: 0px; +}} + +QTabBar#TitleTabs QToolButton[tabClose="true"]:hover {{ + background: rgba(255, 255, 255, 0.25); +}} + +QWidget#TabStatusDot {{ + border-radius: 4px; + min-width: 8px; min-height: 8px; + max-width: 8px; max-height: 8px; +}} +QWidget#TabStatusDot[dotColor="green"] {{ background: #22c55e; }} +QWidget#TabStatusDot[dotColor="yellow"] {{ background: #facc15; }} +QWidget#TabStatusDot[dotColor="red"] {{ background: #ef4444; }} +QWidget#TabStatusDot[dotColor="none"] {{ background: transparent; border: none; }} + +QWidget#TabRightContainer {{ + background: transparent; +}} + +QToolButton[windowControl="minimize"], +QToolButton[windowControl="maximize"], +QToolButton[windowControl="close"] {{ + background: rgba(255, 255, 255, 0.12); + color: #d8e4f1; + border: none; + border-radius: 6px; +}} + +QToolButton[windowControl="minimize"]:hover, +QToolButton[windowControl="maximize"]:hover, +QToolButton[windowControl="close"]:hover {{ + background: rgba(255, 255, 255, 0.25); +}} + +QTabWidget::pane {{ + border: 1px solid {ctab_pane_border}; + border-radius: 8px; + top: -1px; + background: {ctab_pane_bg}; +}} + +QTabBar::tab {{ + background: {ctab_bg}; + color: {ctab_text}; + border: 1px solid {ctab_border}; + border-bottom: none; + padding: 6px 12px; + margin-right: 2px; + min-height: 25px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +}} + +QTabBar::tab:selected {{ + background: {ctab_selected_bg}; + border-color: {ctab_pane_border}; + color: {ctab_selected_text}; + font-weight: 600; +}} + +QTabBar::tab:hover:!selected {{ + background: {ctab_hover_bg}; +}} + +QToolButton[tabClose="true"] {{ + background: {ctab_close_bg}; + color: {ctab_close_text}; + border: none; + border-radius: 7px; + padding: 0px; + font-size: 11px; + font-weight: 600; +}} + +QToolButton[tabClose="true"]:hover {{ + background: {ctab_close_hover_bg}; + color: {ctab_close_hover_text}; +}} + +QToolButton[tabClose="true"]:pressed {{ + background: {ctab_close_hover_bg}; + color: {ctab_close_hover_text}; +}} + +QFrame#HeroCard, +QFrame#PanelCard {{ + background: {bg_card}; + border: 1px solid {border_light}; + border-radius: 14px; +}} + +QLabel#HeroBadge {{ + color: {hero_badge_color}; + background: {hero_badge_bg}; + border: 1px solid {hero_badge_border}; + border-radius: 9px; + padding: 4px 10px; + font-weight: 600; +}} + +QLabel#HeroTitle {{ + color: {text_primary}; + font-size: 22px; + font-weight: 700; +}} + +QLabel#HeroHint {{ + color: {text_muted}; + font-size: 13px; +}} + +QLabel#StatusBadge {{ + border-radius: 10px; + border: 1px solid {status_error_border}; + background: {status_error_bg}; + color: {status_error_text}; + padding: 6px 10px; + font-weight: 600; +}} + +QLabel#StatusBadge[state="need-auth"] {{ + border-color: {status_warn_border}; + background: {status_warn_bg}; + color: {status_warn_text}; +}} + +QLabel#StatusBadge[state="ready"] {{ + border-color: {status_ready_border}; + background: {status_ready_bg}; + color: {status_ready_text}; +}} + +QLabel#SectionTitle {{ + color: {text_secondary}; + font-size: 15px; + font-weight: 700; +}} + +QGroupBox {{ + border: 1px solid {border}; + border-radius: 10px; + margin-top: 10px; + padding-top: 10px; + color: {text_secondary}; + font-weight: 600; +}} + +QGroupBox::title {{ + subcontrol-origin: margin; + left: 10px; + padding: 0 6px; + color: {text_muted}; + font-size: 12px; +}} + +QLabel {{ + color: {text_primary}; +}} + +QComboBox {{ + background: {bg_input}; + border: 1px solid {border}; + border-radius: 8px; + padding: 6px 10px; + min-height: 18px; + color: {text_primary}; + selection-background-color: {btn_primary_bg}; + selection-color: {text_on_accent}; +}} + +QComboBox:hover {{ + border-color: {text_muted}; +}} + +QComboBox:focus {{ + border-color: {btn_primary_bg}; +}} + +QComboBox::drop-down {{ + border: none; + width: 24px; +}} + +QComboBox::down-arrow {{ + width: 10px; + height: 10px; +}} + +QComboBox QAbstractItemView {{ + background: {bg_card}; + color: {text_primary}; + border: 1px solid {border}; + selection-background-color: {btn_primary_bg}; + selection-color: {text_on_accent}; + outline: 0; +}} + +QPushButton {{ + background: {btn_bg}; + border: 1px solid {btn_border}; + border-radius: 9px; + color: {text_primary}; + padding: 7px 12px; + font-weight: 600; +}} + +QPushButton:hover {{ + background: {btn_hover}; +}} + +QPushButton:pressed {{ + background: {btn_pressed}; +}} + +QPushButton[role="primary"] {{ + background: {btn_primary_bg}; + border-color: {btn_primary_bg}; + color: {text_on_accent}; +}} + +QPushButton[role="primary"]:hover {{ + background: {btn_primary_hover}; +}} + +QPushButton[role="primary"]:pressed {{ + background: {btn_primary_pressed}; +}} + +QPushButton[role="ghost"] {{ + background: {bg_card}; + border-color: {border}; + color: {text_muted}; +}} + +QMessageBox, QDialog {{ + background: {bg_dialog}; + color: {text_primary}; +}} + +QWidget#DialogContainer {{ + background: {bg_dialog}; + border: 2px solid {accent}; + border-radius: 10px; +}} + +QWidget#DialogTitleBar {{ + background: {accent_dark}; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + border-bottom: 1px solid {accent_darker}; +}} + +QLabel#DialogTitle {{ + color: #f8fafc; + font-size: 13px; + font-weight: 600; +}} + +QMessageBox QLabel {{ + color: {text_primary}; + font-size: 13px; +}} + +QMessageBox QPushButton {{ + background: {btn_bg}; + border: 1px solid {btn_border}; + border-radius: 9px; + color: {text_primary}; + padding: 7px 18px; + font-weight: 600; + min-width: 70px; +}} + +QMessageBox QPushButton:hover {{ + background: {btn_hover}; +}} + +QMessageBox QPushButton:pressed {{ + background: {btn_pressed}; +}} + +QLabel#InputIndicator {{ + background: {indicator_bg}; + border: 1px solid {indicator_border}; + border-radius: 6px; + color: {indicator_text}; + font-size: 11px; + font-weight: 600; +}} + +QLabel#InputIndicator[active="true"] {{ + background: {indicator_active_bg}; + border-color: {indicator_active_border}; + color: {indicator_active_text}; +}} + +QLabel#LogLabel {{ + color: {text_muted}; + font-size: 12px; + padding: 4px; +}} + +QSpinBox {{ + background: {bg_input}; + border: 1px solid {border}; + border-radius: 6px; + padding: 4px 6px; + color: {text_primary}; + min-height: 18px; +}} + +QSpinBox:focus {{ + border-color: {btn_primary_bg}; +}} + +QToolButton#AccentSwatch {{ + border: 2px solid {border}; + border-radius: 16px; + min-width: 28px; max-width: 28px; + min-height: 28px; max-height: 28px; +}} + +QToolButton#AccentSwatch:checked {{ + border-color: {text_primary}; + border-width: 3px; +}} + +QPushButton:checked {{ + background: {accent_bg_subtle}; + border-color: {accent}; + color: {accent_text}; +}} + +QTextEdit, QPlainTextEdit {{ + background: {bg_input}; + color: {text_primary}; + border: 1px solid {border}; + border-radius: 6px; + padding: 4px; +}} + +QLineEdit {{ + background: {bg_input}; + color: {text_primary}; + border: 1px solid {border}; + border-radius: 8px; + padding: 6px 10px; + min-height: 18px; +}} + +QLineEdit:focus {{ + border-color: {btn_primary_bg}; +}} + +QProgressBar {{ + background: {bg_input}; + border: 1px solid {border}; + border-radius: 6px; + text-align: center; + color: {text_primary}; + min-height: 16px; +}} + +QProgressBar::chunk {{ + background: {accent}; + border-radius: 5px; +}} + +QScrollBar:vertical {{ + background: transparent; + width: 8px; + margin: 0; +}} + +QScrollBar::handle:vertical {{ + background: {border}; + border-radius: 4px; + min-height: 24px; +}} + +QScrollBar::handle:vertical:hover {{ + background: {text_muted}; +}} + +QScrollBar::add-line:vertical, +QScrollBar::sub-line:vertical {{ + height: 0; +}} + +QScrollBar:horizontal {{ + background: transparent; + height: 8px; + margin: 0; +}} + +QScrollBar::handle:horizontal {{ + background: {border}; + border-radius: 4px; + min-width: 24px; +}} + +QScrollBar::handle:horizontal:hover {{ + background: {text_muted}; +}} + +QScrollBar::add-line:horizontal, +QScrollBar::sub-line:horizontal {{ + width: 0; +}} +""" + + +def build_stylesheet(palette: ThemePalette) -> str: + return _QSS_TEMPLATE.format(**asdict(palette)) + + +APP_STYLESHEET = build_stylesheet(_LIGHT_GREEN) diff --git a/app/widgets/__init__.py b/app/widgets/__init__.py new file mode 100644 index 0000000..367a8fb --- /dev/null +++ b/app/widgets/__init__.py @@ -0,0 +1 @@ +"""UI widgets for tabs and screens.""" diff --git a/app/widgets/connection_tab.py b/app/widgets/connection_tab.py new file mode 100644 index 0000000..eceba1e --- /dev/null +++ b/app/widgets/connection_tab.py @@ -0,0 +1,549 @@ +from __future__ import annotations + +from collections.abc import Callable +from enum import Enum, auto + +from PySide6.QtCore import Qt, QThread, QTimer, Signal +from PySide6.QtWidgets import ( + QComboBox, + QFrame, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QScrollArea, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from ..services.com_service import ( + PARITY_SHORT_MAP, + ComConnection, + list_available_ports, + params_from_ui, +) +from ..services.auth_service import AuthResult, AuthStatus, check_status, perform_handshake +from ..ui.icons import ICON_COLOR_DARK, ICON_COLOR_MUTED, icon +from ..serial_config import SerialParams + + +class PlateState(Enum): + """Состояние подключения к плате.""" + DISCONNECTED = auto() # красный + NEED_AUTH = auto() # жёлтый + READY = auto() # зелёный + + +class ConnectionTab(QWidget): + def __init__( + self, + tab_index_getter: Callable[[], int], + set_tab_title: Callable[[int, str], None], + close_tab: Callable[[int], None], + set_tab_indicator: Callable[[int, str], None] | None = None, + ) -> None: + super().__init__() + self._tab_index_getter = tab_index_getter + self._set_tab_title = set_tab_title + self._close_tab = close_tab + self._set_tab_indicator = set_tab_indicator + self._connection = ComConnection() + self._plate_state = PlateState.DISCONNECTED + self._active_threads: list[QThread] = [] + self._open_dialogs: int = 0 # кол-во открытых диалоговых окон + self._health_timer: QTimer | None = None # таймер health-check + self._health_running = False # идёт ли сейчас проверка + + self._setup_ui() + self._load_ports() + # Отложить до момента, когда вкладка уже добавлена в QTabBar + QTimer.singleShot(0, self._sync_indicator) + + def _setup_ui(self) -> None: + outer_layout = QVBoxLayout(self) + outer_layout.setContentsMargins(0, 0, 0, 0) + outer_layout.setSpacing(0) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + outer_layout.addWidget(scroll) + + inner = QWidget() + scroll.setWidget(inner) + + root_layout = QVBoxLayout(inner) + root_layout.setContentsMargins(18, 18, 18, 18) + root_layout.setSpacing(14) + + title = QLabel("Панель соединения") + title.setObjectName("SectionTitle") + root_layout.addWidget(title) + + self.status_label = QLabel() + self.status_label.setObjectName("StatusBadge") + self._set_status("", PlateState.DISCONNECTED) + root_layout.addWidget(self.status_label) + + self.setup_widget = QFrame() + self.setup_widget.setObjectName("PanelCard") + setup_layout = QVBoxLayout(self.setup_widget) + setup_layout.setContentsMargins(14, 14, 14, 14) + setup_layout.setSpacing(10) + + settings_group = QGroupBox("Настройки соединения") + settings_form = QFormLayout(settings_group) + settings_form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) + + self.port_combo = QComboBox() + self.port_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + + self.baud_combo = QComboBox() + self.baud_combo.addItems(["9600", "19200", "38400", "57600", "115200", "230400"]) + self.baud_combo.setCurrentText("115200") + + self.data_bits_combo = QComboBox() + self.data_bits_combo.addItems(["5", "6", "7", "8"]) + self.data_bits_combo.setCurrentText("8") + + self.stop_bits_combo = QComboBox() + self.stop_bits_combo.addItems(["1", "1.5", "2"]) + self.stop_bits_combo.setCurrentText("1") + + self.parity_combo = QComboBox() + self.parity_combo.addItems(["None", "Even", "Odd", "Mark", "Space"]) + self.parity_combo.setCurrentText("None") + + self.flow_control_combo = QComboBox() + self.flow_control_combo.addItems(["None", "XON/XOFF", "RTS/CTS"]) + self.flow_control_combo.setCurrentText("None") + + settings_form.addRow("Port", self.port_combo) + settings_form.addRow("Baud rate", self.baud_combo) + settings_form.addRow("Data bits", self.data_bits_combo) + settings_form.addRow("Stop bits", self.stop_bits_combo) + settings_form.addRow("Parity", self.parity_combo) + settings_form.addRow("Flow control", self.flow_control_combo) + + setup_layout.addWidget(settings_group) + + buttons_layout = QHBoxLayout() + buttons_layout.addStretch(1) + + refresh_btn = QPushButton("Обновить порты") + refresh_btn.setProperty("role", "ghost") + refresh_btn.setIcon(icon("fa5s.sync-alt", color=ICON_COLOR_MUTED)) + refresh_btn.clicked.connect(self._load_ports) + buttons_layout.addWidget(refresh_btn) + + connect_btn = QPushButton("OK") + connect_btn.setProperty("role", "primary") + connect_btn.setIcon(icon("fa5s.check", color="#ecfdf5")) + connect_btn.clicked.connect(self._connect) + buttons_layout.addWidget(connect_btn) + + cancel_btn = QPushButton("Cancel") + cancel_btn.setProperty("role", "ghost") + cancel_btn.setIcon(icon("fa5s.times", color=ICON_COLOR_MUTED)) + cancel_btn.clicked.connect(self._close_this_tab) + buttons_layout.addWidget(cancel_btn) + setup_layout.addLayout(buttons_layout) + + root_layout.addWidget(self.setup_widget) + + self.controls_widget = QFrame() + self.controls_widget.setObjectName("PanelCard") + controls_layout = QVBoxLayout(self.controls_widget) + controls_layout.setContentsMargins(14, 14, 14, 14) + controls_layout.setSpacing(8) + + controls_title = QLabel("Действия с платой") + controls_title.setObjectName("SectionTitle") + controls_layout.addWidget(controls_title) + + # ── Кнопка авторизации (скрыта по умолчанию, показывается если нужна) ── + self.auth_btn = QPushButton("Handshake (Авторизация)") + self.auth_btn.setMinimumHeight(38) + self.auth_btn.setProperty("role", "primary") + self.auth_btn.setIcon(icon("fa5s.key", color="#ecfdf5")) + self.auth_btn.clicked.connect(self._do_handshake) + controls_layout.addWidget(self.auth_btn) + self.auth_btn.hide() + + action_items = ( + ("Тест ввода", "fa5s.keyboard", self._open_input_test), + ("Тест подсветки", "fa5s.lightbulb", self._open_led_test), + ("Тест силовых выходов", "fa5s.bolt", self._open_output_test), + ("Обновление прошивки", "fa5s.upload", self._open_firmware), + ("Задание роли", "fa5s.user-tag", self._open_role), + ) + for title, icon_name, handler in action_items: + btn = QPushButton(title) + btn.setMinimumHeight(34) + btn.setIcon(icon(icon_name, color=ICON_COLOR_DARK)) + btn.clicked.connect(handler) + controls_layout.addWidget(btn) + + command_input_btn = QPushButton("Ввод команд") + command_input_btn.setMinimumHeight(34) + command_input_btn.setProperty("role", "primary") + command_input_btn.setIcon(icon("fa5s.terminal", color="#ecfdf5")) + command_input_btn.clicked.connect(self._open_terminal) + controls_layout.addWidget(command_input_btn) + controls_layout.addStretch(1) + root_layout.addWidget(self.controls_widget) + self.controls_widget.hide() + + root_layout.addStretch(1) + + def _load_ports(self) -> None: + current = self.port_combo.currentText() + self.port_combo.clear() + ports = list_available_ports() + + if not ports: + self.port_combo.addItem("COM1") + else: + self.port_combo.addItems(ports) + + if current and self.port_combo.findText(current) != -1: + self.port_combo.setCurrentText(current) + elif self.port_combo.findText("COM1") != -1: + self.port_combo.setCurrentText("COM1") + + def _collect_params(self) -> SerialParams: + return params_from_ui( + baudrate=self.baud_combo.currentText(), + data_bits=self.data_bits_combo.currentText(), + stop_bits=self.stop_bits_combo.currentText(), + parity=self.parity_combo.currentText(), + flow_control=self.flow_control_combo.currentText(), + ) + + def _connect(self) -> None: + port = self.port_combo.currentText().strip() + if not port: + QMessageBox.warning(self, "Подключение", "Выберите COM-порт.") + return + + params = self._collect_params() + try: + self._connection.open(port=port, params=params) + except Exception as exc: + QMessageBox.critical(self, "Ошибка подключения", f"Не удалось открыть {port}\n{exc}") + return + + self.setup_widget.hide() + self.controls_widget.show() + self._conn_info = ( + f"{port} ({self.baud_combo.currentText()}-" + f"{self.data_bits_combo.currentText()}-" + f"{PARITY_SHORT_MAP[self.parity_combo.currentText()]}-" + f"{self.stop_bits_combo.currentText()})" + ) + self._set_status(self._conn_info, PlateState.NEED_AUTH) + + tab_index = self._tab_index_getter() + if tab_index >= 0: + self._set_tab_title(tab_index, port) + + # Проверяем авторизацию один раз при подключении + self._check_auth_on_connect() + + # Запускаем периодический health-check + self._start_health_timer() + + def _close_this_tab(self) -> None: + index = self._tab_index_getter() + if index >= 0: + self._close_tab(index) + + def close_serial(self) -> None: + self._stop_health_timer() + self._connection.close() + # Дождаться завершения всех фоновых потоков + for thread in self._active_threads: + if thread.isRunning(): + thread.quit() + thread.wait(1000) + self._active_threads.clear() + self._update_plate_state(PlateState.DISCONNECTED) + + # ── Открытие диалогов ─────────────────────────────────────────────── + + def _exec_dialog(self, dlg) -> None: + """Открыть диалог с трекингом (увеличивает/уменьшает счётчик).""" + self._open_dialogs += 1 + try: + dlg.exec() + finally: + self._open_dialogs = max(0, self._open_dialogs - 1) + + def _open_input_test(self) -> None: + from .dialogs.input_test_dialog import InputTestDialog + self._exec_dialog(InputTestDialog(self._connection, parent=self)) + + def _open_led_test(self) -> None: + from .dialogs.led_test_dialog import LedTestDialog + self._exec_dialog(LedTestDialog(self._connection, parent=self)) + + def _open_output_test(self) -> None: + from .dialogs.output_test_dialog import OutputTestDialog + self._exec_dialog(OutputTestDialog(self._connection, parent=self)) + + def _open_firmware(self) -> None: + from .dialogs.firmware_dialog import FirmwareDialog + self._exec_dialog(FirmwareDialog(self._connection, parent=self)) + + def _open_role(self) -> None: + from .dialogs.role_dialog import RoleDialog + self._exec_dialog(RoleDialog(self._connection, parent=self)) + + def _open_terminal(self) -> None: + from .dialogs.command_terminal_dialog import CommandTerminalDialog + self._exec_dialog(CommandTerminalDialog(self._connection, parent=self)) + + def _not_implemented(self) -> None: + QMessageBox.information(self, "В разработке", "Эта функция пока не реализована.") + + def _set_status(self, text: str, state: PlateState) -> None: + _STATE_LABELS = { + PlateState.DISCONNECTED: "Отключено", + PlateState.NEED_AUTH: "Требуется авторизация", + PlateState.READY: "Готово", + } + _STATE_CSS = { + PlateState.DISCONNECTED: "disconnected", + PlateState.NEED_AUTH: "need-auth", + PlateState.READY: "ready", + } + status_text = _STATE_LABELS[state] + full = f"[{status_text}] {text}" if text else f"[{status_text}]" + self.status_label.setText(full) + self.status_label.setProperty("state", _STATE_CSS[state]) + style = self.status_label.style() + style.unpolish(self.status_label) + style.polish(self.status_label) + + # ── Индикация состояния платы ─────────────────────────────────────── + + def _sync_indicator(self) -> None: + """Обновить цвет индикатора в табе.""" + if self._set_tab_indicator is None: + return + idx = self._tab_index_getter() + if idx < 0: + return + color_map = { + PlateState.READY: "green", + PlateState.NEED_AUTH: "yellow", + PlateState.DISCONNECTED: "red", + } + self._set_tab_indicator(idx, color_map[self._plate_state]) + + def _update_plate_state(self, state: PlateState) -> None: + self._plate_state = state + self._sync_indicator() + # Обновить строку статуса + conn_info = getattr(self, "_conn_info", "") + if state == PlateState.DISCONNECTED: + self._set_status("", PlateState.DISCONNECTED) + else: + self._set_status(conn_info, state) + # Показать/скрыть кнопку авторизации + if state == PlateState.NEED_AUTH: + self.auth_btn.show() + else: + self.auth_btn.hide() + + def _check_auth_on_connect(self) -> None: + """Проверить статус авторизации сразу после подключения (в фоновом потоке).""" + self._run_status_check(timeout=3) + + def _run_status_check(self, timeout: float = 0.5) -> None: + """Запуск проверки статуса в фоновом потоке.""" + thread = _StatusThread(self._connection, timeout, parent=self) + thread.result_ready.connect(self._on_status_checked) + thread.finished.connect(lambda: self._cleanup_thread(thread)) + self._active_threads.append(thread) + thread.start() + + def _cleanup_thread(self, thread: QThread) -> None: + """Убрать завершённый поток из списка.""" + if thread in self._active_threads: + self._active_threads.remove(thread) + thread.deleteLater() + + def _on_status_checked(self, status: AuthStatus) -> None: + if status == AuthStatus.OK: + self._update_plate_state(PlateState.READY) + elif status == AuthStatus.NEED_AUTH: + self._update_plate_state(PlateState.NEED_AUTH) + else: + self._update_plate_state(PlateState.DISCONNECTED) + + def _do_handshake(self) -> None: + """Выполнить авторизацию (двойное рукопожатие AES-256) в фоновом потоке.""" + if not self._connection.is_connected: + QMessageBox.warning(self, "Авторизация", "Порт не подключён.") + return + self.auth_btn.setEnabled(False) + self.auth_btn.setText("Авторизация…") + + thread = _HandshakeThread(self._connection, parent=self) + thread.result_ready.connect(self._on_handshake_done) + thread.finished.connect(lambda: self._cleanup_thread(thread)) + self._active_threads.append(thread) + thread.start() + + def _on_handshake_done(self, result: AuthResult) -> None: + self.auth_btn.setEnabled(True) + self.auth_btn.setText("Handshake (Авторизация)") + if result.status == AuthStatus.OK: + self._update_plate_state(PlateState.READY) + QMessageBox.information(self, "Авторизация", f"Успешно!\n{result.detail}") + else: + self._update_plate_state(PlateState.DISCONNECTED) + QMessageBox.warning( + self, + "Авторизация", + f"Ошибка: {result.detail}\n\n" + "Возможно, подключённое устройство не поддерживает " + "данную систему команд.", + ) + + # ── Периодический health-check ────────────────────────────────────── + + _HEALTH_INTERVAL_MS = 10_000 # интервал таймера (10 сек) + _IDLE_THRESHOLD_SEC = 10.0 # минимальный простой перед проверкой + + def _start_health_timer(self) -> None: + if self._health_timer is not None: + return + self._health_timer = QTimer(self) + self._health_timer.setInterval(self._HEALTH_INTERVAL_MS) + self._health_timer.timeout.connect(self._on_health_tick) + self._health_timer.start() + + def _stop_health_timer(self) -> None: + if self._health_timer is not None: + self._health_timer.stop() + self._health_timer.deleteLater() + self._health_timer = None + + def _on_health_tick(self) -> None: + """Проверка по таймеру: если нет открытых диалогов и + прошло ≥10 сек без команд — шлём 99 для проверки связи.""" + if self._open_dialogs > 0: + return + if self._health_running: + return + if not self._connection.is_connected: + # Порт закрыт — пробуем переподключиться + self._try_reconnect() + return + if self._connection.seconds_since_last_command < self._IDLE_THRESHOLD_SEC: + return + + self._health_running = True + thread = _HealthCheckThread(self._connection, parent=self) + thread.result_ready.connect(self._on_health_result) + thread.finished.connect(lambda: self._cleanup_thread(thread)) + self._active_threads.append(thread) + thread.start() + + def _on_health_result(self, status: AuthStatus) -> None: + self._health_running = False + if status == AuthStatus.OK: + # Связь жива, плата авторизована + if self._plate_state != PlateState.READY: + self._update_plate_state(PlateState.READY) + elif status == AuthStatus.NEED_AUTH: + # Связь жива, но нужна авторизация + if self._plate_state != PlateState.NEED_AUTH: + self._update_plate_state(PlateState.NEED_AUTH) + else: + # Связь потеряна + self._update_plate_state(PlateState.DISCONNECTED) + self._connection.close() + + def _try_reconnect(self) -> None: + """Попытаться переподключиться к порту.""" + if self._health_running: + return + self._health_running = True + thread = _ReconnectThread(self._connection, parent=self) + thread.result_ready.connect(self._on_reconnect_result) + thread.finished.connect(lambda: self._cleanup_thread(thread)) + self._active_threads.append(thread) + thread.start() + + def _on_reconnect_result(self, success: bool) -> None: + self._health_running = False + if success: + # Порт снова доступен — проверяем авторизацию + self._check_auth_on_connect() + # Если не удалось — ничего не делаем, следующий тик попробует снова + + +class _StatusThread(QThread): + """Фоновый поток для проверки статуса платы.""" + result_ready = Signal(AuthStatus) + + def __init__(self, connection: ComConnection, timeout: float, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._conn = connection + self._timeout = timeout + + def run(self) -> None: # noqa: D401 + status = check_status(self._conn, timeout=self._timeout) + self.result_ready.emit(status) + + +class _HealthCheckThread(QThread): + """Быстрая проверка связи с платой (команда 99, таймаут 1 с).""" + result_ready = Signal(AuthStatus) + + def __init__(self, connection: ComConnection, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._conn = connection + + def run(self) -> None: # noqa: D401 + try: + status = check_status(self._conn, timeout=1.0) + except Exception: + status = AuthStatus.DISCONNECTED + self.result_ready.emit(status) + + +class _ReconnectThread(QThread): + """Попытка переподключения к порту в фоновом потоке.""" + result_ready = Signal(bool) + + def __init__(self, connection: ComConnection, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._conn = connection + + def run(self) -> None: # noqa: D401 + try: + self._conn.reconnect() + self.result_ready.emit(True) + except Exception: + self.result_ready.emit(False) + + +class _HandshakeThread(QThread): + """Фоновый поток для авторизации.""" + result_ready = Signal(object) + + def __init__(self, connection: ComConnection, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._conn = connection + + def run(self) -> None: # noqa: D401 + from ..services.auth_service import perform_handshake as _handshake + result = _handshake(self._conn, timeout=2.0) + self.result_ready.emit(result) diff --git a/app/widgets/dialogs/__init__.py b/app/widgets/dialogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/widgets/dialogs/command_terminal_dialog.py b/app/widgets/dialogs/command_terminal_dialog.py new file mode 100644 index 0000000..ae77ac1 --- /dev/null +++ b/app/widgets/dialogs/command_terminal_dialog.py @@ -0,0 +1,195 @@ +"""Диалог «Ввод команд» — мини-терминал для ручного общения с платой. + +Позволяет отправлять произвольные команды и видеть ответы. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QKeyEvent +from PySide6.QtWidgets import ( + QHBoxLayout, + QLineEdit, + QPushButton, + QTextEdit, + QVBoxLayout, +) + +from ...services.com_service import ComConnection +from ...ui.icons import ICON_COLOR_MUTED, icon +from .styled_dialog import StyledDialog + + +class CommandTerminalDialog(StyledDialog): + def __init__(self, connection: ComConnection, parent=None) -> None: + super().__init__(parent) + self._conn = connection + self.setWindowTitle("Ввод команд") + self.setMinimumSize(540, 400) + + # Приостанавливаем фоновый дренаж — терминал сам управляет чтением + self._conn._io_lock.acquire() + self._conn._cmd_owner = True + + # Таймер для чтения входящих данных + self._read_timer = QTimer(self) + self._read_timer.setInterval(100) + self._read_timer.timeout.connect(self._read_incoming) + + self._setup_ui() + self._read_timer.start() + + # История команд (макс. 50) + self._history: list[str] = [] + self._history_index: int = -1 + self._history_draft: str = "" + self._MAX_HISTORY = 50 + + # Перехватываем клавиши ↑↓ в поле ввода + self._input.installEventFilter(self) + + # Фокус на поле ввода при открытии + self._input.setFocus() + + def _setup_ui(self) -> None: + root = self.containerLayout() + root.setSpacing(8) + + # ── Лог ── + self._log = QTextEdit() + self._log.setReadOnly(True) + self._log.setStyleSheet( + "QTextEdit { background: #1e293b; color: #e2e8f0; font-family: 'Consolas', 'Courier New', monospace; font-size: 12px; border-radius: 8px; padding: 8px; }" + ) + root.addWidget(self._log, 1) + + # ── Ввод ── + input_layout = QHBoxLayout() + self._input = QLineEdit() + self._input.setPlaceholderText("Введите команду (напр. 99, 05, 91, 8003)…") + self._input.returnPressed.connect(self._send_command) + input_layout.addWidget(self._input, 1) + + send_btn = QPushButton("Отправить") + send_btn.setProperty("role", "primary") + send_btn.setIcon(icon("fa5s.paper-plane", color="#ecfdf5")) + send_btn.clicked.connect(self._send_command) + input_layout.addWidget(send_btn) + + clear_btn = QPushButton("Очистить") + clear_btn.setIcon(icon("fa5s.eraser", color=ICON_COLOR_MUTED)) + clear_btn.clicked.connect(self._log.clear) + input_layout.addWidget(clear_btn) + + root.addLayout(input_layout) + + # ── Быстрые команды ── + quick_layout = QHBoxLayout() + quick_commands = [ + ("Статус (99)", "99"), + ("Версия (05)", "05"), + ("MAC (06)", "06"), + ("Входы (91)", "91"), + ("Сброс (00)", "00"), + ] + for label, cmd in quick_commands: + btn = QPushButton(label) + btn.setMinimumHeight(30) + btn.setStyleSheet("padding: 4px 10px;") + btn.clicked.connect(lambda checked=False, c=cmd: self._send(c)) + quick_layout.addWidget(btn) + quick_layout.addStretch() + root.addLayout(quick_layout) + + # ── Закрыть ── + btn_layout = QHBoxLayout() + btn_layout.addStretch() + close_btn = QPushButton("Закрыть") + close_btn.setIcon(icon("fa5s.times", color=ICON_COLOR_MUTED)) + close_btn.clicked.connect(self.close) + btn_layout.addWidget(close_btn) + root.addLayout(btn_layout) + + def _send_command(self) -> None: + text = self._input.text().strip() + if not text: + return + # Сохранить в историю + if not self._history or self._history[-1] != text: + self._history.append(text) + if len(self._history) > self._MAX_HISTORY: + self._history.pop(0) + self._history_index = -1 + self._history_draft = "" + + self._send(text) + self._input.clear() + + def eventFilter(self, watched, event): # type: ignore[override] + if watched is self._input and isinstance(event, QKeyEvent): + if event.type() == QKeyEvent.Type.KeyPress: + key = event.key() + if key == Qt.Key.Key_Up: + self._history_navigate(-1) + return True + if key == Qt.Key.Key_Down: + self._history_navigate(1) + return True + return super().eventFilter(watched, event) + + def _history_navigate(self, direction: int) -> None: + """Перемещение по истории: direction=-1 (назад), +1 (вперёд).""" + if not self._history: + return + + if self._history_index == -1: + # Сохраняем текущий ввод как черновик + self._history_draft = self._input.text() + + new_index = self._history_index - direction + if new_index < -1: + new_index = -1 + elif new_index >= len(self._history): + new_index = len(self._history) - 1 + + self._history_index = new_index + if new_index == -1: + self._input.setText(self._history_draft) + else: + self._input.setText(self._history[len(self._history) - 1 - new_index]) + + def _send(self, cmd: str) -> None: + if not self._conn.is_connected: + self._log.append('✗ Порт не подключён') + return + try: + ser = self._conn.serial + assert ser is not None + ser.reset_input_buffer() + ser.write((cmd + "\r\n").encode("ascii")) + self._log.append(f'→ {cmd}') + except Exception as exc: + self._log.append(f'✗ {exc}') + + def _read_incoming(self) -> None: + if not self._conn.is_connected: + return + try: + ser = self._conn.serial + if ser is None: + return + if ser.in_waiting > 0: + raw = ser.read(ser.in_waiting) + text = raw.decode("ascii", errors="replace").strip() + if text: + for line in text.splitlines(): + stripped = line.strip() + if stripped: + self._log.append(f'← {stripped}') + except Exception: + pass + + def closeEvent(self, event) -> None: + self._read_timer.stop() + self._conn.release() + super().closeEvent(event) diff --git a/app/widgets/dialogs/commands_dialog.py b/app/widgets/dialogs/commands_dialog.py new file mode 100644 index 0000000..5962654 --- /dev/null +++ b/app/widgets/dialogs/commands_dialog.py @@ -0,0 +1,168 @@ +"""Диалог «Команды платы» — справочник команд из CSV-файлов.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QComboBox, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QScrollArea, + QVBoxLayout, + QWidget, +) + +from ...services.commands_service import CommandSet, load_all_command_sets +from ...ui.icons import ICON_COLOR_DARK, icon +from .styled_dialog import StyledDialog + + +class _CommandCard(QFrame): + """Карточка одной команды.""" + + def __init__(self, code: str, name: str, description: str, + parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setObjectName("PanelCard") + self._code = code.lower() + self._name = name.lower() + self._desc = description.lower() + + lay = QVBoxLayout(self) + lay.setContentsMargins(12, 10, 12, 10) + lay.setSpacing(4) + + # Верхняя строка: код + имя + top = QHBoxLayout() + top.setSpacing(8) + + code_label = QLabel(code) + code_label.setObjectName("HeroBadge") + code_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + top.addWidget(code_label) + + name_label = QLabel(name) + name_label.setObjectName("SectionTitle") + name_label.setWordWrap(True) + top.addWidget(name_label, 1) + lay.addLayout(top) + + # Описание + desc_label = QLabel(description) + desc_label.setObjectName("HeroHint") + desc_label.setWordWrap(True) + lay.addWidget(desc_label) + + def matches(self, query: str) -> bool: + q = query.lower() + return q in self._code or q in self._name or q in self._desc + + +class CommandsDialog(StyledDialog): + """Справочник команд платы: поиск + прокручиваемый список карточек.""" + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("Команды платы") + self.setMinimumWidth(420) + self.setMinimumHeight(460) + self.resize(520, 560) + + lay = self.containerLayout() + lay.setSpacing(10) + + # ── Строка фильтра ──────────────────────────────────────── + filter_row = QHBoxLayout() + filter_row.setSpacing(8) + + self._search = QLineEdit() + self._search.setPlaceholderText("Поиск по коду, названию или описанию…") + search_ico = icon("fa5s.search", color=ICON_COLOR_DARK) + if not search_ico.isNull(): + self._search.addAction(search_ico, QLineEdit.ActionPosition.LeadingPosition) + self._search.textChanged.connect(self._apply_filter) + filter_row.addWidget(self._search, 1) + + self._set_combo = QComboBox() + self._set_combo.setMinimumWidth(140) + self._set_combo.currentIndexChanged.connect(self._on_set_changed) + filter_row.addWidget(self._set_combo) + + lay.addLayout(filter_row) + + # ── Прокручиваемая область ──────────────────────────────── + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.Shape.NoFrame) + self._scroll.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + + self._cards_widget = QWidget() + self._cards_widget.setObjectName("CardsContainer") + self._cards_layout = QVBoxLayout(self._cards_widget) + self._cards_layout.setContentsMargins(2, 4, 6, 4) + self._cards_layout.setSpacing(6) + self._cards_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + self._scroll.setWidget(self._cards_widget) + lay.addWidget(self._scroll, 1) + + # ── Счётчик ─────────────────────────────────────────────── + self._count_label = QLabel() + self._count_label.setObjectName("HeroHint") + self._count_label.setAlignment(Qt.AlignmentFlag.AlignRight) + lay.addWidget(self._count_label) + + # ── Загрузка данных ─────────────────────────────────────── + self._command_sets: list[CommandSet] = load_all_command_sets() + self._cards: list[_CommandCard] = [] + + if self._command_sets: + self._set_combo.blockSignals(True) + for cs in self._command_sets: + self._set_combo.addItem(cs.set_name) + self._set_combo.blockSignals(False) + self._build_cards(self._command_sets[0]) + else: + self._set_combo.addItem("Нет файлов") + self._set_combo.setEnabled(False) + + # ── Построение карточек ──────────────────────────────────────── + + def _clear_cards(self) -> None: + for card in self._cards: + self._cards_layout.removeWidget(card) + card.setParent(None) # type: ignore[call-overload] + card.deleteLater() + self._cards.clear() + + def _build_cards(self, cs: CommandSet) -> None: + self._clear_cards() + for cmd in cs.commands: + card = _CommandCard(cmd.code, cmd.name, cmd.description) + self._cards_layout.addWidget(card) + self._cards.append(card) + self._apply_filter(self._search.text()) + + # ── Фильтрация ──────────────────────────────────────────────── + + def _apply_filter(self, text: str) -> None: + query = text.strip() + visible = 0 + for card in self._cards: + show = card.matches(query) if query else True + card.setVisible(show) + if show: + visible += 1 + total = len(self._cards) + if query: + self._count_label.setText(f"Показано {visible} из {total}") + else: + self._count_label.setText(f"Всего команд: {total}") + + def _on_set_changed(self, index: int) -> None: + if 0 <= index < len(self._command_sets): + self._build_cards(self._command_sets[index]) diff --git a/app/widgets/dialogs/firmware_dialog.py b/app/widgets/dialogs/firmware_dialog.py new file mode 100644 index 0000000..39edecb --- /dev/null +++ b/app/widgets/dialogs/firmware_dialog.py @@ -0,0 +1,418 @@ +"""Диалог «Обновление прошивки» — загрузка и прошивка контроллера. + +Команды: + 05 — версия прошивки + 06 — MAC-адрес + 03 — содержимое SPIFFS + 02 — подготовка к приёму файла → пересылка .bin блоками по 4096 + 04 — запуск OTA-обновления + +Протокол загрузки (команда 02): + 1. Отправляем «02\r\n» + 2. Контроллер ~15 сек форматирует SPIFFS, затем шлёт строку-подтверждение + 3. После подтверждения всё, что приходит в порт, записывается в файл update.bin + 4. На каждый принятый блок (4096 байт) контроллер отвечает размером принятого + 5. Когда принято size байт — переход обратно в режим команд +""" + +from __future__ import annotations + +import os +import time + +from PySide6.QtCore import Qt, QThread, Signal, QObject +from PySide6.QtWidgets import ( + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QProgressBar, + QPushButton, + QTextEdit, + QVBoxLayout, +) + +from ...services.com_service import ComConnection +from ...ui.icons import ICON_COLOR_DARK, ICON_COLOR_MUTED, icon +from .styled_dialog import StyledDialog + +_BLOCK_SIZE = 4096 + + +class FirmwareDialog(StyledDialog): + def __init__(self, connection: ComConnection, parent=None) -> None: + super().__init__(parent) + self._conn = connection + self._firmware_path: str | None = None + self._upload_thread: QThread | None = None + self._worker: _UploadWorker | None = None + self.setWindowTitle("Обновление прошивки") + self.setMinimumSize(520, 440) + self._setup_ui() + + def _setup_ui(self) -> None: + root = self.containerLayout() + root.setSpacing(12) + + # ── Информация о плате ── + info_group = QGroupBox("Информация о плате") + info_layout = QVBoxLayout(info_group) + + info_btn_layout = QHBoxLayout() + ver_btn = QPushButton("Версия (05)") + ver_btn.setIcon(icon("fa5s.info-circle", color=ICON_COLOR_DARK)) + ver_btn.clicked.connect(self._query_version) + info_btn_layout.addWidget(ver_btn) + + mac_btn = QPushButton("MAC-адрес (06)") + mac_btn.setIcon(icon("fa5s.fingerprint", color=ICON_COLOR_DARK)) + mac_btn.clicked.connect(self._query_mac) + info_btn_layout.addWidget(mac_btn) + + spiffs_btn = QPushButton("SPIFFS (03)") + spiffs_btn.setIcon(icon("fa5s.hdd", color=ICON_COLOR_DARK)) + spiffs_btn.clicked.connect(self._query_spiffs) + info_btn_layout.addWidget(spiffs_btn) + + info_layout.addLayout(info_btn_layout) + + self._info_label = QLabel("—") + self._info_label.setWordWrap(True) + self._info_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + info_layout.addWidget(self._info_label) + + root.addWidget(info_group) + + # ── Загрузка файла ── + upload_group = QGroupBox("Загрузка прошивки") + upload_layout = QVBoxLayout(upload_group) + + file_layout = QHBoxLayout() + self._file_label = QLabel("Файл не выбран") + self._file_label.setWordWrap(True) + file_layout.addWidget(self._file_label, 1) + + pick_btn = QPushButton("Выбрать .bin") + pick_btn.setIcon(icon("fa5s.folder-open", color=ICON_COLOR_DARK)) + pick_btn.clicked.connect(self._pick_file) + file_layout.addWidget(pick_btn) + upload_layout.addLayout(file_layout) + + self._progress = QProgressBar() + self._progress.setValue(0) + upload_layout.addWidget(self._progress) + + upload_btn_layout = QHBoxLayout() + self._upload_btn = QPushButton("Загрузить на плату (02)") + self._upload_btn.setProperty("role", "primary") + self._upload_btn.setIcon(icon("fa5s.upload", color="#ecfdf5")) + self._upload_btn.clicked.connect(self._start_upload) + upload_btn_layout.addWidget(self._upload_btn) + + self._flash_btn = QPushButton("Прошить (04)") + self._flash_btn.setIcon(icon("fa5s.bolt", color=ICON_COLOR_DARK)) + self._flash_btn.clicked.connect(self._flash_ota) + self._flash_btn.setEnabled(False) + upload_btn_layout.addWidget(self._flash_btn) + upload_layout.addLayout(upload_btn_layout) + + root.addWidget(upload_group) + + # ── Лог ── + self._log = QTextEdit() + self._log.setReadOnly(True) + self._log.setMaximumHeight(200) + root.addWidget(self._log) + + # ── Закрыть ── + btn_layout = QHBoxLayout() + btn_layout.addStretch() + close_btn = QPushButton("Закрыть") + close_btn.setIcon(icon("fa5s.times", color=ICON_COLOR_MUTED)) + close_btn.clicked.connect(self.close) + btn_layout.addWidget(close_btn) + root.addLayout(btn_layout) + + # ── Запросы информации ── + + def _query(self, cmd: str) -> str | None: + if not self._conn.is_connected: + QMessageBox.warning(self, "Ошибка", "Порт не подключён.") + return None + try: + self._conn.send_command(cmd) + resp = self._conn.read_response(timeout=1.0) + self._conn.release() + return resp + except Exception as exc: + self._conn.release() + self._log.append(f"Ошибка: {exc}") + return None + + def _query_version(self) -> None: + resp = self._query("05") + self._info_label.setText(f"Версия: {resp or '—'}") + self._log.append(f"→ 05\n← {resp or 'таймаут'}") + + def _query_mac(self) -> None: + resp = self._query("06") + self._info_label.setText(f"MAC: {resp or '—'}") + self._log.append(f"→ 06\n← {resp or 'таймаут'}") + + def _query_spiffs(self) -> None: + resp = self._query("03") + self._info_label.setText(f"SPIFFS:\n{resp or '—'}") + self._log.append(f"→ 03\n← {resp or 'таймаут'}") + + # ── Загрузка файла ── + + def _pick_file(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Выбрать файл прошивки", "", "BIN Files (*.bin)") + if path: + self._firmware_path = path + size = os.path.getsize(path) + self._file_label.setText(f"{os.path.basename(path)} ({size:,} байт)") + self._flash_btn.setEnabled(False) + self._progress.setValue(0) + + def _start_upload(self) -> None: + if not self._firmware_path: + QMessageBox.warning(self, "Ошибка", "Сначала выберите файл прошивки.") + return + if not self._conn.is_connected: + QMessageBox.warning(self, "Ошибка", "Порт не подключён.") + return + + self._upload_btn.setEnabled(False) + self._progress.setValue(0) + self._log.append("Запуск загрузки…") + + self._worker = _UploadWorker(self._conn, self._firmware_path) + self._upload_thread = QThread() + self._worker.moveToThread(self._upload_thread) + self._worker.progress.connect(self._on_upload_progress) + self._worker.log_message.connect(self._log.append) + self._worker.finished.connect(self._on_upload_done) + self._worker.finished.connect(self._upload_thread.quit) + self._upload_thread.finished.connect(self._cleanup_upload) + self._upload_thread.started.connect(self._worker.run) + self._upload_thread.start() + + def _cleanup_upload(self) -> None: + """Убрать ссылки на поток и воркер после завершения.""" + if self._worker is not None: + self._worker.deleteLater() + self._worker = None + if self._upload_thread is not None: + self._upload_thread.deleteLater() + self._upload_thread = None + + def _on_upload_progress(self, percent: int) -> None: + self._progress.setValue(percent) + + def _on_upload_done(self, success: bool) -> None: + self._upload_btn.setEnabled(True) + if success: + self._flash_btn.setEnabled(True) + self._log.append("✓ Загрузка завершена. Можно прошивать (04).") + else: + self._log.append("✗ Загрузка не удалась.") + + def _flash_ota(self) -> None: + resp = self._query("04") + self._log.append(f"→ 04\n← {resp or 'таймаут'}") + if resp: + QMessageBox.information(self, "OTA", f"Ответ контроллера:\n{resp}") + + def closeEvent(self, event) -> None: + if self._upload_thread is not None and self._upload_thread.isRunning(): + self._upload_thread.quit() + self._upload_thread.wait(3000) + super().closeEvent(event) + + +class _UploadWorker(QObject): + progress = Signal(int) + log_message = Signal(str) + finished = Signal(bool) + + def __init__(self, conn: ComConnection, path: str) -> None: + super().__init__() + self._conn = conn + self._path = path + + def run(self) -> None: # noqa: C901 + serial = None + old_timeout = None + # Приостанавливаем фоновый дренаж на время прошивки + self._conn._io_lock.acquire() + self._conn._cmd_owner = True + try: + file_size = os.path.getsize(self._path) + serial = self._conn.serial + if serial is None: + self.log_message.emit("✗ Serial-объект не доступен") + self.finished.emit(False) + return + + old_timeout = serial.timeout + + # ── Шаг 1: очистить буферы ── + serial.reset_input_buffer() + serial.reset_output_buffer() + self.log_message.emit("Буферы очищены") + + # ── Шаг 2: отправить 02 ── + cmd = f"02{file_size}" + payload = (cmd + "\r\n").encode("ascii") + self.log_message.emit(f"→ {cmd} ({len(payload)} байт)") + written = serial.write(payload) + serial.flush() + self.log_message.emit(f" write() вернул {written}") + + # ── Шаг 3: ждать «file ready to write» от контроллера ── + # Последовательность ответов контроллера: + # ← SPIFFS Mount OK + # ← SPIFFS formatting .... WAIT (~15 сек) + # ← format OK + # ← file ready to write ← ТОЛЬКО после этого можно слать данные + self.log_message.emit("⏳ Ожидание форматирования SPIFFS...") + deadline = time.time() + 30.0 + buf = b"" + ready = False + last_report = time.time() + + while time.time() < deadline: + waiting = serial.in_waiting + if waiting > 0: + chunk = serial.read(waiting) + buf += chunk + # Разбираем полные строки + while b"\n" in buf: + line_raw, buf = buf.split(b"\n", 1) + line = line_raw.decode("ascii", errors="replace").strip() + if line: + self.log_message.emit(f"← {line}") + # Ждём конкретно эту строку + if "file ready" in line.lower(): + ready = True + break + if ready: + break + else: + now = time.time() + if now - last_report >= 2.0: + elapsed = int(now - (deadline - 30.0)) + self.log_message.emit(f" ожидание... {elapsed} сек") + last_report = now + time.sleep(0.1) + + # Если в буфере остались данные без \n + if buf: + tail = buf.decode("ascii", errors="replace").strip() + if tail: + self.log_message.emit(f"← {tail}") + if "file ready" in tail.lower(): + ready = True + + if not ready: + self.log_message.emit( + "✗ Не получено «file ready to write» (таймаут 30 сек)" + ) + serial.timeout = old_timeout + self.finished.emit(False) + return + + # Небольшая пауза, чтобы контроллер был готов принимать данные + time.sleep(0.2) + # Очистим входной буфер — всё что пришло до этого момента уже обработано + serial.reset_input_buffer() + + self.log_message.emit("✓ Контроллер готов к приёму файла") + + # ── Шаг 4: отправляем файл блоками ── + sent = 0 + block_num = 0 + total_blocks = (file_size + _BLOCK_SIZE - 1) // _BLOCK_SIZE + self.log_message.emit( + f"Отправка {file_size} байт ({total_blocks} блоков по {_BLOCK_SIZE})" + ) + + serial.timeout = 10.0 # для readline ACK + + with open(self._path, "rb") as f: + while True: + block = f.read(_BLOCK_SIZE) + if not block: + break + block_num += 1 + + serial.write(block) + serial.flush() + sent += len(block) + percent = int(sent * 100 / file_size) + self.progress.emit(percent) + + # Ждём ACK — polling, чтобы не зависнуть навечно + ack_deadline = time.time() + 10.0 + ack_buf = b"" + got_ack = False + while time.time() < ack_deadline: + w = serial.in_waiting + if w > 0: + ack_buf += serial.read(w) + if b"\n" in ack_buf: + ack_line, ack_buf = ack_buf.split(b"\n", 1) + ack = ack_line.decode("ascii", errors="replace").strip() + self.log_message.emit( + f" [{block_num}/{total_blocks}] " + f"{sent}/{file_size}: ← {ack}" + ) + got_ack = True + break + else: + time.sleep(0.05) + + if not got_ack: + # Может ACK без \n + if ack_buf: + ack = ack_buf.decode("ascii", errors="replace").strip() + self.log_message.emit( + f" [{block_num}/{total_blocks}] " + f"{sent}/{file_size}: ← {ack}" + ) + else: + self.log_message.emit( + f" [{block_num}/{total_blocks}] " + f"{sent}/{file_size}: (нет ACK)" + ) + + serial.timeout = old_timeout + self.progress.emit(100) + + # ── Шаг 5: финальный ответ ── + time.sleep(0.5) + final_deadline = time.time() + 5.0 + while time.time() < final_deadline: + if serial.in_waiting > 0: + raw = serial.read(serial.in_waiting) + final = raw.decode("ascii", errors="replace").strip() + if final: + self.log_message.emit(f"← {final}") + break + time.sleep(0.1) + + self.log_message.emit(f"✓ Отправлено {sent} байт ({block_num} блоков)") + self.finished.emit(True) + + except Exception as exc: + self.log_message.emit(f"Ошибка: {exc}") + self.finished.emit(False) + finally: + if serial and old_timeout is not None: + try: + serial.timeout = old_timeout + except Exception: + pass + self._conn.release() diff --git a/app/widgets/dialogs/input_test_dialog.py b/app/widgets/dialogs/input_test_dialog.py new file mode 100644 index 0000000..d59b70f --- /dev/null +++ b/app/widgets/dialogs/input_test_dialog.py @@ -0,0 +1,696 @@ +"""Диалог «Тест ввода» — отображение цифровых и аналоговых входов платы. + +Цифровые входы: команда 91 → INPUT (26 бит: 24 входа + 2 хоппера) +Аналоговые входы: команда 92 → ADC,,, + +Опрос запускается автоматически при открытии диалога и работает +в фоновом потоке, чтобы не блокировать UI. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, QThread, QTimer, Signal +from PySide6.QtWidgets import ( + QComboBox, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSizePolicy, + QSpinBox, + QVBoxLayout, +) + +from ...services.com_service import ComConnection +from ...services.input_mappings_service import ( + InputMapping, + _MAX_Y, + get_input_mappings_dir, + load_all_input_mappings, +) +from ...ui.icons import ICON_COLOR_DARK, ICON_COLOR_MUTED, icon +from .styled_dialog import StyledDialog + +_NUM_DIGITAL = 26 # 24 входа + 2 хоппера + + +class _InputPollThread(QThread): + """Фоновый поток циклического опроса цифровых входов. + + Drain приостановлен на время жизни этого потока, поэтому + можно работать напрямую с serial без lock. + """ + + digital_ready = Signal(object) # int — битовая маска 26 бит + analog_ready = Signal(object) # list[str] — 4 значения ADC + info_line = Signal(str) # COINS / HOPPER / PRIZES и пр. + + def __init__(self, connection: ComConnection) -> None: + super().__init__() + self._conn = connection + self._running = True + self._do_analog = False # однократный запрос ADC по кнопке + self._do_debounce: int | None = None # однократная установка антидребезга + self._raw_cmd: str | None = None # произвольная команда (хоппер, монета…) + + def stop(self) -> None: + self._running = False + + def request_analog(self) -> None: + """Запросить одноразовый опрос аналоговых входов (из GUI-потока).""" + self._do_analog = True + + def request_debounce(self, ms: int) -> None: + """Запросить установку антидребезга (из GUI-потока).""" + self._do_debounce = ms + + def send_raw(self, cmd: str) -> None: + """Поставить произвольную команду на отправку (из GUI-потока).""" + self._raw_cmd = cmd + + def _readline(self, ser, deadline) -> str | None: + """Прочитать одну строку из serial до deadline. + + Использует in_waiting для минимальной задержки: если байты + уже в буфере — забираем сразу, иначе короткий read(1) с + таймаутом 2 мс, чтобы не блокировать поток. + """ + import time + buf = bytearray() + while time.monotonic() < deadline: + # Сначала забираем всё, что уже в буфере драйвера + avail = ser.in_waiting + if avail: + chunk = ser.read(avail) + for byte in chunk: + if byte in (0x0D, 0x0A): # \r \n + if buf: + return buf.decode("ascii", errors="replace") + continue + buf.append(byte) + else: + # Буфер пуст — ждём немного + remaining = max(deadline - time.monotonic(), 0.001) + ser.timeout = min(remaining, 0.002) # 2 мс + b = ser.read(1) + if not b: + if buf: + return buf.decode("ascii", errors="replace") + continue + if b[0] in (0x0D, 0x0A): + if buf: + return buf.decode("ascii", errors="replace") + continue + buf.append(b[0]) + if buf: + return buf.decode("ascii", errors="replace") + return None + + def _check_info_line(self, line: str) -> bool: + """Проверить, является ли строка информационной (COINS, HOPPER, PRIZES, SERVICE). + + Если да — отправить сигнал и вернуть True. + """ + upper = line.upper() + for prefix in ("COINS", "HOPPER", "PRIZES", "SERVICE"): + if upper.startswith(prefix): + self.info_line.emit(line) + return True + return False + + def run(self) -> None: # noqa: D401 + import time + + while self._running: + ser = self._conn.serial + if ser is None or not ser.is_open: + self.msleep(100) + continue + + old_timeout = ser.timeout + try: + # ── Однократная установка антидребезга ── + debounce = self._do_debounce + if debounce is not None: + self._do_debounce = None + ser.write(f"91{debounce}\r\n".encode()) + self.msleep(20) + continue + + # ── Произвольная команда (хоппер, монета…) ── + raw = self._raw_cmd + if raw is not None: + self._raw_cmd = None + ser.write(f"{raw}\r\n".encode()) + # Читаем ответ (может быть HOPPER OK/ERROR и т.п.) + deadline = time.monotonic() + 0.5 + while self._running and time.monotonic() < deadline: + line = self._readline(ser, deadline) + if line is None: + break + self._check_info_line(line) + continue + + # ── Однократный опрос ADC ── + if self._do_analog: + self._do_analog = False + ser.reset_input_buffer() + ser.write(b"92\r\n") + deadline = time.monotonic() + 0.2 + while self._running and time.monotonic() < deadline: + line = self._readline(ser, deadline) + if line is None: + break + if line.upper().startswith("ADC"): + parts = line[3:].split(",") + self.analog_ready.emit(parts) + break + self._check_info_line(line) + continue + + # ── Циклический опрос цифровых входов ── + ser.write(b"91\r\n") + + deadline = time.monotonic() + 0.08 # 80 мс макс. ожидание + while self._running and time.monotonic() < deadline: + line = self._readline(ser, deadline) + if line is None: + break + if line.upper().startswith("INPUT"): + val = int(line[5:]) + self.digital_ready.emit(val) + break + # Информационные строки перехватываем + self._check_info_line(line) + except Exception: + pass + finally: + try: + ser.timeout = old_timeout + except Exception: + pass + # Без msleep — сразу следующий цикл + + +class InputTestDialog(StyledDialog): + def __init__(self, connection: ComConnection, parent=None) -> None: + super().__init__(parent) + self._conn = connection + self._closed = False + self.setWindowTitle("Тест ввода") + self.setMinimumSize(600, 560) + + # Состояние программной автовыдачи хоппера (0 = неактивна, 1 или 2 = номер хоппера) + self._auto_dispense_hopper: int = 0 + self._auto_dispense_timer: QTimer | None = None + + self._mappings = load_all_input_mappings() + self._current_mapping: InputMapping | None = None + + self._setup_ui() + self._apply_mapping() + + # Приостанавливаем дренаж — опрос будет единственным читателем порта + self._conn.pause_drain() + + # Запуск опроса сразу при открытии + self._poll_thread = _InputPollThread(connection) + self._poll_thread.digital_ready.connect( + self._on_digital, Qt.ConnectionType.QueuedConnection + ) + self._poll_thread.analog_ready.connect( + self._on_analog, Qt.ConnectionType.QueuedConnection + ) + self._poll_thread.info_line.connect( + self._on_info_line, Qt.ConnectionType.QueuedConnection + ) + self._poll_thread.start() + + # ── UI ──────────────────────────────────────────────────────────── + + def _setup_ui(self) -> None: + root = self.containerLayout() + root.setSpacing(12) + + # ── Выбор маппинга ── + mapping_row = QHBoxLayout() + mapping_row.addWidget(QLabel("Раскладка:")) + self._mapping_combo = QComboBox() + self._mapping_combo.addItem("— Без раскладки —") + for m in self._mappings: + self._mapping_combo.addItem(m.project_name) + self._mapping_combo.currentIndexChanged.connect(self._on_mapping_changed) + mapping_row.addWidget(self._mapping_combo, 1) + + reload_btn = QPushButton() + reload_btn.setIcon(icon("fa5s.sync-alt", color=ICON_COLOR_DARK)) + reload_btn.setToolTip("Перечитать файлы раскладок") + reload_btn.setFixedWidth(36) + reload_btn.clicked.connect(self._reload_mappings) + mapping_row.addWidget(reload_btn) + + open_dir_btn = QPushButton() + open_dir_btn.setIcon(icon("fa5s.folder-open", color=ICON_COLOR_DARK)) + open_dir_btn.setToolTip("Открыть папку input_mappings") + open_dir_btn.setFixedWidth(36) + open_dir_btn.clicked.connect(self._open_mappings_dir) + mapping_row.addWidget(open_dir_btn) + + root.addLayout(mapping_row) + + # ── Холст кнопок (по координатам из маппинга) ── + self._canvas_group = QGroupBox("Кнопки") + self._canvas_grid = QGridLayout(self._canvas_group) + self._canvas_grid.setSpacing(4) + self._canvas_labels: dict[int, QLabel] = {} # input_index → QLabel + root.addWidget(self._canvas_group) + + # ── Цифровые входы (классическая сетка) ── + self._digital_group = QGroupBox("Цифровые входы (24 + 2 хоппера)") + digital_grid = QGridLayout(self._digital_group) + digital_grid.setSpacing(4) + + self._digital_labels: list[QLabel] = [] + for i in range(_NUM_DIGITAL): + lbl = QLabel(f"IN{i}") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setFixedSize(52, 28) + lbl.setObjectName("InputIndicator") + lbl.setProperty("active", False) + self._digital_labels.append(lbl) + row, col = divmod(i, 8) + digital_grid.addWidget(lbl, row, col) + + root.addWidget(self._digital_group) + + # ── Аналоговые входы ── + analog_group = QGroupBox("Аналоговые входы (4 канала)") + analog_inner = QVBoxLayout(analog_group) + analog_values_layout = QHBoxLayout() + self._analog_labels: list[QLabel] = [] + for i in range(4): + lbl = QLabel(f"ADC{i}: —") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setMinimumWidth(90) + self._analog_labels.append(lbl) + analog_values_layout.addWidget(lbl) + analog_inner.addLayout(analog_values_layout) + poll_adc_btn = QPushButton("Опросить ADC") + poll_adc_btn.setIcon(icon("fa5s.sync", color=ICON_COLOR_DARK)) + poll_adc_btn.clicked.connect(self._poll_analog) + analog_inner.addWidget(poll_adc_btn) + root.addWidget(analog_group) + + # ── Монетоприёмник ── + coin_group = QGroupBox("Монетоприёмник") + coin_layout = QHBoxLayout(coin_group) + self._coin_label = QLabel("Монеты: —") + self._coin_label.setMinimumWidth(120) + coin_layout.addWidget(self._coin_label) + coin_confirm_btn = QPushButton("Подтвердить (10)") + coin_confirm_btn.setIcon(icon("fa5s.check-circle", color=ICON_COLOR_DARK)) + coin_confirm_btn.setToolTip("Отправить подтверждение COIN (команда 10)") + coin_confirm_btn.clicked.connect(lambda: self._send_raw("10")) + coin_layout.addWidget(coin_confirm_btn) + coin_layout.addStretch() + root.addWidget(coin_group) + + # ── Хопперы ── + hopper_group = QGroupBox("Хопперы") + hopper_layout = QVBoxLayout(hopper_group) + + # Хоппер 1 + h1_row = QHBoxLayout() + h1_row.addWidget(QLabel("Хоппер 1:")) + self._hopper1_label = QLabel("—") + self._hopper1_label.setMinimumWidth(100) + h1_row.addWidget(self._hopper1_label) + h1_on = QPushButton("Вкл") + h1_on.setIcon(icon("fa5s.play", color=ICON_COLOR_DARK)) + h1_on.setToolTip("Включить мотор (301)") + h1_on.clicked.connect(lambda: self._send_raw("301")) + h1_row.addWidget(h1_on) + h1_off = QPushButton("Выкл") + h1_off.setIcon(icon("fa5s.stop", color=ICON_COLOR_DARK)) + h1_off.setToolTip("Выключить мотор (300)") + h1_off.clicked.connect(lambda: self._send_raw("300")) + h1_row.addWidget(h1_off) + self._h1_auto_btn = QPushButton("Автовыдача") + self._h1_auto_btn.setIcon(icon("fa5s.cog", color=ICON_COLOR_DARK)) + self._h1_auto_btn.setToolTip("Программная автовыдача: вкл мотор → ждёт датчик (10 сек)") + self._h1_auto_btn.clicked.connect(lambda: self._start_auto_dispense(1)) + h1_row.addWidget(self._h1_auto_btn) + h1_prize = QPushButton("Выдать приз") + h1_prize.setIcon(icon("fa5s.gift", color=ICON_COLOR_DARK)) + h1_prize.setToolTip("Авторежим выдачи платы (309)") + h1_prize.clicked.connect(lambda: self._send_raw("309")) + h1_row.addWidget(h1_prize) + h1_row.addStretch() + hopper_layout.addLayout(h1_row) + + # Хоппер 2 + h2_row = QHBoxLayout() + h2_row.addWidget(QLabel("Хоппер 2:")) + self._hopper2_label = QLabel("—") + self._hopper2_label.setMinimumWidth(100) + h2_row.addWidget(self._hopper2_label) + h2_on = QPushButton("Вкл") + h2_on.setIcon(icon("fa5s.play", color=ICON_COLOR_DARK)) + h2_on.setToolTip("Включить мотор (401)") + h2_on.clicked.connect(lambda: self._send_raw("401")) + h2_row.addWidget(h2_on) + h2_off = QPushButton("Выкл") + h2_off.setIcon(icon("fa5s.stop", color=ICON_COLOR_DARK)) + h2_off.setToolTip("Выключить мотор (400)") + h2_off.clicked.connect(lambda: self._send_raw("400")) + h2_row.addWidget(h2_off) + self._h2_auto_btn = QPushButton("Автовыдача") + self._h2_auto_btn.setIcon(icon("fa5s.cog", color=ICON_COLOR_DARK)) + self._h2_auto_btn.setToolTip("Программная автовыдача: вкл мотор → ждёт датчик (10 сек)") + self._h2_auto_btn.clicked.connect(lambda: self._start_auto_dispense(2)) + h2_row.addWidget(self._h2_auto_btn) + h2_prize = QPushButton("Выдать приз") + h2_prize.setIcon(icon("fa5s.gift", color=ICON_COLOR_DARK)) + h2_prize.setToolTip("Авторежим выдачи платы (409)") + h2_prize.clicked.connect(lambda: self._send_raw("409")) + h2_row.addWidget(h2_prize) + h2_row.addStretch() + hopper_layout.addLayout(h2_row) + + root.addWidget(hopper_group) + + # ── Лог событий ── + self._event_log = QLabel("") + self._event_log.setWordWrap(True) + self._event_log.setStyleSheet("color: #888; font-size: 11px;") + root.addWidget(self._event_log) + + # ── Антидребезг ── + debounce_layout = QHBoxLayout() + debounce_layout.addWidget(QLabel("Антидребезг (мс):")) + self._debounce_spin = QSpinBox() + self._debounce_spin.setRange(10, 100) + self._debounce_spin.setValue(50) + debounce_layout.addWidget(self._debounce_spin) + set_debounce_btn = QPushButton("Применить") + set_debounce_btn.setIcon(icon("fa5s.check", color=ICON_COLOR_DARK)) + set_debounce_btn.clicked.connect(self._set_debounce) + debounce_layout.addWidget(set_debounce_btn) + debounce_layout.addStretch() + root.addLayout(debounce_layout) + + # ── Закрыть ── + btn_layout = QHBoxLayout() + btn_layout.addStretch() + close_btn = QPushButton("Закрыть") + close_btn.setIcon(icon("fa5s.times", color=ICON_COLOR_MUTED)) + close_btn.clicked.connect(self.close) + btn_layout.addWidget(close_btn) + root.addLayout(btn_layout) + + # ── Маппинги ────────────────────────────────────────────────────── + + def _on_mapping_changed(self, index: int) -> None: + if index <= 0: + self._current_mapping = None + else: + self._current_mapping = self._mappings[index - 1] + self._apply_mapping() + + def _apply_mapping(self) -> None: + """Перестроить холст кнопок по текущему маппингу.""" + # Очистить старые виджеты + for lbl in self._canvas_labels.values(): + self._canvas_grid.removeWidget(lbl) + lbl.deleteLater() + self._canvas_labels.clear() + + # Сбросить stretch у всех колонок/строк + for c in range(self._canvas_grid.columnCount()): + self._canvas_grid.setColumnStretch(c, 0) + for r in range(self._canvas_grid.rowCount()): + self._canvas_grid.setRowStretch(r, 0) + + mapping = self._current_mapping + if mapping is None or not mapping.buttons: + self._canvas_group.hide() + self._digital_group.show() + return + + self._canvas_group.show() + self._canvas_group.setTitle(f"Кнопки — {mapping.project_name}") + self._digital_group.hide() + + used_cols: set[int] = set() + used_rows: set[int] = set() + + for btn in mapping.buttons: + lbl = QLabel(btn.name) + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setObjectName("InputIndicator") + lbl.setProperty("active", False) + lbl.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred + ) + lbl.setMinimumHeight(28) + # Y инвертирован: +Y = вверх → меньший row в grid + grid_row = _MAX_Y - btn.y + self._canvas_grid.addWidget(lbl, grid_row, btn.x) + self._canvas_labels[btn.input_index] = lbl + used_cols.add(btn.x) + used_rows.add(grid_row) + + # Равномерно растянуть используемые колонки + for c in used_cols: + self._canvas_grid.setColumnStretch(c, 1) + for r in used_rows: + self._canvas_grid.setRowStretch(r, 1) + + # ── Обновление индикаторов ──────────────────────────────────────── + + def _on_digital(self, val: int) -> None: + if self._closed: + return + + # Проверяем автовыдачу хоппера + self._check_auto_dispense(val) + + # Обновляем классическую сетку + for i, lbl in enumerate(self._digital_labels): + active = bool(val & (1 << i)) + pressed = not active # pull-up: 1 = не нажата, 0 = нажата + if lbl.property("active") != pressed: + lbl.setProperty("active", pressed) + lbl.style().unpolish(lbl) + lbl.style().polish(lbl) + lbl.update() + + # Обновляем холст маппинга + for input_idx, lbl in self._canvas_labels.items(): + if input_idx < _NUM_DIGITAL: + active = bool(val & (1 << input_idx)) + pressed = not active + if lbl.property("active") != pressed: + lbl.setProperty("active", pressed) + lbl.style().unpolish(lbl) + lbl.style().polish(lbl) + lbl.update() + + def _on_analog(self, values: list[str]) -> None: + if self._closed: + return + for i, v in enumerate(values[:4]): + self._analog_labels[i].setText(f"ADC{i}: {v}") + + # ── Прочее ──────────────────────────────────────────────────────── + + def _send_raw(self, cmd: str) -> None: + """Отправить произвольную команду через поток опроса.""" + if not self._conn.is_connected or self._closed: + return + self._poll_thread.send_raw(cmd) + + def _on_info_line(self, line: str) -> None: + """Обработать информационную строку от платы (COINS, HOPPER, …).""" + if self._closed: + return + upper = line.upper() + if upper.startswith("COINS"): + self._coin_label.setText(f"Монеты: {line}") + elif upper.startswith("HOPPER1"): + self._hopper1_label.setText(line) + elif upper.startswith("HOPPER2"): + self._hopper2_label.setText(line) + elif upper.startswith("HOPPER"): + # Общий формат без номера + self._hopper1_label.setText(line) + elif upper.startswith("PRIZES"): + self._coin_label.setText(f"Монеты: {line}") + # Обновляем лог последнего события + self._event_log.setText(f"← {line}") + + def _poll_analog(self) -> None: + """Запросить однократный опрос аналоговых входов через поток.""" + if not self._conn.is_connected or self._closed: + return + self._poll_thread.request_analog() + + def _set_debounce(self) -> None: + if not self._conn.is_connected or self._closed: + return + val = self._debounce_spin.value() + self._poll_thread.request_debounce(val) + + # ── Программная автовыдача хоппера ──────────────────────────────── + + def _start_auto_dispense(self, hopper: int) -> None: + """Запустить программную автовыдачу для хоппера 1 или 2. + + Алгоритм: + 1. Включить мотор (301 / 401) + 2. Следить за битом датчика хоппера в INPUT (бит 24 / 25) + 3. Если бит сработал (0) за 10 сек → выключить мотор → OK + 4. Если 10 сек прошло → выключить мотор → ошибка + """ + if not self._conn.is_connected or self._closed: + return + if self._auto_dispense_hopper != 0: + # Уже идёт автовыдача + return + + self._auto_dispense_hopper = hopper + label = self._hopper1_label if hopper == 1 else self._hopper2_label + btn = self._h1_auto_btn if hopper == 1 else self._h2_auto_btn + label.setText("Автовыдача…") + btn.setEnabled(False) + + # Включаем мотор + on_cmd = "301" if hopper == 1 else "401" + self._send_raw(on_cmd) + + # Таймаут 10 секунд + self._auto_dispense_timer = QTimer(self) + self._auto_dispense_timer.setSingleShot(True) + self._auto_dispense_timer.timeout.connect(self._auto_dispense_timeout) + self._auto_dispense_timer.start(10_000) + + def _check_auto_dispense(self, val: int) -> None: + """Проверить, сработал ли датчик хоппера (вызывается из _on_digital).""" + hopper = self._auto_dispense_hopper + if hopper == 0: + return + + # Бит 24 = хоппер 1, бит 25 = хоппер 2 + bit_index = 23 + hopper # 24 или 25 + sensor_active = not bool(val & (1 << bit_index)) # pull-up: 0 = сработал + + if sensor_active: + self._finish_auto_dispense(success=True) + + def _auto_dispense_timeout(self) -> None: + """Таймаут 10 сек — датчик не сработал.""" + if self._auto_dispense_hopper != 0: + self._finish_auto_dispense(success=False) + + def _finish_auto_dispense(self, *, success: bool) -> None: + """Завершить автовыдачу: выключить мотор и показать результат.""" + hopper = self._auto_dispense_hopper + if hopper == 0: + return + + # Останавливаем таймер + if self._auto_dispense_timer is not None: + self._auto_dispense_timer.stop() + self._auto_dispense_timer.deleteLater() + self._auto_dispense_timer = None + + # Выключаем мотор + off_cmd = "300" if hopper == 1 else "400" + self._send_raw(off_cmd) + + label = self._hopper1_label if hopper == 1 else self._hopper2_label + btn = self._h1_auto_btn if hopper == 1 else self._h2_auto_btn + self._auto_dispense_hopper = 0 + btn.setEnabled(True) + + if success: + label.setText("Автовыдача: OK ✓") + self._event_log.setText(f"← Хоппер {hopper}: приз выдан") + else: + label.setText("Автовыдача: ОШИБКА ✗") + self._event_log.setText(f"← Хоппер {hopper}: таймаут 10 сек") + QMessageBox.warning( + self, + f"Хоппер {hopper}", + f"Ошибка выдачи: датчик хоппера {hopper} не сработал за 10 секунд.", + ) + + def _reload_mappings(self) -> None: + """Перечитать .mapping файлы из папки.""" + current_name = self._mapping_combo.currentText() + self._mappings = load_all_input_mappings() + self._mapping_combo.blockSignals(True) + self._mapping_combo.clear() + self._mapping_combo.addItem("— Без раскладки —") + for m in self._mappings: + self._mapping_combo.addItem(m.project_name) + # Попробуем восстановить выбор + idx = self._mapping_combo.findText(current_name) + if idx >= 0: + self._mapping_combo.setCurrentIndex(idx) + self._mapping_combo.blockSignals(False) + self._on_mapping_changed(self._mapping_combo.currentIndex()) + + def _open_mappings_dir(self) -> None: + """Открыть папку input_mappings/ в проводнике.""" + import os + import subprocess + import sys as _sys + + mappings_dir = get_input_mappings_dir() + if not mappings_dir.exists(): + mappings_dir.mkdir(parents=True, exist_ok=True) + if _sys.platform == "win32": + os.startfile(str(mappings_dir)) # noqa: S606 + elif _sys.platform == "darwin": + subprocess.Popen(["open", str(mappings_dir)]) # noqa: S603, S607 + else: + subprocess.Popen(["xdg-open", str(mappings_dir)]) # noqa: S603, S607 + + def closeEvent(self, event) -> None: + self._closed = True + # Если автовыдача активна — выключаем мотор + if self._auto_dispense_hopper != 0: + off_cmd = "300" if self._auto_dispense_hopper == 1 else "400" + self._auto_dispense_hopper = 0 + if self._auto_dispense_timer is not None: + self._auto_dispense_timer.stop() + self._auto_dispense_timer = None + # Отправляем напрямую, т.к. поток ещё жив + self._poll_thread.send_raw(off_cmd) + self._poll_thread.stop() + try: + self._poll_thread.digital_ready.disconnect() + except RuntimeError: + pass + try: + self._poll_thread.analog_ready.disconnect() + except RuntimeError: + pass + try: + self._poll_thread.info_line.disconnect() + except RuntimeError: + pass + self._poll_thread.wait(3000) + # Очищаем буфер serial от возможных остаточных ответов + try: + ser = self._conn.serial + if ser is not None and ser.is_open: + ser.reset_input_buffer() + except Exception: + pass + # Возобновляем дренаж + self._conn.resume_drain() + super().closeEvent(event) diff --git a/app/widgets/dialogs/led_test_dialog.py b/app/widgets/dialogs/led_test_dialog.py new file mode 100644 index 0000000..597b940 --- /dev/null +++ b/app/widgets/dialogs/led_test_dialog.py @@ -0,0 +1,326 @@ +"""Диалог «Тест подсветки» — управление адресными диодами платы. + +Загружает конфигурации эффектов из .lm файлов в папке light_modes/. +Поддерживает два формата команд: + - Простой «8XYY»: канал X (0=все, 1-4), программа YY + - С длительностью «85XXYY»: XX — эффект, YY — время в секундах + - Цветовое чередование «89[X]» (0=RGB … 5=GBR) +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +from PySide6.QtCore import Qt, QTimer +from PySide6.QtWidgets import ( + QComboBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSpinBox, + QVBoxLayout, +) + +from ...services.com_service import ComConnection +from ...services.light_modes_service import ( + LightMode, + get_light_modes_dir, + load_all_light_modes, +) +from ...ui.icons import ICON_COLOR_DARK, ICON_COLOR_MUTED, icon +from .styled_dialog import StyledDialog + +_COLOR_ORDERS: list[tuple[int, str]] = [ + (0, "RGB"), + (1, "RBG"), + (2, "BRG"), + (3, "BGR"), + (4, "GRB"), + (5, "GBR"), +] + + +class LedTestDialog(StyledDialog): + def __init__(self, connection: ComConnection, parent=None) -> None: + super().__init__(parent) + self._conn = connection + self._modes: list[LightMode] = [] + self.setWindowTitle("Тест подсветки") + self.setMinimumWidth(480) + self._load_modes() + self._setup_ui() + + def _load_modes(self) -> None: + self._modes = load_all_light_modes() + + def _setup_ui(self) -> None: + root = self.containerLayout() + root.setSpacing(12) + + # ── Выбор проекта (игры) ── + project_group = QGroupBox("Игровой проект") + project_layout = QVBoxLayout(project_group) + + proj_row = QHBoxLayout() + self._project_combo = QComboBox() + for mode in self._modes: + self._project_combo.addItem(mode.project_name, mode) + self._project_combo.currentIndexChanged.connect(self._on_project_changed) + proj_row.addWidget(self._project_combo, 1) + + reload_btn = QPushButton() + reload_btn.setIcon(icon("fa5s.sync-alt", color=ICON_COLOR_DARK)) + reload_btn.setToolTip("Перечитать файлы конфигураций") + reload_btn.setFixedWidth(36) + reload_btn.clicked.connect(self._reload_modes) + proj_row.addWidget(reload_btn) + + open_dir_btn = QPushButton() + open_dir_btn.setIcon(icon("fa5s.folder-open", color=ICON_COLOR_DARK)) + open_dir_btn.setToolTip("Открыть папку light_modes") + open_dir_btn.setFixedWidth(36) + open_dir_btn.clicked.connect(self._open_modes_dir) + proj_row.addWidget(open_dir_btn) + + project_layout.addLayout(proj_row) + root.addWidget(project_group) + + # ── Эффекты ── + effect_group = QGroupBox("Эффекты") + effect_form = QFormLayout(effect_group) + + # Канал (только для base_command = "8") + self._channel_combo = QComboBox() + self._channel_combo.addItem("Все каналы (0)", 0) + for ch in range(1, 5): + self._channel_combo.addItem(f"Канал {ch}", ch) + self._channel_label = QLabel("Канал:") + effect_form.addRow(self._channel_label, self._channel_combo) + + # Список эффектов из конфига + self._effect_combo = QComboBox() + effect_form.addRow("Эффект:", self._effect_combo) + + # Длительность (только для has_duration = true) + self._duration_spin = QSpinBox() + self._duration_spin.setRange(1, 99) + self._duration_spin.setValue(5) + self._duration_spin.setSuffix(" сек") + self._duration_label = QLabel("Длительность:") + effect_form.addRow(self._duration_label, self._duration_spin) + + # Ручной код эффекта + self._custom_code_spin = QSpinBox() + self._custom_code_spin.setRange(0, 99) + self._custom_code_spin.setValue(10) + effect_form.addRow("Ручной код эффекта:", self._custom_code_spin) + + # Кнопки отправки + btn_row = QHBoxLayout() + send_effect_btn = QPushButton("Отправить эффект") + send_effect_btn.setProperty("role", "primary") + send_effect_btn.setIcon(icon("fa5s.paper-plane", color="#ecfdf5")) + send_effect_btn.clicked.connect(self._send_effect) + btn_row.addWidget(send_effect_btn) + + send_custom_btn = QPushButton("Ручной код") + send_custom_btn.setIcon(icon("fa5s.paper-plane", color=ICON_COLOR_MUTED)) + send_custom_btn.clicked.connect(self._send_custom_effect) + btn_row.addWidget(send_custom_btn) + + # Кнопка «выключить» (base_command без параметра) + self._stop_btn = QPushButton("Выключить эффекты") + self._stop_btn.setIcon(icon("fa5s.stop", color=ICON_COLOR_MUTED)) + self._stop_btn.clicked.connect(self._send_stop) + btn_row.addWidget(self._stop_btn) + + effect_form.addRow(btn_row) + root.addWidget(effect_group) + + # ── Цветовое чередование ── + color_group = QGroupBox("Цветовое чередование (cmd 89)") + color_root = QVBoxLayout(color_group) + + color_row1 = QHBoxLayout() + self._color_order_combo = QComboBox() + for code, name in _COLOR_ORDERS: + self._color_order_combo.addItem(f"{code} — {name}", code) + color_row1.addWidget(self._color_order_combo) + + set_color_btn = QPushButton("Применить") + set_color_btn.setProperty("role", "primary") + set_color_btn.setIcon(icon("fa5s.palette", color="#ecfdf5")) + set_color_btn.clicked.connect(self._send_color_order) + color_row1.addWidget(set_color_btn) + color_root.addLayout(color_row1) + + color_row2 = QHBoxLayout() + self._color_test_btn = QPushButton("Тест цвета") + self._color_test_btn.setProperty("role", "primary") + self._color_test_btn.setIcon(icon("fa5s.eye", color="#ecfdf5")) + self._color_test_btn.clicked.connect(self._run_color_test) + color_row2.addWidget(self._color_test_btn) + + color_hint = QLabel("Должны вывестись: красный → зелёный → синий") + color_hint.setStyleSheet("color: #9ca3af; font-size: 12px;") + color_row2.addWidget(color_hint) + color_row2.addStretch() + color_root.addLayout(color_row2) + + root.addWidget(color_group) + + # ── Лог ── + self._log = QLabel("") + self._log.setObjectName("LogLabel") + self._log.setWordWrap(True) + root.addWidget(self._log) + + # ── Закрыть ── + btn_layout = QHBoxLayout() + btn_layout.addStretch() + close_btn = QPushButton("Закрыть") + close_btn.setIcon(icon("fa5s.times", color=ICON_COLOR_MUTED)) + close_btn.clicked.connect(self.close) + btn_layout.addWidget(close_btn) + root.addLayout(btn_layout) + + # Инициализация состояния + self._on_project_changed() + + # ── Переключение проектов ── + + def _current_mode(self) -> LightMode | None: + return self._project_combo.currentData() + + def _on_project_changed(self) -> None: + """Обновить список эффектов и видимость элементов при смене проекта.""" + mode = self._current_mode() + self._effect_combo.clear() + if mode is None: + return + + for eff in mode.effects: + self._effect_combo.addItem(f"{eff.code:02d} — {eff.name}", eff.code) + + # Канал — только для простых команд (base_command = "8") + is_channel_mode = mode.base_command == "8" + self._channel_combo.setVisible(is_channel_mode) + self._channel_label.setVisible(is_channel_mode) + + # Длительность — только для has_duration + self._duration_spin.setVisible(mode.has_duration) + self._duration_label.setVisible(mode.has_duration) + + # Кнопка «выключить» — только для has_duration + self._stop_btn.setVisible(mode.has_duration) + + def _reload_modes(self) -> None: + """Перечитать .lm файлы из папки.""" + current_name = self._project_combo.currentText() + self._load_modes() + self._project_combo.clear() + for mode in self._modes: + self._project_combo.addItem(mode.project_name, mode) + # Попробуем восстановить выбор + idx = self._project_combo.findText(current_name) + if idx >= 0: + self._project_combo.setCurrentIndex(idx) + self._log.setText(f"Загружено {len(self._modes)} конфигураций") + + def _open_modes_dir(self) -> None: + """Открыть папку light_modes/ в проводнике.""" + modes_dir = get_light_modes_dir() + if not modes_dir.exists(): + modes_dir.mkdir(parents=True, exist_ok=True) + if sys.platform == "win32": + os.startfile(str(modes_dir)) # noqa: S606 + elif sys.platform == "darwin": + subprocess.Popen(["open", str(modes_dir)]) # noqa: S603, S607 + else: + subprocess.Popen(["xdg-open", str(modes_dir)]) # noqa: S603, S607 + + # ── Построение команды ── + + def _build_command(self, effect_code: int) -> str: + """Собрать команду на основе текущего проекта.""" + mode = self._current_mode() + if mode is None: + return "" + + if mode.has_duration: + # Формат: base_command + XX (код) + YY (время) + duration = self._duration_spin.value() + return f"{mode.base_command}{effect_code:02d}{duration:02d}" + else: + # Формат: base_command + X (канал) + YY (код) + channel = self._channel_combo.currentData() or 0 + return f"{mode.base_command}{channel}{effect_code:02d}" + + # ── Отправка ── + + def _send_effect(self) -> None: + code = self._effect_combo.currentData() + if code is None: + return + cmd = self._build_command(code) + self._send(cmd) + + def _send_custom_effect(self) -> None: + code = self._custom_code_spin.value() + cmd = self._build_command(code) + self._send(cmd) + + def _send_stop(self) -> None: + """Отправить base_command без параметра (отключить эффекты).""" + mode = self._current_mode() + if mode is None: + return + self._send(mode.base_command) + + def _send_color_order(self) -> None: + code = self._color_order_combo.currentData() + cmd = f"8900{code}" + self._send(cmd) + + def _run_color_test(self) -> None: + """Последовательно отправить 8003→8004→8005→8002→8000 с паузой 300 мс.""" + self._color_test_cmds = ["8003", "8004", "8005", "8002", "8000"] + self._color_test_idx = 0 + self._color_test_btn.setEnabled(False) + + self._color_test_timer = QTimer(self) + self._color_test_timer.setInterval(300) + self._color_test_timer.timeout.connect(self._color_test_step) + self._color_test_step() # отправить первую команду сразу + self._color_test_timer.start() + + def _color_test_step(self) -> None: + if self._color_test_idx >= len(self._color_test_cmds): + self._color_test_timer.stop() + self._color_test_btn.setEnabled(True) + return + cmd = self._color_test_cmds[self._color_test_idx] + self._color_test_idx += 1 + self._send(cmd) + + def _send(self, cmd: str) -> None: + if not cmd: + return + if not self._conn.is_connected: + QMessageBox.warning(self, "Ошибка", "Порт не подключён.") + return + try: + self._conn.send_command(cmd) + self._log.setText(f"→ {cmd}") + resp = self._conn.read_response(timeout=0.3) + self._conn.release() + if resp: + self._log.setText(f"→ {cmd}\n← {resp}") + except Exception as exc: + self._conn.release() + self._log.setText(f"Ошибка: {exc}") diff --git a/app/widgets/dialogs/output_test_dialog.py b/app/widgets/dialogs/output_test_dialog.py new file mode 100644 index 0000000..7f4bc02 --- /dev/null +++ b/app/widgets/dialogs/output_test_dialog.py @@ -0,0 +1,169 @@ +"""Диалог «Тест силовых выходов» — управление 8 силовыми выходами платы. + +Команда 21XY: + X — индекс выхода (0-7) + Y — действие: 0=выключить, 1=включить, 2=инвертировать +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QVBoxLayout, +) + +from ...services.com_service import ComConnection +from ...ui.icons import ICON_COLOR_DARK, ICON_COLOR_MUTED, icon +from .styled_dialog import StyledDialog + +_NUM_OUTPUTS = 8 + + +class OutputTestDialog(StyledDialog): + """Диалог для тестирования силовых выходов платы (cmd 21XY).""" + + def __init__(self, connection: ComConnection, *, parent=None) -> None: + super().__init__(parent) + self._conn = connection + self.setWindowTitle("Тест силовых выходов") + self.setMinimumWidth(500) + self._build_ui() + + # ── UI ────────────────────────────────────────────────────────────── + + def _build_ui(self) -> None: + root = self.containerLayout() + root.setSpacing(12) + + # ── Описание ── + desc = QLabel( + "Управление силовыми выходами платы (команда 21XY).\n" + "X — индекс выхода (0–7), Y — действие: 1=вкл, 0=выкл, 2=инверсия." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #64748b; font-size: 12px; margin-bottom: 4px;") + root.addWidget(desc) + + # ── Сетка выходов ── + outputs_group = QGroupBox("Силовые выходы") + grid = QGridLayout(outputs_group) + grid.setSpacing(6) + + # Заголовки столбцов + headers = ["Выход", "Включить", "Выключить", "Инвертировать"] + for col, header in enumerate(headers): + lbl = QLabel(f"{header}") + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + grid.addWidget(lbl, 0, col) + + self._state_labels: list[QLabel] = [] + + for i in range(_NUM_OUTPUTS): + row = i + 1 + + # Номер выхода + num_label = QLabel(f" Выход {i + 1} ") + num_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + num_label.setStyleSheet( + "background: #f1f5f9; border: 1px solid #cbd5e1; " + "border-radius: 6px; padding: 4px 8px; font-weight: 600;" + ) + grid.addWidget(num_label, row, 0) + + # Включить + on_btn = QPushButton("Вкл") + on_btn.setProperty("role", "primary") + on_btn.setIcon(icon("fa5s.toggle-on", color="#ecfdf5")) + on_btn.setMinimumHeight(30) + on_btn.clicked.connect(lambda checked=False, idx=i: self._set_output(idx, 1)) + grid.addWidget(on_btn, row, 1) + + # Выключить + off_btn = QPushButton("Выкл") + off_btn.setIcon(icon("fa5s.toggle-off", color=ICON_COLOR_DARK)) + off_btn.setMinimumHeight(30) + off_btn.clicked.connect(lambda checked=False, idx=i: self._set_output(idx, 0)) + grid.addWidget(off_btn, row, 2) + + # Инвертировать + inv_btn = QPushButton("Инверт") + inv_btn.setIcon(icon("fa5s.exchange-alt", color=ICON_COLOR_DARK)) + inv_btn.setMinimumHeight(30) + inv_btn.clicked.connect(lambda checked=False, idx=i: self._set_output(idx, 2)) + grid.addWidget(inv_btn, row, 3) + + root.addWidget(outputs_group) + + # ── Общие действия ── + bulk_layout = QHBoxLayout() + bulk_layout.addStretch() + + all_on_btn = QPushButton("Включить все") + all_on_btn.setProperty("role", "primary") + all_on_btn.setIcon(icon("fa5s.bolt", color="#ecfdf5")) + all_on_btn.clicked.connect(lambda: self._set_all(1)) + bulk_layout.addWidget(all_on_btn) + + all_off_btn = QPushButton("Выключить все") + all_off_btn.setIcon(icon("fa5s.power-off", color=ICON_COLOR_DARK)) + all_off_btn.clicked.connect(lambda: self._set_all(0)) + bulk_layout.addWidget(all_off_btn) + + root.addLayout(bulk_layout) + + # ── Лог ── + self._log = QLabel("") + self._log.setObjectName("LogLabel") + self._log.setWordWrap(True) + root.addWidget(self._log) + + # ── Закрыть ── + btn_layout = QHBoxLayout() + btn_layout.addStretch() + close_btn = QPushButton("Закрыть") + close_btn.setIcon(icon("fa5s.times", color=ICON_COLOR_MUTED)) + close_btn.clicked.connect(self.close) + btn_layout.addWidget(close_btn) + root.addLayout(btn_layout) + + # ── Логика ────────────────────────────────────────────────────────── + + def _set_output(self, index: int, action: int) -> None: + """Отправить команду 21XY для одного выхода.""" + cmd = f"21{index}{action}" + self._send(cmd) + + def _set_all(self, action: int) -> None: + """Включить/выключить все 8 выходов через команду 20X. + + Команда 20X задаёт состояние всех выходов двоичной маской X. + Все включить: 20255 (0xFF), все выключить: 200. + """ + if action == 1: + cmd = "20255" + else: + cmd = "200" + self._send(cmd) + + def _send(self, cmd: str) -> None: + if not cmd: + return + if not self._conn.is_connected: + QMessageBox.warning(self, "Ошибка", "Порт не подключён.") + return + try: + self._conn.send_command(cmd) + self._log.setText(f"→ {cmd}") + resp = self._conn.read_response(timeout=0.3) + self._conn.release() + if resp: + self._log.setText(f"→ {cmd}\n← {resp}") + except Exception as exc: + self._conn.release() + self._log.setText(f"Ошибка: {exc}") diff --git a/app/widgets/dialogs/role_dialog.py b/app/widgets/dialogs/role_dialog.py new file mode 100644 index 0000000..26d3209 --- /dev/null +++ b/app/widgets/dialogs/role_dialog.py @@ -0,0 +1,110 @@ +"""Диалог «Задание роли» — чтение и установка пользовательской роли контроллера. + +Команды: + 97 — прочитать роль → ROLE : <значение> + 96 — задать роль (до 20 символов) +""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QVBoxLayout, +) + +from ...services.com_service import ComConnection +from ...ui.icons import ICON_COLOR_DARK, ICON_COLOR_MUTED, icon +from .styled_dialog import StyledDialog + + +class RoleDialog(StyledDialog): + def __init__(self, connection: ComConnection, parent=None) -> None: + super().__init__(parent) + self._conn = connection + self.setWindowTitle("Задание роли") + self.setMinimumWidth(400) + self._setup_ui() + self._read_role() + + def _setup_ui(self) -> None: + root = self.containerLayout() + root.setSpacing(12) + + # ── Текущая роль ── + current_layout = QHBoxLayout() + current_layout.addWidget(QLabel("Текущая роль:")) + self._current_label = QLabel("—") + self._current_label.setStyleSheet("font-weight: 700; font-size: 14px;") + current_layout.addWidget(self._current_label, 1) + + read_btn = QPushButton("Обновить") + read_btn.setIcon(icon("fa5s.sync-alt", color=ICON_COLOR_DARK)) + read_btn.clicked.connect(self._read_role) + current_layout.addWidget(read_btn) + root.addLayout(current_layout) + + # ── Установка новой роли ── + set_layout = QHBoxLayout() + set_layout.addWidget(QLabel("Новая роль:")) + self._role_input = QLineEdit() + self._role_input.setMaxLength(20) + self._role_input.setPlaceholderText("до 20 символов, напр. left, right, cabinet…") + set_layout.addWidget(self._role_input, 1) + + set_btn = QPushButton("Задать") + set_btn.setProperty("role", "primary") + set_btn.setIcon(icon("fa5s.save", color="#ecfdf5")) + set_btn.clicked.connect(self._set_role) + set_layout.addWidget(set_btn) + root.addLayout(set_layout) + + # ── Закрыть ── + btn_layout = QHBoxLayout() + btn_layout.addStretch() + close_btn = QPushButton("Закрыть") + close_btn.setIcon(icon("fa5s.times", color=ICON_COLOR_MUTED)) + close_btn.clicked.connect(self.close) + btn_layout.addWidget(close_btn) + root.addLayout(btn_layout) + + def _read_role(self) -> None: + if not self._conn.is_connected: + return + try: + self._conn.send_command("97") + resp = self._conn.read_response(timeout=0.5) + self._conn.release() + if resp and "ROLE" in resp: + # формат: "ROLE : <значение>" + role = resp.split(":", 1)[-1].strip() if ":" in resp else resp + self._current_label.setText(role or "(пусто)") + else: + self._current_label.setText(resp or "—") + except Exception: + self._conn.release() + self._current_label.setText("ошибка") + + def _set_role(self) -> None: + text = self._role_input.text().strip() + if not text: + QMessageBox.warning(self, "Роль", "Введите значение роли.") + return + if len(text) > 20: + QMessageBox.warning(self, "Роль", "Роль должна быть не длиннее 20 символов.") + return + if not self._conn.is_connected: + QMessageBox.warning(self, "Ошибка", "Порт не подключён.") + return + try: + self._conn.send_command(f"96{text}") + resp = self._conn.read_response(timeout=0.5) + self._conn.release() + QMessageBox.information(self, "Роль", f"Ответ: {resp or 'OK'}") + self._read_role() + except Exception as exc: + self._conn.release() + QMessageBox.critical(self, "Ошибка", str(exc)) diff --git a/app/widgets/dialogs/settings_dialog.py b/app/widgets/dialogs/settings_dialog.py new file mode 100644 index 0000000..7da6589 --- /dev/null +++ b/app/widgets/dialogs/settings_dialog.py @@ -0,0 +1,148 @@ +"""Settings dialog — accent colour and light / dark mode.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QButtonGroup, + QHBoxLayout, + QLabel, + QPushButton, + QToolButton, + QVBoxLayout, + QWidget, +) + +from ...services import settings_service +from ...ui.icons import icon +from ...ui.theme import build_stylesheet, get_palette +from .styled_dialog import StyledDialog + + +class SettingsDialog(StyledDialog): + """Compact settings dialog: accent colour + light/dark toggle.""" + + theme_changed = Signal() + + _ACCENT_COLORS = { + "green": "#16a34a", + "blue": "#2563eb", + } + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("Настройки приложения") + self.setMinimumWidth(300) + self.resize(340, 280) + + lay = self.containerLayout() + lay.setSpacing(16) + + # ── Accent colour ────────────────────────────────────────── + accent_title = QLabel("Цвет акцента") + accent_title.setObjectName("SectionTitle") + lay.addWidget(accent_title) + + accent_row = QHBoxLayout() + accent_row.setSpacing(10) + accent_row.setAlignment(Qt.AlignmentFlag.AlignLeft) + + self._accent_group = QButtonGroup(self) + self._accent_group.setExclusive(True) + + current_accent = settings_service.theme_accent() + + for name, color in self._ACCENT_COLORS.items(): + swatch = QToolButton() + swatch.setObjectName("AccentSwatch") + swatch.setCheckable(True) + swatch.setChecked(name == current_accent) + swatch.setFixedSize(32, 32) + swatch.setCursor(Qt.CursorShape.PointingHandCursor) + swatch.setStyleSheet( + f"QToolButton#AccentSwatch {{ background: {color}; }}" + ) + swatch.setToolTip(name.capitalize()) + swatch.setProperty("accent_name", name) + self._accent_group.addButton(swatch) + accent_row.addWidget(swatch) + + lay.addLayout(accent_row) + + # ── Light / Dark toggle ──────────────────────────────────── + mode_title = QLabel("Тема оформления") + mode_title.setObjectName("SectionTitle") + lay.addWidget(mode_title) + + mode_row = QHBoxLayout() + mode_row.setSpacing(8) + mode_row.setAlignment(Qt.AlignmentFlag.AlignLeft) + + current_mode = settings_service.theme_mode() + + self._light_btn = QPushButton(" Светлая") + self._light_btn.setCheckable(True) + self._light_btn.setChecked(current_mode == "light") + self._light_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._light_btn.setMinimumWidth(120) + light_ico = icon("fa5s.sun", color="#f59e0b") + if not light_ico.isNull(): + self._light_btn.setIcon(light_ico) + + self._dark_btn = QPushButton(" Тёмная") + self._dark_btn.setCheckable(True) + self._dark_btn.setChecked(current_mode == "dark") + self._dark_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._dark_btn.setMinimumWidth(120) + dark_ico = icon("fa5s.moon", color="#6366f1") + if not dark_ico.isNull(): + self._dark_btn.setIcon(dark_ico) + + self._mode_group = QButtonGroup(self) + self._mode_group.setExclusive(True) + self._mode_group.addButton(self._light_btn) + self._mode_group.addButton(self._dark_btn) + + mode_row.addWidget(self._light_btn) + mode_row.addWidget(self._dark_btn) + lay.addLayout(mode_row) + + # ── Apply button ─────────────────────────────────────────── + lay.addSpacing(8) + + self._apply_btn = QPushButton(" Применить") + self._apply_btn.setProperty("role", "primary") + self._apply_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._apply_btn.setMinimumHeight(36) + apply_ico = icon("fa5s.check", color="#ffffff") + if not apply_ico.isNull(): + self._apply_btn.setIcon(apply_ico) + self._apply_btn.clicked.connect(self._on_apply) + lay.addWidget(self._apply_btn) + + # ── Handlers ─────────────────────────────────────────────────── + + def _selected_accent(self) -> str: + for btn in self._accent_group.buttons(): + if btn.isChecked(): + return str(btn.property("accent_name")) + return "green" + + def _selected_mode(self) -> str: + return "dark" if self._dark_btn.isChecked() else "light" + + def _on_apply(self) -> None: + mode = self._selected_mode() + accent = self._selected_accent() + + settings_service.put("theme_mode", mode) + settings_service.put("theme_accent", accent) + + from PySide6.QtWidgets import QApplication + app = QApplication.instance() + if isinstance(app, QApplication): + palette = get_palette(mode, accent) + app.setStyleSheet(build_stylesheet(palette)) + + self.theme_changed.emit() + self.close() diff --git a/app/widgets/dialogs/styled_dialog.py b/app/widgets/dialogs/styled_dialog.py new file mode 100644 index 0000000..7e99209 --- /dev/null +++ b/app/widgets/dialogs/styled_dialog.py @@ -0,0 +1,133 @@ +"""Базовый класс для стилизованных диалоговых окон. + +Убирает системную рамку, делает фон прозрачным, +добавляет контейнер с зелёной обводкой и скруглёнными углами, +мини-заголовок с кнопкой закрытия и перетаскиванием окна. +""" + +from __future__ import annotations + +from PySide6.QtCore import QSize, Qt +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QDialog, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QToolButton, + QVBoxLayout, + QWidget, +) + +from ...ui.icons import ICON_COLOR_DANGER, icon + + +class StyledDialog(QDialog): + """QDialog без системной рамки, с обводкой, заголовком, кнопкой закрытия и ресайзом.""" + + def __init__(self, parent: QWidget | None = None, **kwargs) -> None: + super().__init__(parent, **kwargs) + self.setWindowFlags( + Qt.WindowType.Dialog + | Qt.WindowType.FramelessWindowHint + ) + self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) + self.setMouseTracking(True) + + # Внешний layout + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + # Внутренний контейнер, к которому применяются стили + self._container = QWidget(self) + self._container.setObjectName("DialogContainer") + self._container.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self._container.setMouseTracking(True) + + outer.addWidget(self._container) + + container_layout = QVBoxLayout(self._container) + container_layout.setContentsMargins(0, 0, 0, 0) + container_layout.setSpacing(0) + + # ── Мини-заголовок ────────────────────────────────────────── + self._title_bar = QWidget(self._container) + self._title_bar.setObjectName("DialogTitleBar") + self._title_bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self._title_bar.setFixedHeight(32) + + tb_layout = QHBoxLayout(self._title_bar) + tb_layout.setContentsMargins(10, 4, 6, 4) + tb_layout.setSpacing(4) + + self._title_label = QLabel() + self._title_label.setObjectName("DialogTitle") + tb_layout.addWidget(self._title_label, 1) + + self._close_btn = self._build_window_button() + self._close_btn.clicked.connect(self.close) + tb_layout.addWidget(self._close_btn) + + container_layout.addWidget(self._title_bar) + + # Область содержимого (sub-layout, доступен через containerLayout()) + self._body = QWidget(self._container) + self._body_layout = QVBoxLayout(self._body) + self._body_layout.setContentsMargins(12, 8, 12, 12) + container_layout.addWidget(self._body, 1) + + self._drag_offset = None + + # ── Публичные методы ──────────────────────────────────────────── + + def setWindowTitle(self, title: str) -> None: # noqa: N802 + super().setWindowTitle(title) + self._title_label.setText(title) + + def containerLayout(self) -> QVBoxLayout: + """Вернуть layout области содержимого (под заголовком).""" + return self._body_layout + + # ── Построение кнопки ─────────────────────────────────────────── + + @staticmethod + def _build_window_button() -> QToolButton: + button = QToolButton() + button.setProperty("windowControl", "close") + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setFixedSize(24, 20) + button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + + close_icon = icon("fa5s.times", color=ICON_COLOR_DANGER) + if not close_icon.isNull(): + button.setIcon(close_icon) + else: + button.setText("×") + button.setIconSize(QSize(10, 10)) + + shadow = QGraphicsDropShadowEffect(button) + shadow.setBlurRadius(10) + shadow.setOffset(0, 2) + shadow.setColor(QColor(0, 0, 0, 80)) + button.setGraphicsEffect(shadow) + + return button + + # ── Перетаскивание ─────────────────────────────────────────────── + + def mousePressEvent(self, event) -> None: # type: ignore[override] + if event.button() == Qt.MouseButton.LeftButton: + self._drag_offset = ( + event.globalPosition().toPoint() - self.frameGeometry().topLeft() + ) + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: # type: ignore[override] + if event.buttons() & Qt.MouseButton.LeftButton and self._drag_offset is not None: + self.move(event.globalPosition().toPoint() - self._drag_offset) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: # type: ignore[override] + self._drag_offset = None + super().mouseReleaseEvent(event) diff --git a/app/widgets/start_tab.py b/app/widgets/start_tab.py new file mode 100644 index 0000000..e6a8772 --- /dev/null +++ b/app/widgets/start_tab.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QFrame, + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, + QWidget, +) + +from ..ui.icons import ICON_COLOR_DARK, ICON_COLOR_PRIMARY, icon +from .dialogs.commands_dialog import CommandsDialog +from .dialogs.settings_dialog import SettingsDialog + + +class StartTab(QWidget): + def __init__(self, on_new_connection) -> None: + super().__init__() + layout = QVBoxLayout(self) + layout.setContentsMargins(26, 26, 26, 26) + layout.addStretch(1) + + hero_card = QFrame() + hero_card.setObjectName("HeroCard") + hero_card.setMaximumWidth(560) + + shadow = QGraphicsDropShadowEffect(self) + shadow.setBlurRadius(28) + shadow.setOffset(0, 8) + shadow.setColor(QColor(20, 30, 50, 45)) + hero_card.setGraphicsEffect(shadow) + + hero_layout = QVBoxLayout(hero_card) + hero_layout.setContentsMargins(28, 28, 28, 28) + hero_layout.setSpacing(14) + + badge = QLabel("Plate Config") + badge.setObjectName("HeroBadge") + badge.setAlignment(Qt.AlignmentFlag.AlignCenter) + hero_layout.addWidget(badge, alignment=Qt.AlignmentFlag.AlignHCenter) + + title = QLabel("Подключение к COM-плате") + title.setObjectName("HeroTitle") + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + hero_layout.addWidget(title) + + hint = QLabel("Откройте новое соединение и настройте параметры порта для работы с платой.") + hint.setObjectName("HeroHint") + hint.setAlignment(Qt.AlignmentFlag.AlignCenter) + hint.setWordWrap(True) + hero_layout.addWidget(hint) + + btn = QPushButton("Новое соединение") + btn.setProperty("role", "primary") + btn.setMinimumWidth(210) + btn.setMinimumHeight(40) + btn.setIcon(icon("fa5s.plus-circle", color=ICON_COLOR_PRIMARY)) + btn.clicked.connect(on_new_connection) + hero_layout.addWidget(btn, alignment=Qt.AlignmentFlag.AlignHCenter) + + card_wrap = QHBoxLayout() + card_wrap.addStretch(1) + card_wrap.addWidget(hero_card) + card_wrap.addStretch(1) + layout.addLayout(card_wrap) + + # ── Кнопки «Команды платы» и «Настройки» ─────────────────── + bottom_row = QHBoxLayout() + bottom_row.setSpacing(12) + bottom_row.addStretch(1) + + commands_btn = QPushButton("Команды платы") + commands_btn.setMinimumHeight(38) + commands_btn.setMinimumWidth(180) + commands_btn.setIcon(icon("fa5s.book", color=ICON_COLOR_DARK)) + commands_btn.clicked.connect(self._open_commands) + bottom_row.addWidget(commands_btn) + + settings_btn = QPushButton("Настройки приложения") + settings_btn.setMinimumHeight(38) + settings_btn.setMinimumWidth(180) + settings_btn.setIcon(icon("fa5s.cog", color=ICON_COLOR_DARK)) + settings_btn.clicked.connect(self._open_settings) + bottom_row.addWidget(settings_btn) + + bottom_row.addStretch(1) + layout.addLayout(bottom_row) + + layout.addStretch(2) + + # ── Handlers ────────────────────────────────────────────────── + + def _open_commands(self) -> None: + dlg = CommandsDialog(self) + dlg.exec() + + def _open_settings(self) -> None: + dlg = SettingsDialog(self) + dlg.exec() diff --git a/app/widgets/title_bar.py b/app/widgets/title_bar.py new file mode 100644 index 0000000..6d8721a --- /dev/null +++ b/app/widgets/title_bar.py @@ -0,0 +1,186 @@ +from __future__ import annotations + +from PySide6.QtCore import QEvent, Qt, QSize +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QGraphicsDropShadowEffect, + QHBoxLayout, + QLabel, + QTabBar, + QToolButton, + QWidget, +) + +from ..ui.icons import ICON_COLOR_PRIMARY, ICON_COLOR_DANGER, icon + + +class TitleBar(QWidget): + def __init__(self, window: QWidget) -> None: + super().__init__(window) + self._window = window + self._drag_offset = None + + self.setObjectName("TitleBar") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self.setAutoFillBackground(True) + self.setFixedHeight(38) + + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 4, 6, 4) + layout.setSpacing(8) + self._layout = layout + + self.icon_label = QLabel() + self.icon_label.setObjectName("TitleBarIcon") + self.icon_label.setFixedSize(16, 16) + layout.addWidget(self.icon_label) + + self.title_label = QLabel() + self.title_label.setObjectName("TitleBarTitle") + layout.addWidget(self.title_label) + + self.tabs_container = QWidget(self) + self.tabs_container.setObjectName("TitleTabsContainer") + self.tabs_layout = QHBoxLayout(self.tabs_container) + self.tabs_layout.setContentsMargins(0, 0, 0, 0) + self.tabs_layout.setSpacing(0) + layout.addWidget(self.tabs_container, 1) + + self.min_btn = self._build_window_button("—", "minimize") + self.min_btn.clicked.connect(self._window.showMinimized) + layout.addWidget(self.min_btn) + + self.max_btn = self._build_window_button("□", "maximize") + self.max_btn.clicked.connect(self._toggle_max_restore) + layout.addWidget(self.max_btn) + + self.close_btn = self._build_window_button("×", "close") + self.close_btn.clicked.connect(self._window.close) + layout.addWidget(self.close_btn) + + self._window.installEventFilter(self) + self._sync_window_meta() + + def _build_window_button(self, text: str, role: str) -> QToolButton: + button = QToolButton(self) + button.setText(text) + button.setProperty("windowControl", role) + button.setCursor(Qt.CursorShape.PointingHandCursor) + button.setFixedSize(30, 24) + button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + + if role == "minimize": + minimize_icon = icon("fa5s.minus", color=ICON_COLOR_PRIMARY) + if not minimize_icon.isNull(): + button.setIcon(minimize_icon) + button.setText("") + elif role == "maximize": + maximize_icon = icon("fa5s.square", color=ICON_COLOR_PRIMARY) + if not maximize_icon.isNull(): + button.setIcon(maximize_icon) + button.setText("") + elif role == "close": + close_icon = icon("fa5s.times", color=ICON_COLOR_DANGER) + if not close_icon.isNull(): + button.setIcon(close_icon) + button.setText("") + + button.setIconSize(QSize(12, 12)) + + shadow = QGraphicsDropShadowEffect(button) + shadow.setBlurRadius(10) + shadow.setOffset(0, 2) + shadow.setColor(QColor(0, 0, 0, 80)) + button.setGraphicsEffect(shadow) + + return button + + def _sync_window_meta(self) -> None: + self.title_label.setText(self._window.windowTitle()) + window_icon = self._window.windowIcon() + if not window_icon.isNull(): + self.icon_label.setPixmap(window_icon.pixmap(16, 16)) + max_icon = icon( + "fa5s.clone" if self._window.isMaximized() else "fa5s.square", + color=ICON_COLOR_PRIMARY, + ) + if not max_icon.isNull(): + self.max_btn.setIcon(max_icon) + self.max_btn.setText("") + else: + self.max_btn.setText("❐" if self._window.isMaximized() else "□") + + def _toggle_max_restore(self) -> None: + if self._window.isMaximized(): + self._window.showNormal() + else: + self._window.showMaximized() + self._sync_window_meta() + + def set_tabs_widget(self, widget: QWidget) -> None: + while self.tabs_layout.count(): + item = self.tabs_layout.takeAt(0) + if item is not None: + old_widget = item.widget() + if old_widget is not None: + old_widget.setParent(None) + self.tabs_layout.addWidget(widget) + # Перехватываем события мыши на QTabBar для перетаскивания + # за пустую область вкладок + widget.installEventFilter(self) + + def eventFilter(self, watched, event): # type: ignore[override] + if watched is self._window and event.type() in ( + QEvent.Type.WindowTitleChange, + QEvent.Type.WindowIconChange, + QEvent.Type.WindowStateChange, + ): + self._sync_window_meta() + + # Перетаскивание окна за пустую область QTabBar + if isinstance(watched, QTabBar): + etype = event.type() + if etype == QEvent.Type.MouseButtonPress: + if event.button() == Qt.MouseButton.LeftButton: + if watched.tabAt(event.position().toPoint()) == -1: + self._drag_offset = ( + event.globalPosition().toPoint() + - self._window.frameGeometry().topLeft() + ) + return True + elif etype == QEvent.Type.MouseMove: + if event.buttons() & Qt.MouseButton.LeftButton and self._drag_offset is not None: + if not self._window.isMaximized(): + self._window.move(event.globalPosition().toPoint() - self._drag_offset) + return True + elif etype == QEvent.Type.MouseButtonRelease: + if self._drag_offset is not None: + self._drag_offset = None + return True + elif etype == QEvent.Type.MouseButtonDblClick: + if event.button() == Qt.MouseButton.LeftButton: + if watched.tabAt(event.position().toPoint()) == -1: + self._toggle_max_restore() + return True + + return super().eventFilter(watched, event) + + def mousePressEvent(self, event) -> None: # type: ignore[override] + if event.button() == Qt.MouseButton.LeftButton: + self._drag_offset = event.globalPosition().toPoint() - self._window.frameGeometry().topLeft() + super().mousePressEvent(event) + + def mouseMoveEvent(self, event) -> None: # type: ignore[override] + if event.buttons() & Qt.MouseButton.LeftButton and self._drag_offset is not None: + if not self._window.isMaximized(): + self._window.move(event.globalPosition().toPoint() - self._drag_offset) + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event) -> None: # type: ignore[override] + self._drag_offset = None + super().mouseReleaseEvent(event) + + def mouseDoubleClickEvent(self, event) -> None: # type: ignore[override] + if event.button() == Qt.MouseButton.LeftButton: + self._toggle_max_restore() + super().mouseDoubleClickEvent(event) diff --git a/build_exe.bat b/build_exe.bat new file mode 100644 index 0000000..36cd6d7 --- /dev/null +++ b/build_exe.bat @@ -0,0 +1,5 @@ +@echo off +chcp 65001 >nul 2>&1 +setlocal +powershell -ExecutionPolicy Bypass -File "%~dp0build_exe.ps1" %* +pause diff --git a/build_exe.ps1 b/build_exe.ps1 new file mode 100644 index 0000000..752c281 --- /dev/null +++ b/build_exe.ps1 @@ -0,0 +1,67 @@ +param( + [switch]$InstallBuildDeps, + [switch]$CleanOnly +) + +$ErrorActionPreference = "Stop" + +$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $projectRoot + +# ── Очистка артефактов сборки ────────────────────────────────────── +function Clean-Build { + $dirs = @("build", "dist") + foreach ($d in $dirs) { + $path = Join-Path $projectRoot $d + if (Test-Path $path) { + Remove-Item $path -Recurse -Force + Write-Host " Удалено: $d" + } + } + # __pycache__ в проекте (без .venv) + Get-ChildItem -Path $projectRoot -Directory -Filter "__pycache__" -Recurse | + Where-Object { $_.FullName -notlike "*\.venv\*" } | + ForEach-Object { + Remove-Item $_.FullName -Recurse -Force + Write-Host " Удалено: $($_.FullName -replace [regex]::Escape($projectRoot), '.')" + } + Write-Host "Очистка завершена." +} + +Write-Host "=== Очистка ===" +Clean-Build + +if ($CleanOnly) { + exit 0 +} + +# ── Виртуальное окружение ────────────────────────────────────────── +$venvPython = Join-Path $projectRoot ".venv\Scripts\python.exe" +if (-not (Test-Path $venvPython)) { + throw "Не найдено виртуальное окружение .venv. Создайте его перед сборкой." +} + +if ($InstallBuildDeps) { + Write-Host "" + Write-Host "=== Установка зависимостей ===" + & $venvPython -m pip install --upgrade pip + & $venvPython -m pip install -r requirements-build.txt +} + +# ── Сборка ───────────────────────────────────────────────────────── +Write-Host "" +Write-Host "=== Сборка EXE ===" +& $venvPython -m PyInstaller --noconfirm --clean plate_config_app.spec + +if ($LASTEXITCODE -ne 0) { + throw "PyInstaller завершился с ошибкой (код $LASTEXITCODE)." +} + +$exe = Join-Path $projectRoot "dist\plate_config_app.exe" +if (Test-Path $exe) { + $size = [math]::Round((Get-Item $exe).Length / 1MB, 1) + Write-Host "" + Write-Host "Сборка завершена: $exe ($size MB)" +} else { + throw "EXE не найден после сборки." +} diff --git a/clean.bat b/clean.bat new file mode 100644 index 0000000..926c071 --- /dev/null +++ b/clean.bat @@ -0,0 +1,5 @@ +@echo off +chcp 65001 >nul 2>&1 +setlocal +powershell -ExecutionPolicy Bypass -File "%~dp0clean.ps1" +pause diff --git a/clean.ps1 b/clean.ps1 new file mode 100644 index 0000000..6c16091 --- /dev/null +++ b/clean.ps1 @@ -0,0 +1,32 @@ +<# +.SYNOPSIS + Очистка артефактов сборки (build/, dist/, __pycache__/). +.DESCRIPTION + Удаляет папки build/, dist/ и все __pycache__/ в проекте. + Вызывается автоматически при сборке (build_exe.ps1), + но может использоваться отдельно. +#> + +$ErrorActionPreference = "Stop" +$projectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +Set-Location $projectRoot + +$dirs = @("build", "dist") +foreach ($d in $dirs) { + $path = Join-Path $projectRoot $d + if (Test-Path $path) { + Remove-Item $path -Recurse -Force + Write-Host "Удалено: $d" + } +} + +Get-ChildItem -Path $projectRoot -Directory -Filter "__pycache__" -Recurse | + Where-Object { $_.FullName -notlike "*\.venv\*" } | + ForEach-Object { + Remove-Item $_.FullName -Recurse -Force + $rel = $_.FullName -replace [regex]::Escape("$projectRoot\"), "" + Write-Host "Удалено: $rel" + } + +Write-Host "" +Write-Host "Очистка завершена." diff --git a/commands/Основные.csv b/commands/Основные.csv new file mode 100644 index 0000000..65eb4e2 --- /dev/null +++ b/commands/Основные.csv @@ -0,0 +1,30 @@ +command;name;description +00;Перезагрузка;Перезагрузить контроллер (RESET). Помещается в очередь без ожидания. +01;Отладка;Включает/выключает подробные логи. В релизе будет отключена. +02;Приём файла;Подготовиться к приёму файла обновления. Параметр — размер в байтах. Пример: 02356987. Выполняется ~15 сек, затем весь приём данных записывается в update.bin. +03;SPIFFS;Показать содержимое SPIFFS (встроенной файловой системы). +04;OTA обновление;Запустить OTA-обновление с ранее записанным файлом /update.bin. +05;Версия прошивки;Показать версию прошивки контроллера. +06;MAC-адрес;Показать MAC-адрес (без двоеточий). Уникальный, можно считать серийным номером. +07;Авторизация (запрос);Запрос авторизации от компьютера. 32 HEX-символа. Контроллер ответит встречным вызовом CHAL. +08;Авторизация (ответ);Приём ответа от компьютера на встречный вызов CHAL. 32 HEX-символа зашифрованных AES256. +09;Сброс EEPROM;Сброс EEPROM. Параметр — MAC-адрес без двоеточий, иначе не сбросится. +10;Подтверждение COIN;Подтвердить принятие сигнала COIN. Без подтверждения сигнал повторяется каждые 200 мс. +20X;Выходы (маска);Установить состояние 8 выходов двоичной маской. Пример: 203 → включить выходы 1 и 2. +21XY;Выход (один);Установить выход X в состояние Y [0/1/2(инверсия)]. Пример: 2131 → включить выход 3. +30X;Хоппер 1;301 — включить мотор, 300 — выключить, 309 — выдать приз (авторежим с контролем датчика). +40X;Хоппер 2;Аналогично хопперу 1. +8XYY;Программа LED;Выбор программы YY адресных диодов по каналу X (1..4). 80YY — на все каналы. 0 — выкл, 3 — красный, 4 — зелёный, 5 — синий. +85XXYY;Спецэффект LED;Специфичная программа эффектов. XX — код эффекта, YY — время в секундах. Пример: 854505 — ЗАРЯДКА на 5 сек. +85;Стоп рендеринг;Без параметра — отключить процесс рендеринга эффектов (освободить RAM). +89[X];Цветовой порядок;Переключить чередование цветов: 0/пусто — RGB, 1 — RBG, 2 — BRG, 3 — BGR, 4 — GRB, 5 — GBR. +90;Очистка очереди;Очистить очередь команд. +91;Входы;Запрос состояния 26 цифровых входов. Ответ: INPUT<число>. Пример: INPUT67108863 — все не нажаты. +91X;Антидребезг;Задать время антидребезга (10..100 мс). По умолчанию 50 мс. Пример: 9110. +92;ADC (однократно);Запрос значений 4 аналоговых входов. Отключает автовывод. Ответ: ADC0,0,0,0. +92X;ADC (авто);Периодический автовывод ADC каждые X мс (50..5000). Пример: 92100. +93;Счётчик монет;Запрос global_summa — нестираемый счётчик принятых монет. +96;Сохранить роль;Сохранить пользовательское значение в памяти платы (до 20 символов). Пример: 96right. +97;Показать роль;Вывод сохранённого пользовательского значения. Формат: ROLE : <значение>. +98;Состояние системы;Запрос загруженности ядер, оперативки и свободного места в памяти. +99;Статус;Запрос текущего статуса платы. diff --git a/input_mappings/Бродяги морей.mapping b/input_mappings/Бродяги морей.mapping new file mode 100644 index 0000000..b706261 --- /dev/null +++ b/input_mappings/Бродяги морей.mapping @@ -0,0 +1,51 @@ +# Пример раскладки кнопок для Морского боя VR +# Координаты: x (0..20 — столбец), y (-4..4 — строка, +Y = вверх) +# input_index — номер цифрового входа платы (0..25) + +[[buttons]] +name = "Вправо" +input_index = 0 +x = 2 +y = 1 + +[[buttons]] +name = "Вниз" +input_index = 1 +x = 1 +y = 0 + +[[buttons]] +name = "Влево" +input_index = 2 +x = 0 +y = 1 + +[[buttons]] +name = "Вверх" +input_index = 3 +x = 1 +y = 3 + +[[buttons]] +name = "Камера лево" +input_index = 4 +x = 3 +y = 4 + +[[buttons]] +name = "Камера право" +input_index = 5 +x = 4 +y = 4 + +[[buttons]] +name = "Выстрел" +input_index = 6 +x = 5 +y = 0 + +[[buttons]] +name = "Сервис" +input_index = 7 +x = 3 +y = -3 \ No newline at end of file diff --git a/input_mappings/Данро.mapping b/input_mappings/Данро.mapping new file mode 100644 index 0000000..2599cc7 --- /dev/null +++ b/input_mappings/Данро.mapping @@ -0,0 +1,45 @@ +# Пример раскладки кнопок для Морского боя VR +# Координаты: x (0..20 — столбец), y (-4..4 — строка, +Y = вверх) +# input_index — номер цифрового входа платы (0..25) + +[[buttons]] +name = "Влево" +input_index = 0 +x = 0 +y = 0 + +[[buttons]] +name = "Вправо" +input_index = 1 +x = 1 +y = 0 + +[[buttons]] +name = "Старт" +input_index = 2 +x = 2 +y = 2 + +[[buttons]] +name = "Вверх" +input_index = 3 +x = 5 +y = 1 + +[[buttons]] +name = "Активировать" +input_index = 4 +x = 4 +y = 0 + +[[buttons]] +name = "Выстрел" +input_index = 5 +x = 6 +y = 0 + +[[buttons]] +name = "Сервис" +input_index = 6 +x = 2 +y = -2 \ No newline at end of file diff --git a/input_mappings/Фишка.mapping b/input_mappings/Фишка.mapping new file mode 100644 index 0000000..0a7da5f --- /dev/null +++ b/input_mappings/Фишка.mapping @@ -0,0 +1,57 @@ +# Пример раскладки кнопок для Морского боя VR +# Координаты: x (0..20 — столбец), y (-4..4 — строка, +Y = вверх) +# input_index — номер цифрового входа платы (0..25) + +[[buttons]] +name = "Влево 1" +input_index = 0 +x = 0 +y = 0 + +[[buttons]] +name = "Вниз 1" +input_index = 1 +x = 1 +y = 1 + +[[buttons]] +name = "Вправо 1" +input_index = 2 +x = 2 +y = 0 + +[[buttons]] +name = "Старт 1и" +input_index = 3 +x = 3 +y = 3 + +[[buttons]] +name = "Старт 2и" +input_index = 4 +x = 5 +y = 3 + +[[buttons]] +name = "Влево 2" +input_index = 5 +x = 6 +y = 0 + +[[buttons]] +name = "Вниз 2" +input_index = 6 +x = 7 +y = 1 + +[[buttons]] +name = "Вправо 2" +input_index = 7 +x = 8 +y = 0 + +[[buttons]] +name = "Сервис" +input_index = 8 +x = 4 +y = -1 \ No newline at end of file diff --git a/light_modes/Базовые.lm b/light_modes/Базовые.lm new file mode 100644 index 0000000..fda4945 --- /dev/null +++ b/light_modes/Базовые.lm @@ -0,0 +1,40 @@ +# Базовые тестовые программы адресных диодов +# Формат команды: 8XYY (X — канал, YY — код эффекта) + +base_command = "8" + +[[effects]] +code = 0 +name = "Выключить (нет вывода)" + +[[effects]] +code = 1 +name = "Повторный вывод буфера" + +[[effects]] +code = 2 +name = "Погасить (нули)" + +[[effects]] +code = 3 +name = "Красный" + +[[effects]] +code = 4 +name = "Зелёный" + +[[effects]] +code = 5 +name = "Синий" + +[[effects]] +code = 6 +name = "Серый (100, 100, 100)" + +[[effects]] +code = 7 +name = "Белый" + +[[effects]] +code = 9 +name = "RGB-переливы" diff --git a/light_modes/Данро.lm b/light_modes/Данро.lm new file mode 100644 index 0000000..0d7c164 --- /dev/null +++ b/light_modes/Данро.lm @@ -0,0 +1,58 @@ +# Данро — специальные эффекты подсветки +# Формат команды: 86XXYY (XX — код эффекта, YY — время в секундах) +# Без параметра (86) — отключить рендеринг эффектов + +base_command = "86" +has_duration = true + +[[effects]] +code = 40 +name = "Горизонтальная волна" + +[[effects]] +code = 41 +name = "Вертикальная волна" + +[[effects]] +code = 42 +name = "Диагональная волна" + +[[effects]] +code = 43 +name = "Радуга" + +[[effects]] +code = 44 +name = "Волна вверх" + +[[effects]] +code = 45 +name = "Вертикальная волна вверх" + +[[effects]] +code = 46 +name = "Конечная волна" + +[[effects]] +code = 47 +name = "Синяя волна" + +[[effects]] +code = 48 +name = "Диагональная волна 2" + +[[effects]] +code = 49 +name = "Падающие ряды" + +[[effects]] +code = 50 +name = "Оранжевая волна" + +[[effects]] +code = 51 +name = "Морская волна" + +[[effects]] +code = 52 +name = "Диагональная волна 3" diff --git a/light_modes/Морской бой VR.lm b/light_modes/Морской бой VR.lm new file mode 100644 index 0000000..e4d8b03 --- /dev/null +++ b/light_modes/Морской бой VR.lm @@ -0,0 +1,116 @@ +# Морской бой VR — специальные эффекты подсветки +# Формат команды: 85XXYY (XX — код эффекта, YY — время в секундах) +# Без параметра (85) — отключить рендеринг эффектов + +base_command = "85" +has_duration = true + +# ── Базовые эффекты ── + +[[effects]] +code = 0 +name = "Горизонтальная волна" + +[[effects]] +code = 1 +name = "Вертикальная волна" + +[[effects]] +code = 2 +name = "Диагональная волна" + +[[effects]] +code = 3 +name = "Плазма" + +[[effects]] +code = 14 +name = "Ночь" + +[[effects]] +code = 5 +name = "Радуга" + +[[effects]] +code = 6 +name = "Огонь-Лёд" + +[[effects]] +code = 7 +name = "Вода" + +[[effects]] +code = 8 +name = "Звёзды" + +[[effects]] +code = 9 +name = "Сканер" + +[[effects]] +code = 10 +name = "Поток" + +[[effects]] +code = 11 +name = "Буря" + +[[effects]] +code = 12 +name = "Лёд" + +# ── Специальные эффекты ── + +[[effects]] +code = 40 +name = "СТАРТ" + +[[effects]] +code = 41 +name = "КОНЕЦ" + +[[effects]] +code = 42 +name = "ПОПАДАНИЕ" + +[[effects]] +code = 43 +name = "ВЫСТРЕЛ" + +[[effects]] +code = 44 +name = "ПРОМАХ" + +[[effects]] +code = 45 +name = "ЗАРЯДКА" + +[[effects]] +code = 46 +name = "СОНАР" + +[[effects]] +code = 47 +name = "КРАСНАЯ ВОЛНА" + +[[effects]] +code = 48 +name = "ТРЕВОГА" + +[[effects]] +code = 49 +name = "ПУЛЬСАЦИЯ" + +# ── Цвета ── + +[[effects]] +code = 80 +name = "Красный" + +[[effects]] +code = 81 +name = "Зелёный" + +[[effects]] +code = 82 +name = "Синий" diff --git a/main.py b/main.py new file mode 100644 index 0000000..1bbfac7 --- /dev/null +++ b/main.py @@ -0,0 +1,10 @@ +import faulthandler +import sys + +faulthandler.enable(file=sys.stderr) + +from app.entry import main + + +if __name__ == "__main__": + main() diff --git a/plate_config_app.spec b/plate_config_app.spec new file mode 100644 index 0000000..6c134cc --- /dev/null +++ b/plate_config_app.spec @@ -0,0 +1,54 @@ +# -*- mode: python ; coding: utf-8 -*- + +import importlib, os + +# ── Автоматический сбор данных qtawesome (шрифты + charmap) ──────── +_qta_pkg = importlib.import_module("qtawesome") +_qta_dir = os.path.dirname(_qta_pkg.__file__) + +a = Analysis( + ['main.py'], + pathex=[], + binaries=[], + datas=[ + ('light_modes', 'light_modes'), + ('input_mappings', 'input_mappings'), + ('commands', 'commands'), + (_qta_dir, 'qtawesome'), + ], + hiddenimports=[ + 'qtawesome', + 'Crypto', + 'Crypto.Cipher', + 'Crypto.Cipher._raw_aes', + 'Crypto.Util._raw_api', + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='plate_config_app', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..0b6eb1a --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pyinstaller>=6.19 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e83aae6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +PySide6>=6.7 +pyserial>=3.5 +qtawesome>=1.4 +pycryptodome>=3.20 diff --git a/Система команд платы v2.x.md b/Система команд платы v2.x.md new file mode 100644 index 0000000..c58f5d5 --- /dev/null +++ b/Система команд платы v2.x.md @@ -0,0 +1,233 @@ +#Периферия + +by: Паша Николаевич + +>[!info] Информация +>Это раздел с информацией о плате периферийного контроллера. +>Информацию о плате для витрины призов смотреть в [[Витрина призов]] + +>[!info] Общие сведения +> - Плата подключается к компьютеру по переходу USB - COM порт. +> - В системе определяется как компорт. +> - Параметры: 115200-8-N-1-нет + +***Оглавление:*** +1. [[#Система команд]] + 1. [[#Кнопка SERVICE на плате]] + 2. [[#Авторизация]] + 1. [[#Алгоритм авторизации]] +2. [[#Команды]] + 1. [[#Примечания]] + 1. [[#Служебные команды]] + 2. [[#Программы адресных диодов]] + 1. [[#Специфичные программы]] + 2. [[#Список команд]] +3. [[#Обновление]] + +# Система команд + +- Общение - строка, заканчивающаяся кодами (0xD)+(0xA), достаточно любого из них  для окончания строки +- Минимально – ДВА (2, two) символа. 00+\ – команда, а 0+\ – уже нет +- Таймаут - 100 милисекунд, если строка за это время не окончилась - идет игнорирование команды + +Команды получаемые от компьютера помещаются в очередь и выполняются последовательно, кроме команд "сброс" (00) и "очистка очереди". + +Шифрование обмена: двойное рукопожатие при помощи AES256 c фиксированным ключом. + +#### Кнопка SERVICE на плате: +- Если ее зажать и удержать при включении – выполнится сброс EEPROM +- При работе – посылает команду «SERVICE – предлагая компьютеру перейти в режим настройки игровой программы + +>[!warning] Внимание! +>Имеются два режима работы: SILENT (тихий режим) и DEVELOPER (режим разработки, с полными логами). +> +>Переключение режимов: +>Если соединить ногу GPIO_15 с землей через резистор <10кОм включается ТИХИЙ режим, и контроллер не выдает логи, а только необходимые ответы и команды. +>Также режим можно переключать командой «01» (временно, в релизе будет удалено) + +### Авторизация + +После загрузки контроллер не принимает никаких команд, кроме запросов на авторизацию. + +Запрос – требование зашифровать строку шестнадцатеричных символов с помощью AES256 алгоритма, используя известный ключ. + +Компьютер генерирует случайную последовательность из 16 байт и отправляет ее контроллеру на шифрование. Тот ее шифрует и отвечает. Компьютер расшифровывает и проверяет на совпадение. + +Можно считать, что контроллер проверку прошел. + +==Однако==, чтобы проверить правильность управляющей программы контроллер выдает встречный запрос на шифровку. Если придет правильный ответ контроллер выдаст “READY” и будет исполнять все поступающие команды. + +#### Алгоритм авторизации + +1. **07 – запрос авторизации от компа (16 байт или 32 HEX-символа)**: + ***Пример:*** **07***8C704E9C4CBF4BA2255741C566FB0F7A* +2. **Встречный запрос от контроллера**: + **CHAL\ (16 байт или 32 HEX-символа)** + ***Пример:*** **CHAL***3D821AA288C153DF7C521E03C5FB9351* +3. **Компьютеру следует ответить**: + **08\. (16 байт или 32 HEX-символа), зашифрованных AES256** + ***Пример:*** **08***FC9D59F43ACB06A382E764B101B10B5B* +4. **Контроллер ответит на первый запрос компа**: + **RAES\ (16 байт или 32 HEX-символа)** + ***Пример:*** **RAES***99ED731648AB8C1709CC4C3C8C3BB0E1* + +# Команды + +## Примечания + +### Служебные команды + +`02…09` – команды относятся к служебным (обновление прошивки и двойное рукопожопие) + +>[!warning] Внимание! +>Перешивается только основная программа, не трогается таблица разделов и буты. +>Поэтому контроллер должен шиться изначально в файловую систему «OTA0-1M+OTA1-1M+SpiFFS-1.7M». +>При прошивке зашивается ключ шифрования и AES ключ авторизации. +>Прошивка для обновления идет бин-файлом, уже криптованным. + +### Программы адресных диодов + +`8XYY` - выбор программы адресных диодов + +Всего 4 канала + +- `81YY…84YY`  - переключить программу на канале + ***Например:*** `8204` – на второй канал вывести четвертую, "зеленую" программу +- `80YY` – переключает программу на всех каналах сразу. + +Программа зависит от автомата и не может быть унифицирована. Именно для этого требуется обновление прошивки контроллера. + +Кроме, пожалуй, этих (считать тестовыми. Остальное – добавлять по вкусу): +- 0 – ничего не выводить в канал +- 1 – повторный периодический вывод буфера +- 2 – погасить (выводить нолики на всю длину) +- 3 – красный +- 4 – зеленый +- 5 - синий +- 6 - серый (100, 100, 100) +- 7 - белый +- 9 - выводить ЛГБТ переливы + +*10 и далее – пользовательские функции* + +#### Специфичные программы + +##### Отдельный раздел для морского боя VR + +`85XXYY`: +- XX – команда; +- YY – время в секундах. + +***Пример:*** `854505` – включить спецэффект ЗАРЯДКА на 5 секунд + +`85` – без параметра отключает процесс рендеринга эффектов (чтобы освободить оперативную память). + +###### Список эффектов + +| Команда (`XX`) | Название эффекта | +| :-------------: | :------------------: | +| **Базовые** | **эффекты** | +| 00 | Горизонтальная волна | +| 01 | Вертикальная волна | +| 02 | Диагональная волна | +| 03 | Плазма | +| 04 | Ночь | +| 05 | Радуга | +| 06 | Огонь-Лёд | +| 07 | Вода | +| 08 | Звёзды | +| 09 | Сканер | +| 10 | Поток | +| 11 | Буря | +| 12 | Лёд | +| **Специальные** | **эффекты** | +| 40 | СТАРТ | +| 41 | КОНЕЦ | +| 42 | ПОПАДАНИЕ | +| 43 | ВЫСТРЕЛ | +| 44 | ПРОМАХ | +| 45 | ЗАРЯДКА | +| 46 | СОНАР | +| 47 | КРАСНАЯ ВОЛНА | +| 48 | ТРЕВОГА | +| 49 | ПУЛЬСАЦИЯ | +| 80 | красный | +| 81 | зеленый | +| 82 | синий | + +##### Отдельный раздел для Данро + +`85XXYY`: +- XX – команда; +- YY – время в секундах. + +***Пример:*** `854505` – включить спецэффект ЗАРЯДКА на 5 секунд + +`85` – без параметра отключает процесс рендеринга эффектов (чтобы освободить оперативную память). + +###### Список эффектов + +| Команда (`XX`) | Название эффекта | +| :------------: | :----------------------: | +| 40 | Горизонтальная волна | +| 41 | Вертикальная волна | +| 42 | Диагональная волна | +| 43 | Радуга | +| 44 | Волна вверх | +| 45 | Вертикальная волна вверх | +| 46 | Конечная волна | +| 47 | Синяя волна | +| 48 | Диагональная волна 2 | +| 49 | Падающие ряды | +| 50 | Оранжевая волна | +| 51 | Морская волна | +| 52 | Диагональная волна 3 | + +## Список команд + +| Команда | Назначение, примеры, ответы | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 00 | Перезагрузить контроллер (RESET) | +| 01 | Отладочная. включает-выключает подробные логи. В релизе будет отключена | +| 02 | Подготовиться к приему файла. освободить место, отформатировать встроенную файловую, проверить хватит ли места.
_**Например**_, `02356987` - заявка на файл обновления размером 356987 байт.
Выполняется около 15 секунд, после чего весь прием  данных в порту заносится в файл "update.bin".
Ответ при приеме файла - каждый блок (4096) - размер принятого.
По окончании - переход снова в режим команд (подробнее – [Обновление](#Обновление)). | +| 03 | Показать содержимое SPIFFS | +| 04 | Запустить ОТА обновление с ранее записанным файлом SPIFFS: `/update.bin` | +| 05 | Показать версию прошивки (указана в дефолтах) | +| 06 | Показать MAC-адрес (без двоеточий, как в оригинале), уникальный, можно считать серийным номером | +| 07 | Безопасность (запрос авторизации от компа. На этот запрос создается рандомный запрос навстречу. См. [[#Алгоритм авторизации]]) | +| 08 | Безопасность (прием ответа от компьютера на запрос `07`. См. [[#Алгоритм авторизации]]) | +| 09 | Сброс EEPROM (параметром - MAC без двоеточий, иначе не сбросится) | +| 10 | Подтверждение принятия сигнала COIN, иначе этот сигнал будет посылаться снова с периодичностью `200 мс` (сколько приняли койнов, столько и подтверждений быть должно) | +| 20X | Установить состояние выходов - всего выходов 8, давать их двоичное состояние.
_**Например**_, `203` - вывести 1 и 2 выходы по единичке, остальные - нолики. Соответственно, выход на разъеме будет "прижат" к земле. | +| 21XY | Установить состояние выхода номер Х в -> Y \[0,1,2(инверсия)\].
_**Примеры:**_
`2131` - Включить 3 по индексу выход
`2170` - Выключить 7 по индексу выход | +| 30X | Хоппер 1.
`301` - Включить мотор хоппера
`300` - Выключить мотор хоппера
`309` - Выдать приз (в авторежиме[^1]) | +| 40X | Хоппер 2.
Аналогично | +| 8XY | Выбор программы адресных диодов Y по каналу X (См. [[#Программы адресных диодов]]) | +| 85XXYY | См. [[#Специфичные программы]] | +| 85 | Без параметра отключает процесс рендеринга эффектов (чтобы освободить оперативную память) | +| 89\[X\] | Переключить цветовое чередование.
***Примеры:***
- `89` или `890` – RGB
- `891` – RBG
- `892` – BRG
- `893` – BGR
- `894` - GRB
- `895` - GBR | +| 90 | Очистить очередь команд | +| 91 | Запрос состояния цифровых входов, их всего 24(+2 входа хоппера), ответ в двоичном коде.
Для уменьшения объема передаваемой информации, преобразуется в десятичное число
_**Примеры:**_
`INPUT67108863` (`11111111111111111111111111`) - все кнопки не нажаты
`INPUT67108861` (`11111111111111111111111101`) - нажата вторая кнопка | +| 91X | _Дополнительно_: задать время антидребезга платы (`10..100`; по умолчанию установлено на 50 мс)
_**Например**_, 9110 - Установить антидребезг на 10 мс | +| 92 | Запрос значения аналоговых входов **(отключает автовывод)** - ответ в формате `ADC0,0,0,0`.
Всего 4 аналоговых входа | +| 92X | Периодический автовывод значений аналоговых входов.
_**Например**_, `92100` - каждые 100 милисекунд выдавать значения ADC (`50..5000`) | +| 93 | Запрос `global_summa` (число коинов, нестираемо) | +| 96 | Cохранение в памяти платы пользовательского значения.
***Например,*** «роли» контроллера (левый-правый в морском бое, или шкаф…)
***Пример команды:*** `96right` | +| 97 | Вывод пользовательского значения
***Формат вывода:*** `ROLE : <значение>` (до 20 символов) | +| 98 | Запрос загруженности ядер, оперативки, свободного места в памяти | +| 99 | Запрос статуса | + +[^1]: Авторежим: если датчик в закрытом состоянии - на полсекунды включить мотор, ждать сработки датчика либо таймаута. Если таймаут - выдать сообщение "HOPPER1 ERROR" и наглухо повесить процесс хоппера до перезагрузки. Иначе - сообщение "HOPPER1 OK" + +# Обновление + +Для обновления платы на новую версию прошивки надо: +1. Волшебным образом заполучить флешку с новой прошивкой +2. Проверить, что прошивка на плате точно старше при помощи команды `05` +3. Выбрать файл новой прошивки с флешки +4. Вполнить появившуюся команду +5. После успешного выполнения команды, проверить состояние SPIFFS +6. При наличии выделенного места, обновить прошивку при помощи команды `04`, дождаться завершения обновления +7. Проверить, что прошивка на плату встала при помощи команды `05` +8. ... +9. PROFIT! \ No newline at end of file