From 763913ffb855b5ed16e43f39acc884f7bc4e6108 Mon Sep 17 00:00:00 2001 From: Danil Iarosh Date: Fri, 20 Mar 2026 11:11:57 +0300 Subject: [PATCH] v1.2.0: OTA fix for basic mode, version display in title bar --- app/__init__.py | 2 + app/main_window.py | 4 +- app/services/settings_service.py | 15 +- app/ui/theme.py | 158 +++++- app/widgets/connection_tab.py | 257 ++++++++- app/widgets/dialogs/input_test_dialog.py | 270 +++++++++- app/widgets/dialogs/output_test_dialog.py | 11 +- app/widgets/dialogs/settings_dialog.py | 35 ++ app/widgets/sections/__init__.py | 6 + app/widgets/sections/firmware_section.py | 212 ++++++++ app/widgets/sections/input_test_section.py | 599 +++++++++++++++++++++ app/widgets/sections/led_test_section.py | 131 +++++ app/widgets/sections/payment_section.py | 208 +++++++ 13 files changed, 1865 insertions(+), 43 deletions(-) create mode 100644 app/widgets/sections/__init__.py create mode 100644 app/widgets/sections/firmware_section.py create mode 100644 app/widgets/sections/input_test_section.py create mode 100644 app/widgets/sections/led_test_section.py create mode 100644 app/widgets/sections/payment_section.py diff --git a/app/__init__.py b/app/__init__.py index a62e474..3b0b2fa 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1,3 @@ """Plate Config App package.""" + +__version__ = "1.2.0" diff --git a/app/main_window.py b/app/main_window.py index 2ff01d6..9be3aad 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -19,13 +19,15 @@ from .widgets.connection_tab import ConnectionTab from .widgets.start_tab import StartTab from .widgets.title_bar import TitleBar +from . import __version__ + class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() self.setObjectName("AppWindow") - self.setWindowTitle("Plate Config App") + self.setWindowTitle(f"Plate Config App v{__version__}") self.resize(820, 560) self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True) diff --git a/app/services/settings_service.py b/app/services/settings_service.py index 38f3674..e22d541 100644 --- a/app/services/settings_service.py +++ b/app/services/settings_service.py @@ -21,8 +21,10 @@ def _settings_path() -> Path: _DEFAULTS: dict[str, Any] = { - "theme_mode": "light", # "light" | "dark" - "theme_accent": "green", # "green" | "blue" + "theme_mode": "light", # "light" | "dark" + "theme_accent": "green", # "green" | "blue" + "health_check_enabled": False, # периодическая проверка связи с платой + "health_check_interval": 10, # интервал в секундах (5–60) } _cache: dict[str, Any] | None = None @@ -72,3 +74,12 @@ def theme_mode() -> str: def theme_accent() -> str: return str(get("theme_accent")) + + +def health_check_enabled() -> bool: + return bool(get("health_check_enabled")) + + +def health_check_interval() -> int: + val = int(get("health_check_interval")) + return max(5, min(60, val)) diff --git a/app/ui/theme.py b/app/ui/theme.py index 00076a0..6010172 100644 --- a/app/ui/theme.py +++ b/app/ui/theme.py @@ -8,6 +8,8 @@ APP_STYLESHEET is the default (light-green) for backward compatibility. from __future__ import annotations from dataclasses import dataclass, asdict +import os +import tempfile @dataclass(frozen=True) @@ -434,6 +436,19 @@ QLabel#StatusBadge[state="ready"] {{ color: {status_ready_text}; }} +QPushButton#ReconnectBtn {{ + background: transparent; + border: 1px solid {border}; + border-radius: 6px; + padding: 4px; +}} +QPushButton#ReconnectBtn:hover {{ + background: {btn_bg}; +}} +QPushButton#ReconnectBtn:pressed {{ + background: {border}; +}} + QLabel#SectionTitle {{ color: {text_secondary}; font-size: 15px; @@ -465,7 +480,7 @@ QComboBox {{ background: {bg_input}; border: 1px solid {border}; border-radius: 8px; - padding: 6px 10px; + padding: 6px 28px 6px 10px; min-height: 18px; color: {text_primary}; selection-background-color: {btn_primary_bg}; @@ -481,15 +496,30 @@ QComboBox:focus {{ }} QComboBox::drop-down {{ - border: none; + subcontrol-origin: padding; + subcontrol-position: center right; width: 24px; + border: none; + border-left: 1px solid {border}; + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + background: {btn_bg}; +}} + +QComboBox::drop-down:hover {{ + background: {btn_hover}; }} QComboBox::down-arrow {{ + image: url("{_arrow_down_svg}"); width: 10px; height: 10px; }} +QComboBox::down-arrow:on {{ + image: url("{_arrow_up_svg}"); +}} + QComboBox QAbstractItemView {{ background: {bg_card}; color: {text_primary}; @@ -608,7 +638,7 @@ QSpinBox {{ background: {bg_input}; border: 1px solid {border}; border-radius: 6px; - padding: 4px 6px; + padding: 4px 22px 4px 6px; color: {text_primary}; min-height: 18px; }} @@ -617,6 +647,54 @@ QSpinBox:focus {{ border-color: {btn_primary_bg}; }} +QSpinBox::up-button {{ + subcontrol-origin: border; + subcontrol-position: top right; + width: 20px; + border: none; + border-left: 1px solid {border}; + border-top-right-radius: 6px; + background: {btn_bg}; +}} + +QSpinBox::up-button:hover {{ + background: {btn_hover}; +}} + +QSpinBox::up-button:pressed {{ + background: {btn_pressed}; +}} + +QSpinBox::up-arrow {{ + image: url("{_arrow_up_svg}"); + width: 8px; + height: 8px; +}} + +QSpinBox::down-button {{ + subcontrol-origin: border; + subcontrol-position: bottom right; + width: 20px; + border: none; + border-left: 1px solid {border}; + border-bottom-right-radius: 6px; + background: {btn_bg}; +}} + +QSpinBox::down-button:hover {{ + background: {btn_hover}; +}} + +QSpinBox::down-button:pressed {{ + background: {btn_pressed}; +}} + +QSpinBox::down-arrow {{ + image: url("{_arrow_down_svg}"); + width: 8px; + height: 8px; +}} + QToolButton#AccentSwatch {{ border: 2px solid {border}; border-radius: 16px; @@ -711,11 +789,83 @@ QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }} + +/* ── Checkbox ─────────────────────────────────── */ +QCheckBox {{ + color: {text_primary}; + spacing: 6px; +}} + +/* ── PRO-mode toggle ──────────────────────────── */ +QCheckBox#ProToggle {{ + color: {text_muted}; + font-size: 12px; + spacing: 6px; +}} +QCheckBox#ProToggle::indicator {{ + width: 36px; + height: 20px; + border-radius: 10px; + background: {border}; + border: 1px solid {border_light}; +}} +QCheckBox#ProToggle::indicator:checked {{ + background: {accent}; + border-color: {accent_dark}; +}} + +/* ── Collapsible section header ───────────────── */ +QPushButton#CollapsibleHeader {{ + background: {btn_bg}; + border: 1px solid {border}; + border-radius: 8px; + color: {text_primary}; + padding: 8px 12px; + font-weight: 700; + font-size: 13px; + text-align: left; +}} +QPushButton#CollapsibleHeader:hover {{ + background: {btn_hover}; + border-color: {text_muted}; +}} +QPushButton#CollapsibleHeader:pressed {{ + background: {btn_pressed}; +}} """ +_ARROW_SVG_DIR: str | None = None + + +def _arrow_svg_path(name: str, color: str, points: str) -> str: + """Создать SVG-файл стрелки и вернуть путь (forward-slash для QSS).""" + global _ARROW_SVG_DIR # noqa: PLW0603 + if _ARROW_SVG_DIR is None: + _ARROW_SVG_DIR = tempfile.mkdtemp(prefix="plate_arrows_") + # Уникальное имя: name + hex-цвет без # + fname = f"{name}_{color.lstrip('#')}.svg" + fpath = os.path.join(_ARROW_SVG_DIR, fname) + if not os.path.exists(fpath): + svg = ( + '' + f'' + '' + ) + with open(fpath, "w", encoding="utf-8") as f: + f.write(svg) + return fpath.replace("\\", "/") + + def build_stylesheet(palette: ThemePalette) -> str: - return _QSS_TEMPLATE.format(**asdict(palette)) + d = asdict(palette) + # Генерируем SVG-стрелки + muted = d["text_muted"] + d["_arrow_down_svg"] = _arrow_svg_path("arrow_down", muted, "2,4 6,8 10,4") + d["_arrow_up_svg"] = _arrow_svg_path("arrow_up", muted, "2,8 6,4 10,8") + return _QSS_TEMPLATE.format(**d) APP_STYLESHEET = build_stylesheet(_LIGHT_GREEN) diff --git a/app/widgets/connection_tab.py b/app/widgets/connection_tab.py index 6ae2fd8..79fb5f7 100644 --- a/app/widgets/connection_tab.py +++ b/app/widgets/connection_tab.py @@ -5,6 +5,7 @@ from enum import Enum, auto from PySide6.QtCore import Qt, QThread, QTimer, Signal from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QFrame, QFormLayout, @@ -26,8 +27,60 @@ from ..services.com_service import ( params_from_ui, ) from ..services.auth_service import AuthResult, AuthStatus, check_status, perform_handshake +from ..services import settings_service from ..ui.icons import get_icon_color, get_icon_muted, icon from ..serial_config import SerialParams +from .sections.led_test_section import LedTestSection +from .sections.input_test_section import InputTestSection +from .sections.firmware_section import FirmwareSection +from .sections.payment_section import PaymentSection + + +class _CollapsibleGroup(QWidget): + """Сворачиваемая группа с заголовком-кнопкой и контентом.""" + + def __init__(self, title: str, *, expanded: bool = False, parent: QWidget | None = None) -> None: + super().__init__(parent) + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self._header = QPushButton(title) + self._header.setObjectName("CollapsibleHeader") + self._header.setCursor(Qt.CursorShape.PointingHandCursor) + self._header.clicked.connect(self._toggle) + layout.addWidget(self._header) + + self._content = QWidget() + self._content_layout = QVBoxLayout(self._content) + self._content_layout.setContentsMargins(0, 6, 0, 0) + self._content_layout.setSpacing(0) + layout.addWidget(self._content) + + self._expanded = expanded + self._content.setVisible(expanded) + self._update_arrow() + + def add_widget(self, widget: QWidget) -> None: + self._content_layout.addWidget(widget) + + def set_expanded(self, expanded: bool) -> None: + self._expanded = expanded + self._content.setVisible(expanded) + self._update_arrow() + + def _toggle(self) -> None: + self.set_expanded(not self._expanded) + + def _update_arrow(self) -> None: + arrow = "▼" if self._expanded else "▶" + text = self._header.text() + # strip any existing arrow prefix + for prefix in ("▼ ", "▶ "): + if text.startswith(prefix): + text = text[len(prefix):] + break + self._header.setText(f"{arrow} {text}") class PlateState(Enum): @@ -80,14 +133,41 @@ class ConnectionTab(QWidget): root_layout.setContentsMargins(18, 18, 18, 18) root_layout.setSpacing(14) + # ── Заголовок + PRO-режим ────────────────────────────────── + header_row = QHBoxLayout() title = QLabel("Панель соединения") title.setObjectName("SectionTitle") - root_layout.addWidget(title) + header_row.addWidget(title) + header_row.addStretch(1) + + self._pro_toggle = QCheckBox("PRO-режим") + self._pro_toggle.setObjectName("ProToggle") + self._pro_toggle.setCursor(Qt.CursorShape.PointingHandCursor) + self._pro_toggle.setChecked(False) + self._pro_toggle.toggled.connect(self._on_pro_toggled) + header_row.addWidget(self._pro_toggle) + root_layout.addLayout(header_row) self.status_label = QLabel() self.status_label.setObjectName("StatusBadge") + self.status_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self._set_status("", PlateState.DISCONNECTED) - root_layout.addWidget(self.status_label) + + self._reconnect_btn = QPushButton() + self._reconnect_btn.setObjectName("ReconnectBtn") + self._reconnect_btn.setToolTip("Переподключиться") + self._reconnect_btn.setIcon(icon("fa5s.sync-alt", color=get_icon_color())) + self._reconnect_btn.setFixedSize(34, 34) + self._reconnect_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self._reconnect_btn.clicked.connect(self._do_reconnect) + self._reconnect_btn.hide() + + status_row = QHBoxLayout() + status_row.setContentsMargins(0, 0, 0, 0) + status_row.setSpacing(6) + status_row.addWidget(self.status_label) + status_row.addWidget(self._reconnect_btn) + root_layout.addLayout(status_row) self.setup_widget = QFrame() self.setup_widget.setObjectName("PanelCard") @@ -176,27 +256,70 @@ class ConnectionTab(QWidget): controls_layout.addWidget(self.auth_btn) self.auth_btn.hide() - action_items = ( + # ── Базовые секции (PRO выключен) ── каждая в сворачиваемой группе ── + self._firmware_section = FirmwareSection(self._connection) + self._firmware_section.upload_started.connect(self._on_fw_upload_started) + self._firmware_section.upload_finished.connect(self._on_fw_upload_finished) + self._led_section = LedTestSection(self._connection) + self._input_section = InputTestSection(self._connection) + self._payment_section = PaymentSection( + send_raw=lambda cmd: self._input_section._send_raw(cmd), + ) + + self._firmware_group = _CollapsibleGroup("Обновление прошивки", expanded=True) + self._firmware_group.add_widget(self._firmware_section) + controls_layout.addWidget(self._firmware_group) + + self._led_group = _CollapsibleGroup("Тест цвета", expanded=False) + self._led_group.add_widget(self._led_section) + controls_layout.addWidget(self._led_group) + + self._input_group = _CollapsibleGroup("Тест ввода", expanded=False) + self._input_group.add_widget(self._input_section) + controls_layout.addWidget(self._input_group) + + self._payment_group = _CollapsibleGroup("Тест оплаты и призов", expanded=False) + self._payment_group.add_widget(self._payment_section) + controls_layout.addWidget(self._payment_group) + + self._basic_groups: list[_CollapsibleGroup] = [ + self._firmware_group, + self._led_group, + self._input_group, + self._payment_group, + ] + self._basic_sections: list[QWidget] = [ + self._firmware_section, + self._led_section, + self._input_section, + self._payment_section, + ] + + # ── PRO-кнопки (все действия, скрыты по умолчанию) ───────── + pro_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), ) - self._action_buttons: list[tuple[QPushButton, str]] = [] - for title, icon_name, handler in action_items: + self._pro_buttons: list[tuple[QPushButton, str]] = [] + for title, icon_name, handler in pro_items: btn = QPushButton(title) btn.setMinimumHeight(34) btn.setIcon(icon(icon_name, color=get_icon_color())) btn.clicked.connect(handler) + btn.hide() controls_layout.addWidget(btn) - self._action_buttons.append((btn, icon_name)) + self._pro_buttons.append((btn, icon_name)) 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) + self._command_input_btn = command_input_btn + self._command_input_btn.hide() controls_layout.addWidget(command_input_btn) controls_layout.addStretch(1) root_layout.addWidget(self.controls_widget) @@ -212,8 +335,59 @@ class ConnectionTab(QWidget): muted = get_icon_muted() self._refresh_btn.setIcon(icon("fa5s.sync-alt", color=muted)) self._cancel_btn.setIcon(icon("fa5s.times", color=muted)) - for btn, icon_name in self._action_buttons: + self._reconnect_btn.setIcon(icon("fa5s.sync-alt", color=clr)) + for btn, icon_name in self._pro_buttons: btn.setIcon(icon(icon_name, color=clr)) + self._led_section.refresh_icons() + self._input_section.refresh_icons() + self._firmware_section.refresh_icons() + self._payment_section.refresh_icons() + + # ── PRO-режим ───────────────────────────────────────────────── + + def _start_basic_polling(self) -> None: + """Запустить опрос входов и подключить PaymentSection к сигналам потока.""" + self._input_section.start_polling() + pt = self._input_section._poll_thread + if pt is not None: + pt.digital_ready.connect( + self._payment_section.on_digital, Qt.ConnectionType.QueuedConnection + ) + pt.info_line.connect( + self._payment_section.on_info_line, Qt.ConnectionType.QueuedConnection + ) + + def _on_fw_upload_started(self) -> None: + """Остановить опрос входов перед прошивкой — порт нужен целиком.""" + self._input_section.stop_polling() + # stop_polling вызывает resume_drain, но drain-поток может съесть + # данные прошивки до того, как UploadWorker захватит io_lock. + # Поэтому приостанавливаем drain снова — UploadWorker сам работает + # с io_lock напрямую, drain ему не нужен. + self._connection.pause_drain() + + def _on_fw_upload_finished(self) -> None: + """Возобновить опрос входов после завершения прошивки.""" + if not self._pro_toggle.isChecked(): + self._start_basic_polling() + else: + # В PRO-режиме poll не запускается — вернуть drain + self._connection.resume_drain() + + def _on_pro_toggled(self, checked: bool) -> None: + """Переключить видимость базовых секций / PRO кнопок.""" + # Базовые inline-группы + for group in self._basic_groups: + group.setVisible(not checked) + # Управление poll-потоком InputTestSection + if not checked: + self._start_basic_polling() + else: + self._input_section.stop_polling() + # PRO-кнопки + for btn, _ in self._pro_buttons: + btn.setVisible(checked) + self._command_input_btn.setVisible(checked) def _load_ports(self) -> None: current = self.port_combo.currentText() @@ -254,6 +428,7 @@ class ConnectionTab(QWidget): self.setup_widget.hide() self.controls_widget.show() + self._reconnect_btn.show() self._conn_info = ( f"{port} ({self.baud_combo.currentText()}-" f"{self.data_bits_combo.currentText()}-" @@ -269,8 +444,12 @@ class ConnectionTab(QWidget): # Проверяем авторизацию один раз при подключении self._check_auth_on_connect() - # Запускаем периодический health-check - self._start_health_timer() + # Запускаем периодический health-check (если включён в настройках) + self._apply_health_settings() + + # Если PRO выключен — запускаем опрос входов в inline-секции + if not self._pro_toggle.isChecked(): + self._start_basic_polling() def _close_this_tab(self) -> None: index = self._tab_index_getter() @@ -279,6 +458,9 @@ class ConnectionTab(QWidget): def close_serial(self) -> None: self._stop_health_timer() + # Остановить inline-секции + self._input_section.stop_polling() + self._firmware_section.cancel_upload() self._connection.close() # Дождаться завершения всех фоновых потоков for thread in self._active_threads: @@ -287,6 +469,31 @@ class ConnectionTab(QWidget): thread.wait(1000) self._active_threads.clear() self._update_plate_state(PlateState.DISCONNECTED) + self._reconnect_btn.hide() + + def _do_reconnect(self) -> None: + """Переподключиться к порту: закрыть → переоткрыть → проверить авторизацию.""" + if not self._connection.is_connected: + return + # Остановить опрос / health + self._stop_health_timer() + self._input_section.stop_polling() + port = self._connection.serial.port if self._connection.serial else None + if port is None: + return + params = self._collect_params() + self._connection.close() + self._update_plate_state(PlateState.DISCONNECTED) + try: + self._connection.open(port=port, params=params) + except Exception as exc: + QMessageBox.critical(self, "Переподключение", f"Не удалось открыть {port}\n{exc}") + return + self._set_status(self._conn_info, PlateState.NEED_AUTH) + self._check_auth_on_connect() + self._apply_health_settings() + if not self._pro_toggle.isChecked(): + self._start_basic_polling() # ── Открытие диалогов ─────────────────────────────────────────────── @@ -433,15 +640,27 @@ class ConnectionTab(QWidget): # ── Периодический health-check ────────────────────────────────────── - _HEALTH_INTERVAL_MS = 10_000 # интервал таймера (10 сек) - _IDLE_THRESHOLD_SEC = 10.0 # минимальный простой перед проверкой _MAX_HEALTH_FAILURES = 3 # закрывать порт после N неудач подряд - def _start_health_timer(self) -> None: + def _apply_health_settings(self) -> None: + """Прочитать настройки и запустить/остановить health-check.""" + if settings_service.health_check_enabled(): + interval_ms = settings_service.health_check_interval() * 1000 + if self._health_timer is not None: + # Обновить интервал на лету + self._health_timer.setInterval(interval_ms) + else: + self._start_health_timer(interval_ms) + else: + self._stop_health_timer() + + def _start_health_timer(self, interval_ms: int | None = None) -> None: if self._health_timer is not None: return + if interval_ms is None: + interval_ms = settings_service.health_check_interval() * 1000 self._health_timer = QTimer(self) - self._health_timer.setInterval(self._HEALTH_INTERVAL_MS) + self._health_timer.setInterval(interval_ms) self._health_timer.timeout.connect(self._on_health_tick) self._health_timer.start() @@ -453,16 +672,24 @@ class ConnectionTab(QWidget): def _on_health_tick(self) -> None: """Проверка по таймеру: если нет открытых диалогов и - прошло ≥10 сек без команд — шлём 99 для проверки связи.""" + прошло достаточно без команд — шлём 99 для проверки связи.""" + # Если проверка отключена в настройках — остановить таймер + if not settings_service.health_check_enabled(): + self._stop_health_timer() + return if self._open_dialogs > 0: return + # Если InputTestSection ведёт опрос — drain приостановлен, не мешаем + if self._input_section._polling: + 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: + idle_threshold = float(settings_service.health_check_interval()) + if self._connection.seconds_since_last_command < idle_threshold: return self._health_running = True diff --git a/app/widgets/dialogs/input_test_dialog.py b/app/widgets/dialogs/input_test_dialog.py index d9a1d51..44f115f 100644 --- a/app/widgets/dialogs/input_test_dialog.py +++ b/app/widgets/dialogs/input_test_dialog.py @@ -9,7 +9,8 @@ from __future__ import annotations -from PySide6.QtCore import Qt, QThread, QTimer, Signal +from PySide6.QtCore import Qt, QThread, QTimer, QPointF, Signal +from PySide6.QtGui import QPainter, QPen, QBrush, QColor from PySide6.QtWidgets import ( QComboBox, QGridLayout, @@ -21,6 +22,7 @@ from PySide6.QtWidgets import ( QSizePolicy, QSpinBox, QVBoxLayout, + QWidget, ) from ...services.com_service import ComConnection @@ -34,6 +36,106 @@ from ...ui.icons import get_icon_color, get_icon_muted, icon from .styled_dialog import StyledDialog _NUM_DIGITAL = 26 # 24 входа + 2 хоппера +_JOY_SIZE = 120 # размер виджета джойстика в пикселях + + +class _JoystickWidget(QWidget): + """Визуальный круг джойстика — показывает позицию по двум осям (0‥4095). + + Поддерживает калибровку: при включённом режиме калибровки запоминает + min/max по каждой оси; нормализация ведётся по откалиброванному диапазону. + """ + + _ADC_MAX = 4095.0 + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setFixedSize(_JOY_SIZE, _JOY_SIZE) + self._x = 0.5 + self._y = 0.5 + self._calibrating = False + self._cal_x_min: int | None = None + self._cal_x_max: int | None = None + self._cal_y_min: int | None = None + self._cal_y_max: int | None = None + + # ── Калибровка ──────────────────────────────────────────────────── + + def start_calibration(self) -> None: + self._calibrating = True + self._cal_x_min = None + self._cal_x_max = None + self._cal_y_min = None + self._cal_y_max = None + + def finish_calibration(self) -> None: + self._calibrating = False + + def reset_calibration(self) -> None: + self._calibrating = False + self._cal_x_min = None + self._cal_x_max = None + self._cal_y_min = None + self._cal_y_max = None + + @property + def is_calibrated(self) -> bool: + return self._cal_x_min is not None and self._cal_x_max is not None + + def calibration_text(self) -> str: + if not self.is_calibrated: + return "Нет" + return ( + f"X: {self._cal_x_min}–{self._cal_x_max} " + f"Y: {self._cal_y_min}–{self._cal_y_max}" + ) + + # ── Данные ──────────────────────────────────────────────────────── + + def _normalize(self, raw: int, cal_min: int | None, cal_max: int | None) -> float: + if cal_min is not None and cal_max is not None and cal_max > cal_min: + return max(0.0, min((raw - cal_min) / (cal_max - cal_min), 1.0)) + return max(0.0, min(raw / self._ADC_MAX, 1.0)) + + def set_values(self, raw_x: int, raw_y: int) -> None: + if self._calibrating: + if self._cal_x_min is None or raw_x < self._cal_x_min: + self._cal_x_min = raw_x + if self._cal_x_max is None or raw_x > self._cal_x_max: + self._cal_x_max = raw_x + if self._cal_y_min is None or raw_y < self._cal_y_min: + self._cal_y_min = raw_y + if self._cal_y_max is None or raw_y > self._cal_y_max: + self._cal_y_max = raw_y + self._x = self._normalize(raw_x, self._cal_x_min, self._cal_x_max) + self._y = self._normalize(raw_y, self._cal_y_min, self._cal_y_max) + self.update() + + # ── Рисование ───────────────────────────────────────────────────── + + def paintEvent(self, event) -> None: # noqa: N802 + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + w, h = self.width(), self.height() + cx, cy = w / 2.0, h / 2.0 + radius = min(w, h) / 2.0 - 4 + + p.setPen(QPen(QColor("#555555"), 1.5)) + p.setBrush(QBrush(QColor(40, 40, 40, 80))) + p.drawEllipse(QPointF(cx, cy), radius, radius) + + p.setPen(QPen(QColor("#444444"), 0.8)) + p.drawLine(QPointF(cx - radius, cy), QPointF(cx + radius, cy)) + p.drawLine(QPointF(cx, cy - radius), QPointF(cx, cy + radius)) + + px = cx + (self._x - 0.5) * 2.0 * radius + py = cy + (self._y - 0.5) * 2.0 * radius + dot_r = 6.0 + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(QColor("#10b981"))) + p.drawEllipse(QPointF(px, py), dot_r, dot_r) + + p.end() class _InputPollThread(QThread): @@ -182,6 +284,11 @@ class _InputPollThread(QThread): val = int(line[5:]) self.digital_ready.emit(val) break + # ADC-строки (от автовывода 92X) перехватываем + if line.upper().startswith("ADC"): + parts = line[3:].split(",") + self.analog_ready.emit(parts) + continue # Информационные строки перехватываем self._check_info_line(line) except Exception: @@ -285,23 +392,85 @@ class InputTestDialog(StyledDialog): 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=get_icon_color())) - poll_adc_btn.clicked.connect(self._poll_analog) - analog_inner.addWidget(poll_adc_btn) + # ── Аналоговые входы (джойстики) ── + analog_group = QGroupBox("Аналоговые входы — Джойстики") + analog_outer = QVBoxLayout(analog_group) + + # Два круга — джойстик 1 и джойстик 2 + joysticks_row = QHBoxLayout() + + # Джойстик 1 (ADC0, ADC1) + joy1_frame = QGroupBox("Джойстик 1") + joy1_layout = QVBoxLayout(joy1_frame) + self._joy1_canvas = _JoystickWidget() + joy1_layout.addWidget(self._joy1_canvas, alignment=Qt.AlignmentFlag.AlignCenter) + self._joy1_label = QLabel("X: — Y: —") + self._joy1_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + joy1_layout.addWidget(self._joy1_label) + self._joy1_cal_label = QLabel("Калибровка: Нет") + self._joy1_cal_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._joy1_cal_label.setStyleSheet("color: #888; font-size: 10px;") + joy1_layout.addWidget(self._joy1_cal_label) + joy1_btn_row = QHBoxLayout() + self._joy1_cal_btn = QPushButton("Калибровать") + self._joy1_cal_btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color())) + self._joy1_cal_btn.setToolTip("Покрутите джойстик по кругу, затем нажмите 'Готово'") + self._joy1_cal_btn.clicked.connect(lambda: self._toggle_calibration(1)) + joy1_btn_row.addWidget(self._joy1_cal_btn) + self._joy1_reset_btn = QPushButton("Сброс") + self._joy1_reset_btn.setIcon(icon("fa5s.undo", color=get_icon_color())) + self._joy1_reset_btn.setToolTip("Сбросить калибровку джойстика 1") + self._joy1_reset_btn.clicked.connect(lambda: self._reset_calibration(1)) + joy1_btn_row.addWidget(self._joy1_reset_btn) + joy1_layout.addLayout(joy1_btn_row) + joysticks_row.addWidget(joy1_frame) + + # Джойстик 2 (ADC2, ADC3) + joy2_frame = QGroupBox("Джойстик 2") + joy2_layout = QVBoxLayout(joy2_frame) + self._joy2_canvas = _JoystickWidget() + joy2_layout.addWidget(self._joy2_canvas, alignment=Qt.AlignmentFlag.AlignCenter) + self._joy2_label = QLabel("X: — Y: —") + self._joy2_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + joy2_layout.addWidget(self._joy2_label) + self._joy2_cal_label = QLabel("Калибровка: Нет") + self._joy2_cal_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._joy2_cal_label.setStyleSheet("color: #888; font-size: 10px;") + joy2_layout.addWidget(self._joy2_cal_label) + joy2_btn_row = QHBoxLayout() + self._joy2_cal_btn = QPushButton("Калибровать") + self._joy2_cal_btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color())) + self._joy2_cal_btn.setToolTip("Покрутите джойстик по кругу, затем нажмите 'Готово'") + self._joy2_cal_btn.clicked.connect(lambda: self._toggle_calibration(2)) + joy2_btn_row.addWidget(self._joy2_cal_btn) + self._joy2_reset_btn = QPushButton("Сброс") + self._joy2_reset_btn.setIcon(icon("fa5s.undo", color=get_icon_color())) + self._joy2_reset_btn.setToolTip("Сбросить калибровку джойстика 2") + self._joy2_reset_btn.clicked.connect(lambda: self._reset_calibration(2)) + joy2_btn_row.addWidget(self._joy2_reset_btn) + joy2_layout.addLayout(joy2_btn_row) + joysticks_row.addWidget(joy2_frame) + + analog_outer.addLayout(joysticks_row) + + # Управление автоопросом ADC + adc_ctrl_row = QHBoxLayout() + adc_ctrl_row.addWidget(QLabel("Интервал (мс):")) + self._adc_interval_spin = QSpinBox() + self._adc_interval_spin.setRange(50, 5000) + self._adc_interval_spin.setValue(100) + self._adc_interval_spin.setSingleStep(50) + adc_ctrl_row.addWidget(self._adc_interval_spin) + + self._adc_toggle_btn = QPushButton("Начать тест") + self._adc_toggle_btn.setIcon(icon("fa5s.play", color=get_icon_color())) + self._adc_toggle_btn.clicked.connect(self._toggle_adc_polling) + adc_ctrl_row.addWidget(self._adc_toggle_btn) + adc_ctrl_row.addStretch() + analog_outer.addLayout(adc_ctrl_row) + root.addWidget(analog_group) + self._adc_polling_active = False # ── Монетоприёмник ── coin_group = QGroupBox("Монетоприёмник") @@ -501,8 +670,22 @@ class InputTestDialog(StyledDialog): def _on_analog(self, values: list[str]) -> None: if self._closed: return + vals = [0, 0, 0, 0] for i, v in enumerate(values[:4]): - self._analog_labels[i].setText(f"ADC{i}: {v}") + try: + vals[i] = int(v) + except ValueError: + vals[i] = 0 + # Джойстик 1: ADC0 (X), ADC1 (Y) + self._joy1_canvas.set_values(vals[0], vals[1]) + self._joy1_label.setText(f"X: {vals[0]} Y: {vals[1]}") + if self._joy1_canvas._calibrating: + self._joy1_cal_label.setText("Калибровка: идёт… покрутите джойстик") + # Джойстик 2: ADC2 (X), ADC3 (Y) + self._joy2_canvas.set_values(vals[2], vals[3]) + self._joy2_label.setText(f"X: {vals[2]} Y: {vals[3]}") + if self._joy2_canvas._calibrating: + self._joy2_cal_label.setText("Калибровка: идёт… покрутите джойстик") # ── Прочее ──────────────────────────────────────────────────────── @@ -531,6 +714,53 @@ class InputTestDialog(StyledDialog): # Обновляем лог последнего события self._event_log.setText(f"← {line}") + def _toggle_adc_polling(self) -> None: + """Начать или прекратить периодический опрос ADC.""" + if not self._conn.is_connected or self._closed: + return + if self._adc_polling_active: + self._send_raw("92") + self._adc_polling_active = False + self._adc_toggle_btn.setText("Начать тест") + self._adc_toggle_btn.setIcon(icon("fa5s.play", color=get_icon_color())) + self._adc_interval_spin.setEnabled(True) + else: + interval = self._adc_interval_spin.value() + self._send_raw(f"92{interval}") + self._adc_polling_active = True + self._adc_toggle_btn.setText("Прекратить тест") + self._adc_toggle_btn.setIcon(icon("fa5s.stop", color=get_icon_color())) + self._adc_interval_spin.setEnabled(False) + + # ── Калибровка джойстиков ───────────────────────────────────────── + + def _toggle_calibration(self, joy: int) -> None: + """Начать/завершить калибровку джойстика 1 или 2.""" + canvas = self._joy1_canvas if joy == 1 else self._joy2_canvas + btn = self._joy1_cal_btn if joy == 1 else self._joy2_cal_btn + cal_label = self._joy1_cal_label if joy == 1 else self._joy2_cal_label + + if canvas._calibrating: + canvas.finish_calibration() + btn.setText("Калибровать") + btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color())) + cal_label.setText(f"Калибровка: {canvas.calibration_text()}") + else: + canvas.start_calibration() + btn.setText("Готово") + btn.setIcon(icon("fa5s.check", color=get_icon_color())) + cal_label.setText("Калибровка: идёт… покрутите джойстик") + + def _reset_calibration(self, joy: int) -> None: + """Сбросить калибровку джойстика.""" + canvas = self._joy1_canvas if joy == 1 else self._joy2_canvas + btn = self._joy1_cal_btn if joy == 1 else self._joy2_cal_btn + cal_label = self._joy1_cal_label if joy == 1 else self._joy2_cal_label + canvas.reset_calibration() + btn.setText("Калибровать") + btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color())) + cal_label.setText("Калибровка: Нет") + def _poll_analog(self) -> None: """Запросить однократный опрос аналоговых входов через поток.""" if not self._conn.is_connected or self._closed: @@ -661,6 +891,10 @@ class InputTestDialog(StyledDialog): def closeEvent(self, event) -> None: self._closed = True + # Если ADC автовывод был включён — выключить + if self._adc_polling_active: + self._adc_polling_active = False + self._poll_thread.send_raw("92") # Если автовыдача активна — выключаем мотор if self._auto_dispense_hopper != 0: off_cmd = "300" if self._auto_dispense_hopper == 1 else "400" diff --git a/app/widgets/dialogs/output_test_dialog.py b/app/widgets/dialogs/output_test_dialog.py index 6b4786f..5c87f9f 100644 --- a/app/widgets/dialogs/output_test_dialog.py +++ b/app/widgets/dialogs/output_test_dialog.py @@ -19,7 +19,9 @@ from PySide6.QtWidgets import ( ) from ...services.com_service import ComConnection +from ...services import settings_service from ...ui.icons import get_icon_color, get_icon_muted, icon +from ...ui.theme import get_palette from .styled_dialog import StyledDialog _NUM_OUTPUTS = 8 @@ -41,13 +43,15 @@ class OutputTestDialog(StyledDialog): root = self.containerLayout() root.setSpacing(12) + pal = get_palette(settings_service.theme_mode(), settings_service.theme_accent()) + # ── Описание ── desc = QLabel( "Управление силовыми выходами платы (команда 21XY).\n" "X — индекс выхода (0–7), Y — действие: 1=вкл, 0=выкл, 2=инверсия." ) desc.setWordWrap(True) - desc.setStyleSheet("color: #64748b; font-size: 12px; margin-bottom: 4px;") + desc.setStyleSheet(f"color: {pal.text_muted}; font-size: 12px; margin-bottom: 4px;") root.addWidget(desc) # ── Сетка выходов ── @@ -71,8 +75,9 @@ class OutputTestDialog(StyledDialog): 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;" + f"background: {pal.btn_bg}; border: 1px solid {pal.border}; " + f"border-radius: 6px; padding: 4px 8px; font-weight: 600;" + f" color: {pal.text_primary};" ) grid.addWidget(num_label, row, 0) diff --git a/app/widgets/dialogs/settings_dialog.py b/app/widgets/dialogs/settings_dialog.py index 86c9998..5fa9108 100644 --- a/app/widgets/dialogs/settings_dialog.py +++ b/app/widgets/dialogs/settings_dialog.py @@ -5,9 +5,11 @@ from __future__ import annotations from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QButtonGroup, + QCheckBox, QHBoxLayout, QLabel, QPushButton, + QSpinBox, QToolButton, QVBoxLayout, QWidget, @@ -22,6 +24,7 @@ class SettingsDialog(StyledDialog): """Compact settings dialog: accent colour + light/dark toggle.""" theme_changed = Signal() + health_settings_changed = Signal() _ACCENT_COLORS = { "green": "#16a34a", @@ -106,6 +109,32 @@ class SettingsDialog(StyledDialog): mode_row.addWidget(self._dark_btn) lay.addLayout(mode_row) + # ── Health-check ─────────────────────────────────────────── + hc_title = QLabel("Проверка состояния платы") + hc_title.setObjectName("SectionTitle") + lay.addWidget(hc_title) + + self._hc_enabled = QCheckBox("Автоматическая проверка связи") + self._hc_enabled.setChecked(settings_service.health_check_enabled()) + self._hc_enabled.toggled.connect(self._on_hc_toggled) + lay.addWidget(self._hc_enabled) + + hc_interval_row = QHBoxLayout() + hc_interval_row.setSpacing(8) + hc_interval_row.setAlignment(Qt.AlignmentFlag.AlignLeft) + + hc_interval_label = QLabel("Интервал (сек):") + hc_interval_row.addWidget(hc_interval_label) + + self._hc_interval = QSpinBox() + self._hc_interval.setRange(5, 60) + self._hc_interval.setSingleStep(5) + self._hc_interval.setValue(settings_service.health_check_interval()) + self._hc_interval.setEnabled(self._hc_enabled.isChecked()) + hc_interval_row.addWidget(self._hc_interval) + + lay.addLayout(hc_interval_row) + # ── Apply button ─────────────────────────────────────────── lay.addSpacing(8) @@ -130,12 +159,17 @@ class SettingsDialog(StyledDialog): def _selected_mode(self) -> str: return "dark" if self._dark_btn.isChecked() else "light" + def _on_hc_toggled(self, checked: bool) -> None: + self._hc_interval.setEnabled(checked) + 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) + settings_service.put("health_check_enabled", self._hc_enabled.isChecked()) + settings_service.put("health_check_interval", self._hc_interval.value()) # Walk up widget hierarchy to find MainWindow and ask it to apply theme from ...main_window import MainWindow @@ -157,4 +191,5 @@ class SettingsDialog(StyledDialog): app.setStyleSheet(build_stylesheet(palette)) self.theme_changed.emit() + self.health_settings_changed.emit() self.close() diff --git a/app/widgets/sections/__init__.py b/app/widgets/sections/__init__.py new file mode 100644 index 0000000..b0152c7 --- /dev/null +++ b/app/widgets/sections/__init__.py @@ -0,0 +1,6 @@ +from .led_test_section import LedTestSection +from .input_test_section import InputTestSection +from .firmware_section import FirmwareSection +from .payment_section import PaymentSection + +__all__ = ["LedTestSection", "InputTestSection", "FirmwareSection", "PaymentSection"] diff --git a/app/widgets/sections/firmware_section.py b/app/widgets/sections/firmware_section.py new file mode 100644 index 0000000..c60dfbc --- /dev/null +++ b/app/widgets/sections/firmware_section.py @@ -0,0 +1,212 @@ +"""Встраиваемая секция «Обновление прошивки» — загрузка и OTA. + +Повторяет функциональность FirmwareDialog, но как обычный QWidget. +""" + +from __future__ import annotations + +import os + +from PySide6.QtCore import QThread, Signal +from PySide6.QtWidgets import ( + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QProgressBar, + QPushButton, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from ...services.com_service import ComConnection +from ...ui.icons import get_icon_color, icon + +# Переиспользуем воркер из диалога +from ..dialogs.firmware_dialog import _UploadWorker + + +class FirmwareSection(QWidget): + """Inline-секция обновления прошивки (для базового режима).""" + + upload_started = Signal() + upload_finished = Signal() + + def __init__(self, connection: ComConnection, parent: QWidget | None = 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._need_ota: bool = False + self._setup_ui() + + # ── UI ──────────────────────────────────────────────────────────── + + def _setup_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(10) + + # ── Обновление прошивки ── + fw_group = QGroupBox("Обновление прошивки") + fw_layout = QVBoxLayout(fw_group) + + file_layout = QHBoxLayout() + self._file_label = QLabel("Файл не выбран") + self._file_label.setWordWrap(True) + file_layout.addWidget(self._file_label, 1) + + self._pick_btn = QPushButton("Выбрать .bin") + self._pick_btn.setIcon(icon("fa5s.folder-open", color=get_icon_color())) + self._pick_btn.clicked.connect(self._pick_file) + file_layout.addWidget(self._pick_btn) + fw_layout.addLayout(file_layout) + + self._progress = QProgressBar() + self._progress.setValue(0) + fw_layout.addWidget(self._progress) + + self._update_btn = QPushButton("Обновить прошивку") + self._update_btn.setProperty("role", "primary") + self._update_btn.setIcon(icon("fa5s.upload", color="#ecfdf5")) + self._update_btn.clicked.connect(self._start_update) + fw_layout.addWidget(self._update_btn) + + root.addWidget(fw_group) + + # ── Лог ── + self._log = QTextEdit() + self._log.setReadOnly(True) + self._log.setMaximumHeight(160) + root.addWidget(self._log) + + # ── Public ──────────────────────────────────────────────────────── + + def refresh_icons(self) -> None: + clr = get_icon_color() + self._pick_btn.setIcon(icon("fa5s.folder-open", color=clr)) + + def cancel_upload(self) -> None: + """Прервать загрузку (если идёт) — вызывается при скрытии секции.""" + if self._upload_thread is not None and self._upload_thread.isRunning(): + self._upload_thread.quit() + self._upload_thread.wait(3000) + + # ── Загрузка файла ──────────────────────────────────────────────── + + 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._progress.setValue(0) + + def _start_update(self) -> None: + """Загрузить файл на плату (02), а затем автоматически прошить (04).""" + if not self._firmware_path: + QMessageBox.warning(self, "Ошибка", "Сначала выберите файл прошивки.") + return + if not self._conn.is_connected: + QMessageBox.warning(self, "Ошибка", "Порт не подключён.") + return + + self._update_btn.setEnabled(False) + self._pick_btn.setEnabled(False) + self._progress.setValue(0) + self._log.append("Запуск загрузки…") + + self.upload_started.emit() + + # Очистить serial-буфер от остатков предыдущего опроса + try: + ser = self._conn.serial + if ser is not None and ser.is_open: + ser.reset_input_buffer() + except Exception: + pass + + 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 + # Worker thread полностью завершён, finally выполнен, lock свободен. + # Теперь безопасно запускать OTA. + if self._need_ota: + self._need_ota = False + self._flash_ota() + + def _on_upload_progress(self, percent: int) -> None: + self._progress.setValue(percent) + + def _on_upload_done(self, success: bool) -> None: + if success: + self._log.append("✓ Загрузка завершена. Запуск OTA (04)…") + # Не вызываем _flash_ota сразу: worker thread ещё в finally, + # io_lock может быть занят. Отложим до _cleanup_upload, + # который сработает после реального завершения потока. + self._need_ota = True + else: + self._need_ota = False + self._log.append("✗ Загрузка не удалась.") + self._update_btn.setEnabled(True) + self._pick_btn.setEnabled(True) + self.upload_finished.emit() + + def _flash_ota(self) -> None: + if not self._conn.is_connected: + self._log.append("✗ Порт не подключён.") + self._update_btn.setEnabled(True) + self._pick_btn.setEnabled(True) + self.upload_finished.emit() + return + try: + self._conn.send_command("04") + self._log.append("→ 04") + + # Контроллер шлёт прогресс 1-100, затем «Successful update» + # и через ~4 сек перезагружается с «READY!». + # Ждём до 60 сек (перепрошивка + перезагрузка). + import time + deadline = time.time() + 60.0 + success = False + + while time.time() < deadline: + remaining = max(deadline - time.time(), 0.1) + resp = self._conn.read_response(timeout=min(remaining, 2.0)) + if resp is None: + continue + self._log.append(f"← {resp}") + if "ready" in resp.lower(): + success = True + break + + self._conn.release() + + if success: + self._log.append("✓ Прошивка обновлена, плата перезагружена.") + else: + self._log.append("← таймаут (нет ответа)") + except Exception as exc: + self._conn.release() + self._log.append(f"Ошибка OTA: {exc}") + self._update_btn.setEnabled(True) + self._pick_btn.setEnabled(True) + self.upload_finished.emit() diff --git a/app/widgets/sections/input_test_section.py b/app/widgets/sections/input_test_section.py new file mode 100644 index 0000000..d830faa --- /dev/null +++ b/app/widgets/sections/input_test_section.py @@ -0,0 +1,599 @@ +"""Встраиваемая секция «Проверка ввода» — цифровые/аналоговые входы. + +Повторяет функциональность InputTestDialog, но как обычный QWidget. +Поток опроса запускается при вызове start_polling() и останавливается +при stop_polling() или скрытии виджета. +""" + +from __future__ import annotations + +import os +import subprocess +import sys as _sys + +from PySide6.QtCore import Qt, QTimer, QPointF +from PySide6.QtGui import QPainter, QPen, QBrush, QColor +from PySide6.QtWidgets import ( + QComboBox, + QGridLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QPushButton, + QSizePolicy, + QSpinBox, + QVBoxLayout, + QWidget, +) + +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 get_icon_color, get_icon_muted, icon + +# Переиспользуем поток из диалога — без дублирования кода +from ..dialogs.input_test_dialog import _InputPollThread + +_NUM_DIGITAL = 26 # 24 входа + 2 хоппера + +_JOY_SIZE = 120 # размер виджета джойстика + + +class _JoystickWidget(QWidget): + """Визуальный круг джойстика — показывает позицию по двум осям (0‥4095). + + Поддерживает калибровку: при включённом режиме калибровки запоминает + min/max по каждой оси; нормализация ведётся по откалиброванному диапазону. + """ + + _ADC_MAX = 4095.0 + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setFixedSize(_JOY_SIZE, _JOY_SIZE) + self._x = 0.5 # нормализованные 0..1 + self._y = 0.5 + # Калибровка: min/max для каждой оси + self._calibrating = False + self._cal_x_min: int | None = None + self._cal_x_max: int | None = None + self._cal_y_min: int | None = None + self._cal_y_max: int | None = None + + # ── Калибровка ──────────────────────────────────────────────────── + + def start_calibration(self) -> None: + """Начать калибровку: сбросить min/max и начать запоминать.""" + self._calibrating = True + self._cal_x_min = None + self._cal_x_max = None + self._cal_y_min = None + self._cal_y_max = None + + def finish_calibration(self) -> None: + """Завершить калибровку — min/max зафиксированы.""" + self._calibrating = False + + def reset_calibration(self) -> None: + """Сбросить калибровку — вернуться к диапазону 0‥4095.""" + self._calibrating = False + self._cal_x_min = None + self._cal_x_max = None + self._cal_y_min = None + self._cal_y_max = None + + @property + def is_calibrated(self) -> bool: + return self._cal_x_min is not None and self._cal_x_max is not None + + def calibration_text(self) -> str: + """Текстовое представление калибровки.""" + if not self.is_calibrated: + return "Нет" + return ( + f"X: {self._cal_x_min}–{self._cal_x_max} " + f"Y: {self._cal_y_min}–{self._cal_y_max}" + ) + + # ── Данные ──────────────────────────────────────────────────────── + + def _normalize(self, raw: int, cal_min: int | None, cal_max: int | None) -> float: + """Нормализовать значение в 0..1 с учётом калибровки.""" + if cal_min is not None and cal_max is not None and cal_max > cal_min: + return max(0.0, min((raw - cal_min) / (cal_max - cal_min), 1.0)) + return max(0.0, min(raw / self._ADC_MAX, 1.0)) + + def set_values(self, raw_x: int, raw_y: int) -> None: + """Принять сырые ADC-значения (0‥4095) и перерисовать.""" + # Обновляем калибровку + if self._calibrating: + if self._cal_x_min is None or raw_x < self._cal_x_min: + self._cal_x_min = raw_x + if self._cal_x_max is None or raw_x > self._cal_x_max: + self._cal_x_max = raw_x + if self._cal_y_min is None or raw_y < self._cal_y_min: + self._cal_y_min = raw_y + if self._cal_y_max is None or raw_y > self._cal_y_max: + self._cal_y_max = raw_y + + self._x = self._normalize(raw_x, self._cal_x_min, self._cal_x_max) + self._y = self._normalize(raw_y, self._cal_y_min, self._cal_y_max) + self.update() + + # ── Рисование ───────────────────────────────────────────────────── + + def paintEvent(self, event) -> None: # noqa: N802 + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing) + w, h = self.width(), self.height() + cx, cy = w / 2.0, h / 2.0 + radius = min(w, h) / 2.0 - 4 + + # Фон — круг + p.setPen(QPen(QColor("#555555"), 1.5)) + p.setBrush(QBrush(QColor(40, 40, 40, 80))) + p.drawEllipse(QPointF(cx, cy), radius, radius) + + # Крест + p.setPen(QPen(QColor("#444444"), 0.8)) + p.drawLine(QPointF(cx - radius, cy), QPointF(cx + radius, cy)) + p.drawLine(QPointF(cx, cy - radius), QPointF(cx, cy + radius)) + + # Точка — положение джойстика + px = cx + (self._x - 0.5) * 2.0 * radius + py = cy + (self._y - 0.5) * 2.0 * radius + dot_r = 6.0 + p.setPen(Qt.PenStyle.NoPen) + p.setBrush(QBrush(QColor("#10b981"))) + p.drawEllipse(QPointF(px, py), dot_r, dot_r) + + p.end() + + +class InputTestSection(QWidget): + """Inline-секция проверки ввода (для базового режима).""" + + def __init__(self, connection: ComConnection, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._conn = connection + self._closed = False + self._polling = False + self._poll_thread: _InputPollThread | None = None + + self._mappings = load_all_input_mappings() + self._current_mapping: InputMapping | None = None + + self._setup_ui() + self._apply_mapping() + + # ── UI ──────────────────────────────────────────────────────────── + + def _setup_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(10) + + # ── Выбор маппинга ── + 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) + + self._reload_btn = QPushButton() + self._reload_btn.setIcon(icon("fa5s.sync-alt", color=get_icon_color())) + self._reload_btn.setToolTip("Перечитать файлы раскладок") + self._reload_btn.setFixedWidth(36) + self._reload_btn.clicked.connect(self._reload_mappings) + mapping_row.addWidget(self._reload_btn) + + self._open_dir_btn = QPushButton() + self._open_dir_btn.setIcon(icon("fa5s.folder-open", color=get_icon_color())) + self._open_dir_btn.setToolTip("Открыть папку input_mappings") + self._open_dir_btn.setFixedWidth(36) + self._open_dir_btn.clicked.connect(self._open_mappings_dir) + mapping_row.addWidget(self._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] = {} + 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("Аналоговые входы — Джойстики") + analog_outer = QVBoxLayout(analog_group) + + # Два круга — джойстик 1 и джойстик 2 + joysticks_row = QHBoxLayout() + + # Джойстик 1 (ADC0, ADC1) + joy1_frame = QGroupBox("Джойстик 1") + joy1_layout = QVBoxLayout(joy1_frame) + self._joy1_canvas = _JoystickWidget() + joy1_layout.addWidget(self._joy1_canvas, alignment=Qt.AlignmentFlag.AlignCenter) + self._joy1_label = QLabel("X: — Y: —") + self._joy1_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + joy1_layout.addWidget(self._joy1_label) + self._joy1_cal_label = QLabel("Калибровка: Нет") + self._joy1_cal_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._joy1_cal_label.setStyleSheet("color: #888; font-size: 10px;") + joy1_layout.addWidget(self._joy1_cal_label) + joy1_btn_row = QHBoxLayout() + self._joy1_cal_btn = QPushButton("Калибровать") + self._joy1_cal_btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color())) + self._joy1_cal_btn.setToolTip("Покрутите джойстик по кругу, затем нажмите 'Готово'") + self._joy1_cal_btn.clicked.connect(lambda: self._toggle_calibration(1)) + joy1_btn_row.addWidget(self._joy1_cal_btn) + self._joy1_reset_btn = QPushButton("Сброс") + self._joy1_reset_btn.setIcon(icon("fa5s.undo", color=get_icon_color())) + self._joy1_reset_btn.setToolTip("Сбросить калибровку джойстика 1") + self._joy1_reset_btn.clicked.connect(lambda: self._reset_calibration(1)) + joy1_btn_row.addWidget(self._joy1_reset_btn) + joy1_layout.addLayout(joy1_btn_row) + joysticks_row.addWidget(joy1_frame) + + # Джойстик 2 (ADC2, ADC3) + joy2_frame = QGroupBox("Джойстик 2") + joy2_layout = QVBoxLayout(joy2_frame) + self._joy2_canvas = _JoystickWidget() + joy2_layout.addWidget(self._joy2_canvas, alignment=Qt.AlignmentFlag.AlignCenter) + self._joy2_label = QLabel("X: — Y: —") + self._joy2_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + joy2_layout.addWidget(self._joy2_label) + self._joy2_cal_label = QLabel("Калибровка: Нет") + self._joy2_cal_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._joy2_cal_label.setStyleSheet("color: #888; font-size: 10px;") + joy2_layout.addWidget(self._joy2_cal_label) + joy2_btn_row = QHBoxLayout() + self._joy2_cal_btn = QPushButton("Калибровать") + self._joy2_cal_btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color())) + self._joy2_cal_btn.setToolTip("Покрутите джойстик по кругу, затем нажмите 'Готово'") + self._joy2_cal_btn.clicked.connect(lambda: self._toggle_calibration(2)) + joy2_btn_row.addWidget(self._joy2_cal_btn) + self._joy2_reset_btn = QPushButton("Сброс") + self._joy2_reset_btn.setIcon(icon("fa5s.undo", color=get_icon_color())) + self._joy2_reset_btn.setToolTip("Сбросить калибровку джойстика 2") + self._joy2_reset_btn.clicked.connect(lambda: self._reset_calibration(2)) + joy2_btn_row.addWidget(self._joy2_reset_btn) + joy2_layout.addLayout(joy2_btn_row) + joysticks_row.addWidget(joy2_frame) + + analog_outer.addLayout(joysticks_row) + + # Управление опросом + adc_ctrl_row = QHBoxLayout() + adc_ctrl_row.addWidget(QLabel("Интервал (мс):")) + self._adc_interval_spin = QSpinBox() + self._adc_interval_spin.setRange(50, 5000) + self._adc_interval_spin.setValue(100) + self._adc_interval_spin.setSingleStep(50) + adc_ctrl_row.addWidget(self._adc_interval_spin) + + self._adc_toggle_btn = QPushButton("Начать тест") + self._adc_toggle_btn.setIcon(icon("fa5s.play", color=get_icon_color())) + self._adc_toggle_btn.clicked.connect(self._toggle_adc_polling) + adc_ctrl_row.addWidget(self._adc_toggle_btn) + adc_ctrl_row.addStretch() + analog_outer.addLayout(adc_ctrl_row) + + root.addWidget(analog_group) + self._adc_polling_active = False + + # ── Лог событий ── + 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) + self._set_debounce_btn = QPushButton("Применить") + self._set_debounce_btn.setIcon(icon("fa5s.check", color=get_icon_color())) + self._set_debounce_btn.clicked.connect(self._set_debounce) + debounce_layout.addWidget(self._set_debounce_btn) + debounce_layout.addStretch() + root.addLayout(debounce_layout) + + # ── Public API ──────────────────────────────────────────────────── + + def start_polling(self) -> None: + """Запустить поток опроса входов. Вызывать после подключения.""" + if self._polling: + return + if not self._conn.is_connected: + return + self._closed = False + self._conn.pause_drain() + self._poll_thread = _InputPollThread(self._conn) + 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() + self._polling = True + + def stop_polling(self) -> None: + """Остановить поток опроса и вернуть drain.""" + if not self._polling: + return + # Если ADC автовывод был включён — выключить + if self._adc_polling_active: + self._adc_polling_active = False + self._adc_toggle_btn.setText("Начать тест") + self._adc_toggle_btn.setIcon(icon("fa5s.play", color=get_icon_color())) + self._adc_interval_spin.setEnabled(True) + # Отправляем «92» напрямую через serial (поток ещё жив) + if self._poll_thread is not None: + self._poll_thread.send_raw("92") + self._closed = True + if self._poll_thread is not None: + 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) + self._poll_thread = None + # Очищаем буфер 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() + self._polling = False + + def refresh_icons(self) -> None: + clr = get_icon_color() + self._reload_btn.setIcon(icon("fa5s.sync-alt", color=clr)) + self._open_dir_btn.setIcon(icon("fa5s.folder-open", color=clr)) + if not self._adc_polling_active: + self._adc_toggle_btn.setIcon(icon("fa5s.play", color=clr)) + else: + self._adc_toggle_btn.setIcon(icon("fa5s.stop", color=clr)) + self._set_debounce_btn.setIcon(icon("fa5s.check", color=clr)) + self._joy1_cal_btn.setIcon(icon("fa5s.crosshairs", color=clr)) + self._joy1_reset_btn.setIcon(icon("fa5s.undo", color=clr)) + self._joy2_cal_btn.setIcon(icon("fa5s.crosshairs", color=clr)) + self._joy2_reset_btn.setIcon(icon("fa5s.undo", color=clr)) + + # ── Маппинги ────────────────────────────────────────────────────── + + 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() + + 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) + 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 + for i, lbl in enumerate(self._digital_labels): + active = bool(val & (1 << i)) + pressed = not active + 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 + # values = ["v0", "v1", "v2", "v3"] + vals = [0, 0, 0, 0] + for i, v in enumerate(values[:4]): + try: + vals[i] = int(v) + except ValueError: + vals[i] = 0 + # Джойстик 1: ADC0 (X), ADC1 (Y) + self._joy1_canvas.set_values(vals[0], vals[1]) + self._joy1_label.setText(f"X: {vals[0]} Y: {vals[1]}") + if self._joy1_canvas._calibrating: + self._joy1_cal_label.setText("Калибровка: идёт… покрутите джойстик") + # Джойстик 2: ADC2 (X), ADC3 (Y) + self._joy2_canvas.set_values(vals[2], vals[3]) + self._joy2_label.setText(f"X: {vals[2]} Y: {vals[3]}") + if self._joy2_canvas._calibrating: + self._joy2_cal_label.setText("Калибровка: идёт… покрутите джойстик") + + # ── Прочее ──────────────────────────────────────────────────────── + + def _send_raw(self, cmd: str) -> None: + if not self._conn.is_connected or self._closed: + return + if self._poll_thread is not None: + self._poll_thread.send_raw(cmd) + + def _on_info_line(self, line: str) -> None: + if self._closed: + return + self._event_log.setText(f"← {line}") + + def _toggle_adc_polling(self) -> None: + """Начать или прекратить периодический опрос ADC.""" + if not self._conn.is_connected or self._closed: + return + if self._adc_polling_active: + # Прекратить — отправить «92» (без параметра) + self._send_raw("92") + self._adc_polling_active = False + self._adc_toggle_btn.setText("Начать тест") + self._adc_toggle_btn.setIcon(icon("fa5s.play", color=get_icon_color())) + self._adc_interval_spin.setEnabled(True) + else: + # Начать — отправить «92» + interval = self._adc_interval_spin.value() + self._send_raw(f"92{interval}") + self._adc_polling_active = True + self._adc_toggle_btn.setText("Прекратить тест") + self._adc_toggle_btn.setIcon(icon("fa5s.stop", color=get_icon_color())) + self._adc_interval_spin.setEnabled(False) + + # ── Калибровка джойстиков ───────────────────────────────────────── + + def _toggle_calibration(self, joy: int) -> None: + """Начать/завершить калибровку джойстика 1 или 2.""" + canvas = self._joy1_canvas if joy == 1 else self._joy2_canvas + btn = self._joy1_cal_btn if joy == 1 else self._joy2_cal_btn + cal_label = self._joy1_cal_label if joy == 1 else self._joy2_cal_label + + if canvas._calibrating: + # Завершить калибровку + canvas.finish_calibration() + btn.setText("Калибровать") + btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color())) + cal_label.setText(f"Калибровка: {canvas.calibration_text()}") + else: + # Начать калибровку + canvas.start_calibration() + btn.setText("Готово") + btn.setIcon(icon("fa5s.check", color=get_icon_color())) + cal_label.setText("Калибровка: идёт… покрутите джойстик") + + def _reset_calibration(self, joy: int) -> None: + """Сбросить калибровку джойстика.""" + canvas = self._joy1_canvas if joy == 1 else self._joy2_canvas + btn = self._joy1_cal_btn if joy == 1 else self._joy2_cal_btn + cal_label = self._joy1_cal_label if joy == 1 else self._joy2_cal_label + canvas.reset_calibration() + btn.setText("Калибровать") + btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color())) + cal_label.setText("Калибровка: Нет") + + def _set_debounce(self) -> None: + if not self._conn.is_connected or self._closed: + return + val = self._debounce_spin.value() + if self._poll_thread is not None: + self._poll_thread.request_debounce(val) + + def _reload_mappings(self) -> None: + 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: + 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 diff --git a/app/widgets/sections/led_test_section.py b/app/widgets/sections/led_test_section.py new file mode 100644 index 0000000..22a99b7 --- /dev/null +++ b/app/widgets/sections/led_test_section.py @@ -0,0 +1,131 @@ +"""Встраиваемая секция «Тест цвета» — цветовое чередование диодов. + +Позволяет задать порядок цветов (RGB/RBG/…) командой 89 +и запустить тест: красный → зелёный → синий. +""" + +from __future__ import annotations + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import ( + QComboBox, + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) + +from ...services.com_service import ComConnection +from ...ui.icons import icon + +_COLOR_ORDERS: list[tuple[int, str]] = [ + (0, "RGB"), + (1, "RBG"), + (2, "BRG"), + (3, "BGR"), + (4, "GRB"), + (5, "GBR"), +] + + +class LedTestSection(QWidget): + """Inline-секция тест цвета (для базового режима).""" + + def __init__(self, connection: ComConnection, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._conn = connection + self._setup_ui() + + # ── UI ──────────────────────────────────────────────────────────── + + def _setup_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(10) + + # ── Тест цвета ── + 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) + + self._set_color_btn = QPushButton("Применить") + self._set_color_btn.setProperty("role", "primary") + self._set_color_btn.setIcon(icon("fa5s.palette", color="#ecfdf5")) + self._set_color_btn.clicked.connect(self._send_color_order) + color_row1.addWidget(self._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) + + # ── Public ──────────────────────────────────────────────────────── + + def refresh_icons(self) -> None: + """Нет theme-dependent иконок — ничего не делаем.""" + + # ── Отправка ────────────────────────────────────────────────────── + + def _send_color_order(self) -> None: + code = self._color_order_combo.currentData() + self._send(f"8900{code}") + + def _run_color_test(self) -> None: + self._color_test_cmds = ["8003", "8004", "8005", "8002", "8000"] + self._color_test_idx = 0 + self._color_test_btn.setEnabled(False) + self._color_test_timer = QTimer(self) + self._color_test_timer.setInterval(300) + self._color_test_timer.timeout.connect(self._color_test_step) + self._color_test_step() + self._color_test_timer.start() + + def _color_test_step(self) -> None: + if self._color_test_idx >= len(self._color_test_cmds): + self._color_test_timer.stop() + self._color_test_btn.setEnabled(True) + return + cmd = self._color_test_cmds[self._color_test_idx] + self._color_test_idx += 1 + self._send(cmd) + + def _send(self, cmd: str) -> None: + if not cmd: + return + if not self._conn.is_connected: + QMessageBox.warning(self, "Ошибка", "Порт не подключён.") + return + try: + self._conn.send_command(cmd) + self._log.setText(f"→ {cmd}") + resp = self._conn.read_response(timeout=0.3) + self._conn.release() + if resp: + self._log.setText(f"→ {cmd}\n← {resp}") + except Exception as exc: + self._conn.release() + self._log.setText(f"Ошибка: {exc}") diff --git a/app/widgets/sections/payment_section.py b/app/widgets/sections/payment_section.py new file mode 100644 index 0000000..c0c3774 --- /dev/null +++ b/app/widgets/sections/payment_section.py @@ -0,0 +1,208 @@ +"""Встраиваемая секция «Тест оплаты и призов» — монетоприёмник и хопперы. + +Виджет не управляет потоком опроса самостоятельно — принимает callable +для отправки команд и вызывается снаружи при поступлении info-строк. +""" + +from __future__ import annotations + +from collections.abc import Callable + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import ( + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) + +from ...ui.icons import get_icon_color, icon + + +class PaymentSection(QWidget): + """Inline-секция оплаты и призов (для базового режима).""" + + def __init__( + self, + send_raw: Callable[[str], None], + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._send_raw = send_raw + self._auto_dispense_hopper: int = 0 + self._auto_dispense_timer: QTimer | None = None + # Нужен доступ к digital-маске для автовыдачи — будет передаваться снаружи + self._last_digital_val: int = 0xFFFFFFFF + self._setup_ui() + + # ── UI ──────────────────────────────────────────────────────────── + + def _setup_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(10) + + # ── Монетоприёмник ── + coin_group = QGroupBox("Монетоприёмник") + coin_layout = QHBoxLayout(coin_group) + self._coin_label = QLabel("Монеты: —") + self._coin_label.setMinimumWidth(120) + coin_layout.addWidget(self._coin_label) + self._coin_confirm_btn = QPushButton("Подтвердить (10)") + self._coin_confirm_btn.setIcon(icon("fa5s.check-circle", color=get_icon_color())) + self._coin_confirm_btn.setToolTip("Отправить подтверждение COIN (команда 10)") + self._coin_confirm_btn.clicked.connect(lambda: self._send_raw("10")) + coin_layout.addWidget(self._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) + self._h1_on = QPushButton("Вкл") + self._h1_on.setIcon(icon("fa5s.play", color=get_icon_color())) + self._h1_on.setToolTip("Включить мотор (301)") + self._h1_on.clicked.connect(lambda: self._send_raw("301")) + h1_row.addWidget(self._h1_on) + self._h1_off = QPushButton("Выкл") + self._h1_off.setIcon(icon("fa5s.stop", color=get_icon_color())) + self._h1_off.setToolTip("Выключить мотор (300)") + self._h1_off.clicked.connect(lambda: self._send_raw("300")) + h1_row.addWidget(self._h1_off) + self._h1_auto_btn = QPushButton("Автовыдача") + self._h1_auto_btn.setIcon(icon("fa5s.cog", color=get_icon_color())) + 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_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) + self._h2_on = QPushButton("Вкл") + self._h2_on.setIcon(icon("fa5s.play", color=get_icon_color())) + self._h2_on.setToolTip("Включить мотор (401)") + self._h2_on.clicked.connect(lambda: self._send_raw("401")) + h2_row.addWidget(self._h2_on) + self._h2_off = QPushButton("Выкл") + self._h2_off.setIcon(icon("fa5s.stop", color=get_icon_color())) + self._h2_off.setToolTip("Выключить мотор (400)") + self._h2_off.clicked.connect(lambda: self._send_raw("400")) + h2_row.addWidget(self._h2_off) + self._h2_auto_btn = QPushButton("Автовыдача") + self._h2_auto_btn.setIcon(icon("fa5s.cog", color=get_icon_color())) + 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_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) + + # ── Public ──────────────────────────────────────────────────────── + + def refresh_icons(self) -> None: + clr = get_icon_color() + self._coin_confirm_btn.setIcon(icon("fa5s.check-circle", color=clr)) + self._h1_on.setIcon(icon("fa5s.play", color=clr)) + self._h1_off.setIcon(icon("fa5s.stop", color=clr)) + self._h1_auto_btn.setIcon(icon("fa5s.cog", color=clr)) + self._h2_on.setIcon(icon("fa5s.play", color=clr)) + self._h2_off.setIcon(icon("fa5s.stop", color=clr)) + self._h2_auto_btn.setIcon(icon("fa5s.cog", color=clr)) + + def on_info_line(self, line: str) -> None: + """Обработать информационную строку (COINS, HOPPER, PRIZES).""" + 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 on_digital(self, val: int) -> None: + """Получить текущее значение цифровых входов (для автовыдачи).""" + self._last_digital_val = val + self._check_auto_dispense(val) + + # ── Автовыдача ──────────────────────────────────────────────────── + + def _start_auto_dispense(self, hopper: int) -> None: + 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) + 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: + hopper = self._auto_dispense_hopper + if hopper == 0: + return + bit_index = 23 + hopper + sensor_active = not bool(val & (1 << bit_index)) + if sensor_active: + self._finish_auto_dispense(success=True) + + def _auto_dispense_timeout(self) -> None: + 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 секунд.", + )