Initial commit: Plate Config App v1.0.0
This commit is contained in:
commit
0b3b66a38b
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal 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
67
BUILD_EXE.md
Normal 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
1
app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Plate Config App package."""
|
||||
29
app/entry.py
Normal file
29
app/entry.py
Normal 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
235
app/main_window.py
Normal 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
13
app/serial_config.py
Normal 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
1
app/services/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Services layer for hardware and external integrations."""
|
||||
159
app/services/auth_service.py
Normal file
159
app/services/auth_service.py
Normal 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
314
app/services/com_service.py
Normal 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)
|
||||
84
app/services/commands_service.py
Normal file
84
app/services/commands_service.py
Normal 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()
|
||||
99
app/services/input_mappings_service.py
Normal file
99
app/services/input_mappings_service.py
Normal 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()
|
||||
91
app/services/light_modes_service.py
Normal file
91
app/services/light_modes_service.py
Normal 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()
|
||||
74
app/services/settings_service.py
Normal file
74
app/services/settings_service.py
Normal 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
1
app/ui/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""UI theme and shared style helpers."""
|
||||
64
app/ui/icons.py
Normal file
64
app/ui/icons.py
Normal 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
713
app/ui/theme.py
Normal 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
1
app/widgets/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""UI widgets for tabs and screens."""
|
||||
549
app/widgets/connection_tab.py
Normal file
549
app/widgets/connection_tab.py
Normal 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)
|
||||
0
app/widgets/dialogs/__init__.py
Normal file
0
app/widgets/dialogs/__init__.py
Normal file
195
app/widgets/dialogs/command_terminal_dialog.py
Normal file
195
app/widgets/dialogs/command_terminal_dialog.py
Normal 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)
|
||||
168
app/widgets/dialogs/commands_dialog.py
Normal file
168
app/widgets/dialogs/commands_dialog.py
Normal 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])
|
||||
418
app/widgets/dialogs/firmware_dialog.py
Normal file
418
app/widgets/dialogs/firmware_dialog.py
Normal 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()
|
||||
696
app/widgets/dialogs/input_test_dialog.py
Normal file
696
app/widgets/dialogs/input_test_dialog.py
Normal 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)
|
||||
326
app/widgets/dialogs/led_test_dialog.py
Normal file
326
app/widgets/dialogs/led_test_dialog.py
Normal 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}")
|
||||
169
app/widgets/dialogs/output_test_dialog.py
Normal file
169
app/widgets/dialogs/output_test_dialog.py
Normal 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}")
|
||||
110
app/widgets/dialogs/role_dialog.py
Normal file
110
app/widgets/dialogs/role_dialog.py
Normal 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))
|
||||
148
app/widgets/dialogs/settings_dialog.py
Normal file
148
app/widgets/dialogs/settings_dialog.py
Normal 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()
|
||||
133
app/widgets/dialogs/styled_dialog.py
Normal file
133
app/widgets/dialogs/styled_dialog.py
Normal 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
103
app/widgets/start_tab.py
Normal 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
186
app/widgets/title_bar.py
Normal 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
5
build_exe.bat
Normal 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
67
build_exe.ps1
Normal 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
5
clean.bat
Normal 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
32
clean.ps1
Normal 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 "Очистка завершена."
|
||||
30
commands/Основные.csv
Normal file
30
commands/Основные.csv
Normal 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;Статус;Запрос текущего статуса платы.
|
||||
|
51
input_mappings/Бродяги морей.mapping
Normal file
51
input_mappings/Бродяги морей.mapping
Normal 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
|
||||
45
input_mappings/Данро.mapping
Normal file
45
input_mappings/Данро.mapping
Normal 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
|
||||
57
input_mappings/Фишка.mapping
Normal file
57
input_mappings/Фишка.mapping
Normal 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
|
||||
40
light_modes/Базовые.lm
Normal file
40
light_modes/Базовые.lm
Normal 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
58
light_modes/Данро.lm
Normal 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"
|
||||
116
light_modes/Морской бой VR.lm
Normal file
116
light_modes/Морской бой VR.lm
Normal 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
10
main.py
Normal 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
54
plate_config_app.spec
Normal 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
2
requirements-build.txt
Normal file
@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
pyinstaller>=6.19
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
PySide6>=6.7
|
||||
pyserial>=3.5
|
||||
qtawesome>=1.4
|
||||
pycryptodome>=3.20
|
||||
233
Система команд платы v2.x.md
Normal file
233
Система команд платы v2.x.md
Normal 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!
|
||||
Loading…
x
Reference in New Issue
Block a user