v1.2.0: OTA fix for basic mode, version display in title bar
This commit is contained in:
parent
76272433f8
commit
763913ffb8
@ -1 +1,3 @@
|
||||
"""Plate Config App package."""
|
||||
|
||||
__version__ = "1.2.0"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
158
app/ui/theme.py
158
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 = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 12">'
|
||||
f'<polyline points="{points}" fill="none" '
|
||||
f'stroke="{color}" stroke-width="2" '
|
||||
'stroke-linecap="round" stroke-linejoin="round"/>'
|
||||
'</svg>'
|
||||
)
|
||||
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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
6
app/widgets/sections/__init__.py
Normal file
6
app/widgets/sections/__init__.py
Normal file
@ -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"]
|
||||
212
app/widgets/sections/firmware_section.py
Normal file
212
app/widgets/sections/firmware_section.py
Normal file
@ -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()
|
||||
599
app/widgets/sections/input_test_section.py
Normal file
599
app/widgets/sections/input_test_section.py
Normal file
@ -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>»
|
||||
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
|
||||
131
app/widgets/sections/led_test_section.py
Normal file
131
app/widgets/sections/led_test_section.py
Normal file
@ -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}")
|
||||
208
app/widgets/sections/payment_section.py
Normal file
208
app/widgets/sections/payment_section.py
Normal file
@ -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 секунд.",
|
||||
)
|
||||
Loading…
x
Reference in New Issue
Block a user