Initial commit: Plate Config App v1.0.0

This commit is contained in:
Данил Ярош 2026-03-19 15:49:00 +03:00
commit 0b3b66a38b
46 changed files with 5987 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@ -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/

67
BUILD_EXE.md Normal file
View File

@ -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`

1
app/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Plate Config App package."""

29
app/entry.py Normal file
View File

@ -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())

235
app/main_window.py Normal file
View File

@ -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()

13
app/serial_config.py Normal file
View File

@ -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

1
app/services/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Services layer for hardware and external integrations."""

View File

@ -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<hex> ──
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<hex> ──
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, "Авторизация пройдена")

314
app/services/com_service.py Normal file
View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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"))

1
app/ui/__init__.py Normal file
View File

@ -0,0 +1 @@
"""UI theme and shared style helpers."""

64
app/ui/icons.py Normal file
View File

@ -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()

713
app/ui/theme.py Normal file
View File

@ -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)

1
app/widgets/__init__.py Normal file
View File

@ -0,0 +1 @@
"""UI widgets for tabs and screens."""

View File

@ -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)

View File

View File

@ -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('<span style="color:#f87171;">✗ Порт не подключён</span>')
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'<span style="color:#4ade80;">→ {cmd}</span>')
except Exception as exc:
self._log.append(f'<span style="color:#f87171;">✗ {exc}</span>')
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'<span style="color:#93c5fd;">← {stripped}</span>')
except Exception:
pass
def closeEvent(self, event) -> None:
self._read_timer.stop()
self._conn.release()
super().closeEvent(event)

View File

@ -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])

View File

@ -0,0 +1,418 @@
"""Диалог «Обновление прошивки» — загрузка и прошивка контроллера.
Команды:
05 — версия прошивки
06 — MAC-адрес
03 — содержимое SPIFFS
02<size> — подготовка к приёму файла → пересылка .bin блоками по 4096
04 — запуск OTA-обновления
Протокол загрузки (команда 02):
1. Отправляем «02<size>\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<size> ──
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()

View File

@ -0,0 +1,696 @@
"""Диалог «Тест ввода» — отображение цифровых и аналоговых входов платы.
Цифровые входы: команда 91 → INPUT<decimal> (26 бит: 24 входа + 2 хоппера)
Аналоговые входы: команда 92 → ADC<v1>,<v2>,<v3>,<v4>
Опрос запускается автоматически при открытии диалога и работает
в фоновом потоке, чтобы не блокировать 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)

View File

@ -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}")

View File

@ -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"<b>{header}</b>")
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}")

View File

@ -0,0 +1,110 @@
"""Диалог «Задание роли» — чтение и установка пользовательской роли контроллера.
Команды:
97 — прочитать роль → ROLE : <значение>
96<text> — задать роль (до 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))

View File

@ -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()

View File

@ -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)

103
app/widgets/start_tab.py Normal file
View File

@ -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()

186
app/widgets/title_bar.py Normal file
View File

@ -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)

5
build_exe.bat Normal file
View File

@ -0,0 +1,5 @@
@echo off
chcp 65001 >nul 2>&1
setlocal
powershell -ExecutionPolicy Bypass -File "%~dp0build_exe.ps1" %*
pause

67
build_exe.ps1 Normal file
View File

@ -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 не найден после сборки."
}

5
clean.bat Normal file
View File

@ -0,0 +1,5 @@
@echo off
chcp 65001 >nul 2>&1
setlocal
powershell -ExecutionPolicy Bypass -File "%~dp0clean.ps1"
pause

32
clean.ps1 Normal file
View File

@ -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 "Очистка завершена."

View File

@ -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;Статус;Запрос текущего статуса платы.
1 command name description
2 00 Перезагрузка Перезагрузить контроллер (RESET). Помещается в очередь без ожидания.
3 01 Отладка Включает/выключает подробные логи. В релизе будет отключена.
4 02 Приём файла Подготовиться к приёму файла обновления. Параметр — размер в байтах. Пример: 02356987. Выполняется ~15 сек, затем весь приём данных записывается в update.bin.
5 03 SPIFFS Показать содержимое SPIFFS (встроенной файловой системы).
6 04 OTA обновление Запустить OTA-обновление с ранее записанным файлом /update.bin.
7 05 Версия прошивки Показать версию прошивки контроллера.
8 06 MAC-адрес Показать MAC-адрес (без двоеточий). Уникальный, можно считать серийным номером.
9 07 Авторизация (запрос) Запрос авторизации от компьютера. 32 HEX-символа. Контроллер ответит встречным вызовом CHAL.
10 08 Авторизация (ответ) Приём ответа от компьютера на встречный вызов CHAL. 32 HEX-символа зашифрованных AES256.
11 09 Сброс EEPROM Сброс EEPROM. Параметр — MAC-адрес без двоеточий, иначе не сбросится.
12 10 Подтверждение COIN Подтвердить принятие сигнала COIN. Без подтверждения сигнал повторяется каждые 200 мс.
13 20X Выходы (маска) Установить состояние 8 выходов двоичной маской. Пример: 203 → включить выходы 1 и 2.
14 21XY Выход (один) Установить выход X в состояние Y [0/1/2(инверсия)]. Пример: 2131 → включить выход 3.
15 30X Хоппер 1 301 — включить мотор, 300 — выключить, 309 — выдать приз (авторежим с контролем датчика).
16 40X Хоппер 2 Аналогично хопперу 1.
17 8XYY Программа LED Выбор программы YY адресных диодов по каналу X (1..4). 80YY — на все каналы. 0 — выкл, 3 — красный, 4 — зелёный, 5 — синий.
18 85XXYY Спецэффект LED Специфичная программа эффектов. XX — код эффекта, YY — время в секундах. Пример: 854505 — ЗАРЯДКА на 5 сек.
19 85 Стоп рендеринг Без параметра — отключить процесс рендеринга эффектов (освободить RAM).
20 89[X] Цветовой порядок Переключить чередование цветов: 0/пусто — RGB, 1 — RBG, 2 — BRG, 3 — BGR, 4 — GRB, 5 — GBR.
21 90 Очистка очереди Очистить очередь команд.
22 91 Входы Запрос состояния 26 цифровых входов. Ответ: INPUT<число>. Пример: INPUT67108863 — все не нажаты.
23 91X Антидребезг Задать время антидребезга (10..100 мс). По умолчанию 50 мс. Пример: 9110.
24 92 ADC (однократно) Запрос значений 4 аналоговых входов. Отключает автовывод. Ответ: ADC0,0,0,0.
25 92X ADC (авто) Периодический автовывод ADC каждые X мс (50..5000). Пример: 92100.
26 93 Счётчик монет Запрос global_summa — нестираемый счётчик принятых монет.
27 96 Сохранить роль Сохранить пользовательское значение в памяти платы (до 20 символов). Пример: 96right.
28 97 Показать роль Вывод сохранённого пользовательского значения. Формат: ROLE : <значение>.
29 98 Состояние системы Запрос загруженности ядер, оперативки и свободного места в памяти.
30 99 Статус Запрос текущего статуса платы.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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-переливы"

58
light_modes/Данро.lm Normal file
View File

@ -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"

View File

@ -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 = "Синий"

10
main.py Normal file
View File

@ -0,0 +1,10 @@
import faulthandler
import sys
faulthandler.enable(file=sys.stderr)
from app.entry import main
if __name__ == "__main__":
main()

54
plate_config_app.spec Normal file
View File

@ -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,
)

2
requirements-build.txt Normal file
View File

@ -0,0 +1,2 @@
-r requirements.txt
pyinstaller>=6.19

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
PySide6>=6.7
pyserial>=3.5
qtawesome>=1.4
pycryptodome>=3.20

View File

@ -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+\<Enter\> – команда, а 0+\<Enter\> – уже нет
- Таймаут - 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\<symbols\> (16 байт или 32 HEX-символа)**
***Пример:*** **CHAL***3D821AA288C153DF7C521E03C5FB9351*
3. **Компьютеру следует ответить**:
**08\<symbols\>. (16 байт или 32 HEX-символа), зашифрованных AES256**
***Пример:*** **08***FC9D59F43ACB06A382E764B101B10B5B*
4. **Контроллер ответит на первый запрос компа**:
**RAES\<symbols\> (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 | Подготовиться к приему файла. освободить место, отформатировать встроенную файловую, проверить хватит ли места. <br>_**Например**_, `02356987` - заявка на файл обновления размером 356987 байт.<br>Выполняется около 15 секунд, после чего весь прием  данных в порту заносится в файл "update.bin".<br>Ответ при приеме файла - каждый блок (4096) - размер принятого.<br>По окончании - переход снова в режим команд (подробнее – [Обновление](#Обновление)). |
| 03 | Показать содержимое SPIFFS |
| 04 | Запустить ОТА обновление с ранее записанным файлом SPIFFS: `/update.bin` |
| 05 | Показать версию прошивки (указана в дефолтах) |
| 06 | Показать MAC-адрес (без двоеточий, как в оригинале), уникальный, можно считать серийным номером |
| 07 | Безопасность (запрос авторизации от компа. На этот запрос создается рандомный запрос навстречу. См. [[#Алгоритм авторизации]]) |
| 08 | Безопасность (прием ответа от компьютера на запрос `07`. См. [[#Алгоритм авторизации]]) |
| 09 | Сброс EEPROM (параметром - MAC без двоеточий, иначе не сбросится) |
| 10 | Подтверждение принятия сигнала COIN, иначе этот сигнал будет посылаться снова с периодичностью `200 мс` (сколько приняли койнов, столько и подтверждений быть должно) |
| 20X | Установить состояние выходов - всего выходов 8, давать их двоичное состояние.<br>_**Например**_, `203` - вывести 1 и 2 выходы по единичке, остальные - нолики. Соответственно, выход на разъеме будет "прижат" к земле. |
| 21XY | Установить состояние выхода номер Х в -> Y \[0,1,2(инверсия)\].<br>_**Примеры:**_<br>`2131` - Включить 3 по индексу выход<br>`2170` - Выключить 7 по индексу выход |
| 30X | Хоппер 1.<br>`301` - Включить мотор хоппера<br>`300` - Выключить мотор хоппера<br>`309` - Выдать приз (в авторежиме[^1]) |
| 40X | Хоппер 2.<br>Аналогично |
| 8XY | Выбор программы адресных диодов Y по каналу X (См. [[#Программы адресных диодов]]) |
| 85XXYY | См. [[#Специфичные программы]] |
| 85 | Без параметра отключает процесс рендеринга эффектов (чтобы освободить оперативную память) |
| 89\[X\] | Переключить цветовое чередование.<br>***Примеры:***<br>- `89` или `890` – RGB<br>- `891` – RBG<br>- `892` – BRG<br>- `893` – BGR<br>- `894` - GRB<br>- `895` - GBR |
| 90 | Очистить очередь команд |
| 91 | Запрос состояния цифровых входов, их всего 24(+2 входа хоппера), ответ в двоичном коде.<br>Для уменьшения объема передаваемой информации, преобразуется в десятичное число<br>_**Примеры:**_<br>`INPUT67108863` (`11111111111111111111111111`) - все кнопки не нажаты<br>`INPUT67108861` (`11111111111111111111111101`) - нажата вторая кнопка |
| 91X | _Дополнительно_: задать время антидребезга платы (`10..100`; по умолчанию установлено на 50 мс)<br>_**Например**_, 9110 - Установить антидребезг на 10 мс |
| 92 | Запрос значения аналоговых входов **(отключает автовывод)** - ответ в формате `ADC0,0,0,0`.<br>Всего 4 аналоговых входа |
| 92X | Периодический автовывод значений аналоговых входов.<br>_**Например**_, `92100` - каждые 100 милисекунд выдавать значения ADC (`50..5000`) |
| 93 | Запрос `global_summa` (число коинов, нестираемо) |
| 96 | Cохранение в памяти платы пользовательского значения.<br>***Например,*** «роли» контроллера (левый-правый в морском бое, или шкаф…)<br>***Пример команды:*** `96right` |
| 97 | Вывод пользовательского значения<br>***Формат вывода:*** `ROLE : <значение>` (до 20 символов) |
| 98 | Запрос загруженности ядер, оперативки, свободного места в памяти |
| 99 | Запрос статуса |
[^1]: Авторежим: если датчик в закрытом состоянии - на полсекунды включить мотор, ждать сработки датчика либо таймаута. Если таймаут - выдать сообщение "HOPPER1 ERROR" и наглухо повесить процесс хоппера до перезагрузки. Иначе - сообщение "HOPPER1 OK"
# Обновление
Для обновления платы на новую версию прошивки надо:
1. Волшебным образом заполучить флешку с новой прошивкой
2. Проверить, что прошивка на плате точно старше при помощи команды `05`
3. Выбрать файл новой прошивки с флешки
4. Вполнить появившуюся команду
5. После успешного выполнения команды, проверить состояние SPIFFS
6. При наличии выделенного места, обновить прошивку при помощи команды `04`, дождаться завершения обновления
7. Проверить, что прошивка на плату встала при помощи команды `05`
8. ...
9. PROFIT!