diff --git a/.gitignore b/.gitignore index a4e2c6f..6bccc6b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ dist/ # ── IDE / Editor ────────────────────────────────────── .vscode/ .idea/ +.codex/ *.swp *.swo *~ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8699907 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,22 @@ +# Agent Guidance + +## Documentation Language + +- Write all project documentation in Russian by default. +- Apply this rule to guides, READMEs, setup notes, conventions, and other repository documentation unless the user explicitly asks for another language. + +## Git Commit Messages + +- При создании коммитов первая строка сообщения должна быть кратким описанием изменений на английском языке. +- Начиная с третьей строки сообщения коммита следует указывать подробный список изменений на русском языке. + +## MCP Tool Preference + +- При подходящих сценариях агенту следует по максимуму отдавать приоритет MCP-серверам `claude-core`, `claude-explorer` и `claude-rag` вместо встроенных инструментов. +- `claude-core` следует предпочитать для чтения и редактирования файлов, поиска по проекту, работы с задачами и базовых shell-операций, если его возможностей достаточно. +- `claude-explorer` следует предпочитать для изучения архитектуры, исходников, команд и внутренних инструментов Claude Code. +- `claude-rag` следует предпочитать для семантического поиска по проекту, анализа больших объёмов кода и ситуаций, где локальный индекс даёт преимущество над обычным текстовым поиском. +- Память о проекте, его договорённостях, архитектурных решениях, важных гайдах, кодовой базе и ранее выявленном контексте следует по возможности сохранять и повторно использовать через `claude-rag`. +- Кодовую базу проекта также следует по возможности индексировать и использовать через `claude-rag`, чтобы агент мог опираться на локальную семантическую память о структуре, модулях, связях и ранее изученных участках кода. +- При повторяющихся задачах, исследовании крупных подсистем, возвращении к старым решениям, поиске проектного контекста и анализе существующей кодовой базы агенту следует сначала опираться на `claude-rag`, если для этого подходит локальный индекс. +- Если MCP-инструмент хуже подходит для конкретной задачи, недоступен или создаёт лишние ограничения, агент может использовать встроенные инструменты как запасной вариант. diff --git a/app/main_window.py b/app/main_window.py index 9be3aad..8ab3d0e 100644 --- a/app/main_window.py +++ b/app/main_window.py @@ -1,7 +1,16 @@ from __future__ import annotations -from PySide6.QtCore import QSize, Qt -from PySide6.QtGui import QCloseEvent +from PySide6.QtCore import QSize, Qt, QRectF +from PySide6.QtGui import ( + QCloseEvent, + QColor, + QPainter, + QPainterPath, + QPen, + QRegion, + QResizeEvent, + QShowEvent, +) from PySide6.QtWidgets import ( QFrame, QHBoxLayout, @@ -15,6 +24,7 @@ from PySide6.QtWidgets import ( from .ui.icons import ICON_COLOR_ACCENT, ICON_COLOR_MUTED, ICON_COLOR_PRIMARY, icon from .ui.theme import build_stylesheet, get_palette +from .services import settings_service from .widgets.connection_tab import ConnectionTab from .widgets.start_tab import StartTab from .widgets.title_bar import TitleBar @@ -22,7 +32,39 @@ from .widgets.title_bar import TitleBar from . import __version__ +class _WindowBorderOverlay(QWidget): + """Paints the accent border above all window content.""" + + def __init__(self, parent: QWidget, radius: int) -> None: + super().__init__(parent) + self._radius = radius + self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True) + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, False) + self.setObjectName("WindowBorderOverlay") + + def paintEvent(self, event) -> None: # type: ignore[override] + parent = self.parentWidget() + if parent is None: + return + if parent.isMaximized() or parent.isFullScreen(): + return + + accent = get_palette( + settings_service.theme_mode(), + settings_service.theme_accent(), + ).accent + + p = QPainter(self) + p.setRenderHint(QPainter.RenderHint.Antialiasing, True) + p.setBrush(Qt.BrushStyle.NoBrush) + p.setPen(QPen(QColor(accent), 2)) + rect = self.rect().adjusted(1, 1, -1, -1) + p.drawRoundedRect(QRectF(rect), self._radius, self._radius) + p.end() + + class MainWindow(QMainWindow): + _WINDOW_RADIUS = 10 def __init__(self) -> None: super().__init__() @@ -62,11 +104,39 @@ class MainWindow(QMainWindow): container_layout.addWidget(self.content_frame, 1) self.setCentralWidget(container) + self._border_overlay = _WindowBorderOverlay(self, self._WINDOW_RADIUS) + self._border_overlay.setGeometry(self.rect()) + self._border_overlay.raise_() start_tab = StartTab(self.new_connection_tab) self._add_tab(start_tab, "Главная", icon_name="fa5s.home", closable=False) self.tabs.setCurrentIndex(0) + def _update_window_mask(self) -> None: + """Apply rounded mask so bottom corners are physically clipped.""" + if self.isMaximized() or self.isFullScreen(): + self.clearMask() + return + if self.width() <= 0 or self.height() <= 0: + return + path = QPainterPath() + path.addRoundedRect(QRectF(self.rect()), self._WINDOW_RADIUS, self._WINDOW_RADIUS) + self.setMask(QRegion(path.toFillPolygon().toPolygon())) + + def resizeEvent(self, event: QResizeEvent) -> None: + super().resizeEvent(event) + self._update_window_mask() + self._border_overlay.setGeometry(self.rect()) + self._border_overlay.raise_() + self._border_overlay.update() + + def showEvent(self, event: QShowEvent) -> None: + super().showEvent(event) + self._update_window_mask() + self._border_overlay.setGeometry(self.rect()) + self._border_overlay.raise_() + self._border_overlay.update() + def new_connection_tab(self) -> None: tab: ConnectionTab | None = None @@ -256,3 +326,11 @@ class MainWindow(QMainWindow): refresh() self._refresh_tab_icons() + self._border_overlay.update() + + def apply_runtime_settings(self) -> None: + """Propagate runtime settings changes to all connection tabs.""" + for i in range(self.pages.count()): + page = self.pages.widget(i) + if isinstance(page, ConnectionTab): + page.apply_runtime_settings() diff --git a/app/services/com_service.py b/app/services/com_service.py index dc28db0..3f382e5 100644 --- a/app/services/com_service.py +++ b/app/services/com_service.py @@ -3,6 +3,7 @@ from __future__ import annotations import threading import time as _time +from PySide6.QtCore import QObject, Signal from serial import ( EIGHTBITS, FIVEBITS, @@ -56,21 +57,27 @@ def params_from_ui( ) -class ComConnection: - """Обёртка над pyserial с фоновым дренажом входного буфера. +class ComConnection(QObject): + """Обёртка над pyserial с единым фоновым потоком чтения порта. - Фоновый поток-дренаж (_drain thread) постоянно вычитывает и - отбрасывает всё, что плата присылает «по своей инициативе» - (COINS, PRIZES и т.п.). Когда вызывается send_command() — - дренаж приостанавливается (через Lock), чтобы последующий + Фоновый reader-поток постоянно вычитывает строки из serial и + рассылает события подписчикам (line/input/adc/info). + Когда вызывается send_command() — reader приостанавливается + (через lock), чтобы последующий read_response() получил именно ответ на команду. После read_response() (или серии вызовов) нужно вызвать - release(), чтобы дренаж возобновился. Для удобства + release(), чтобы reader возобновился. Для удобства send_command() автоматически освобождает предыдущую блокировку перед захватом новой. """ + line_received = Signal(str) # любая входящая строка + input_received = Signal(object) # int (маска входов INPUT...) + adc_received = Signal(object) # list[str] (ADCv1,v2,v3,v4) + info_received = Signal(str) # COINS / HOPPER / PRIZES / SERVICE + def __init__(self) -> None: + super().__init__() self._serial: Serial | None = None self._io_lock = threading.Lock() self._cmd_owner = False @@ -78,6 +85,7 @@ class ComConnection: self._drain_paused = threading.Event() # установлен = дренаж приостановлен self._drain_thread: threading.Thread | None = None self._read_buf = bytearray() # остаток между вызовами read_response + self._event_buf = bytearray() # буфер фонового reader-потока self._last_cmd_time: float = 0.0 # monotonic-метка последней отправки # Параметры последнего подключения (для reconnect) self._last_port: str | None = None @@ -118,6 +126,7 @@ class ComConnection: ) # Запускаем фоновый дренаж self._drain_stop.clear() + self._event_buf.clear() self._drain_thread = threading.Thread( target=self._drain_loop, daemon=True, name="serial-drain" ) @@ -139,6 +148,7 @@ class ComConnection: if self._serial and self._serial.is_open: self._serial.close() self._serial = None + self._event_buf.clear() def reconnect(self) -> None: """Переподключиться с параметрами последнего open(). @@ -191,6 +201,24 @@ class ComConnection: self._serial.write((cmd + "\r\n").encode("ascii")) self._last_cmd_time = _time.monotonic() + def send_async(self, cmd: str) -> None: + """Отправить команду, не удерживая lock после записи. + + Используется UI-обработчиками (кнопки/диалоги), когда не нужно + сразу читать синхронный ответ через read_response(). + """ + if not self.is_connected: + raise RuntimeError("Порт не открыт") + assert self._serial is not None + acquired = self._io_lock.acquire(timeout=0.5) + if not acquired: + raise TimeoutError("Порт занят другой операцией") + try: + self._serial.write((cmd + "\r\n").encode("ascii")) + self._last_cmd_time = _time.monotonic() + finally: + self._io_lock.release() + def read_response(self, timeout: float | None = None) -> str | None: """Прочитать одну строку ответа. Возвращает None при таймауте. @@ -279,8 +307,32 @@ class ComConnection: # ── Фоновый дренаж ──────────────────────────────────────────────── + def _dispatch_line(self, line: str) -> None: + """Разобрать строку и разослать события подписчикам.""" + self.line_received.emit(line) + + upper = line.upper() + if upper.startswith("INPUT"): + payload = line[5:].strip() + if payload: + try: + self.input_received.emit(int(payload)) + except ValueError: + pass + return + + if upper.startswith("ADC"): + parts = line[3:].split(",") + self.adc_received.emit(parts) + return + + for prefix in ("COINS", "HOPPER", "PRIZES", "SERVICE"): + if upper.startswith(prefix): + self.info_received.emit(line) + return + def _drain_loop(self) -> None: - """Постоянно вычитывает и отбрасывает входящие данные. + """Постоянно вычитывает входящие данные и публикует строки. Работает, пока не установлен _drain_stop. Захватывает io-lock на короткое время; если lock занят @@ -298,9 +350,20 @@ class ComConnection: try: if self._serial and self._serial.is_open: old_timeout = self._serial.timeout - self._serial.timeout = 0.02 try: - self._serial.read(256) # читаем и отбрасываем + avail = self._serial.in_waiting + if avail > 0: + chunk = self._serial.read(avail) + else: + self._serial.timeout = 0.02 + chunk = self._serial.read(1) + if chunk: + self._event_buf.extend(chunk) + while True: + line = self._extract_line(self._event_buf) + if line is None: + break + self._dispatch_line(line) finally: try: self._serial.timeout = old_timeout @@ -311,4 +374,4 @@ class ComConnection: finally: self._io_lock.release() # Пауза между итерациями - self._drain_stop.wait(0.03) + self._drain_stop.wait(0.005) diff --git a/app/ui/icons.py b/app/ui/icons.py index c5e26a7..82bdf66 100644 --- a/app/ui/icons.py +++ b/app/ui/icons.py @@ -76,7 +76,7 @@ def get_icon_muted() -> str: def icon(name: str, *, color: str = ICON_COLOR_DARK) -> QIcon: - if not QTA_AVAILABLE: + if not QTA_AVAILABLE or qta is None: return QIcon() try: diff --git a/app/ui/theme.py b/app/ui/theme.py index 6010172..3a77628 100644 --- a/app/ui/theme.py +++ b/app/ui/theme.py @@ -224,7 +224,7 @@ QMainWindow#AppWindow {{ QWidget#MainContainer {{ background: {bg_base}; - border: 2px solid {accent}; + border: none; border-radius: 10px; }} @@ -252,10 +252,10 @@ QScrollArea > QWidget > QWidget {{ }} QWidget#TitleBar {{ - background: {accent_dark}; - border-top-left-radius: 10px; - border-top-right-radius: 10px; - border-bottom: 1px solid {accent_darker}; + background: {accent}; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + border-bottom: 1px solid {accent}; }} QLabel#TitleBarTitle {{ @@ -578,10 +578,10 @@ QWidget#DialogContainer {{ }} QWidget#DialogTitleBar {{ - background: {accent_dark}; + background: {accent}; border-top-left-radius: 8px; border-top-right-radius: 8px; - border-bottom: 1px solid {accent_darker}; + border-bottom: 1px solid {accent}; }} QLabel#DialogTitle {{ diff --git a/app/widgets/connection_tab.py b/app/widgets/connection_tab.py index 79fb5f7..029ccfe 100644 --- a/app/widgets/connection_tab.py +++ b/app/widgets/connection_tab.py @@ -6,7 +6,7 @@ from enum import Enum, auto from PySide6.QtCore import Qt, QThread, QTimer, Signal from PySide6.QtWidgets import ( QCheckBox, - QComboBox, + QDialog, QFrame, QFormLayout, QGroupBox, @@ -31,9 +31,11 @@ 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.button_backlight_section import ButtonBacklightSection from .sections.input_test_section import InputTestSection from .sections.firmware_section import FirmwareSection from .sections.payment_section import PaymentSection +from .no_wheel_combo_box import NoWheelComboBox class _CollapsibleGroup(QWidget): @@ -107,9 +109,12 @@ class ConnectionTab(QWidget): self._plate_state = PlateState.DISCONNECTED self._active_threads: list[QThread] = [] self._open_dialogs: int = 0 # кол-во открытых диалоговых окон + self._open_dialog_windows: list[QDialog] = [] + self._open_dialog_windows_by_key: dict[str, QDialog] = {} self._health_timer: QTimer | None = None # таймер health-check self._health_running = False # идёт ли сейчас проверка self._health_fail_count = 0 # подряд неудачных проверок + self._payment_observers_bound = False self._setup_ui() self._load_ports() @@ -179,26 +184,26 @@ class ConnectionTab(QWidget): settings_form = QFormLayout(settings_group) settings_form.setLabelAlignment(Qt.AlignmentFlag.AlignLeft) - self.port_combo = QComboBox() + self.port_combo = NoWheelComboBox() self.port_combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) - self.baud_combo = QComboBox() + self.baud_combo = NoWheelComboBox() self.baud_combo.addItems(["9600", "19200", "38400", "57600", "115200", "230400"]) self.baud_combo.setCurrentText("115200") - self.data_bits_combo = QComboBox() + self.data_bits_combo = NoWheelComboBox() self.data_bits_combo.addItems(["5", "6", "7", "8"]) self.data_bits_combo.setCurrentText("8") - self.stop_bits_combo = QComboBox() + self.stop_bits_combo = NoWheelComboBox() self.stop_bits_combo.addItems(["1", "1.5", "2"]) self.stop_bits_combo.setCurrentText("1") - self.parity_combo = QComboBox() + self.parity_combo = NoWheelComboBox() self.parity_combo.addItems(["None", "Even", "Odd", "Mark", "Space"]) self.parity_combo.setCurrentText("None") - self.flow_control_combo = QComboBox() + self.flow_control_combo = NoWheelComboBox() self.flow_control_combo.addItems(["None", "XON/XOFF", "RTS/CTS"]) self.flow_control_combo.setCurrentText("None") @@ -261,6 +266,7 @@ class ConnectionTab(QWidget): 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._button_backlight_section = ButtonBacklightSection(self._connection) self._input_section = InputTestSection(self._connection) self._payment_section = PaymentSection( send_raw=lambda cmd: self._input_section._send_raw(cmd), @@ -270,8 +276,9 @@ class ConnectionTab(QWidget): self._firmware_group.add_widget(self._firmware_section) controls_layout.addWidget(self._firmware_group) - self._led_group = _CollapsibleGroup("Тест цвета", expanded=False) + self._led_group = _CollapsibleGroup("Тест подсветки", expanded=False) self._led_group.add_widget(self._led_section) + self._led_group.add_widget(self._button_backlight_section) controls_layout.addWidget(self._led_group) self._input_group = _CollapsibleGroup("Тест ввода", expanded=False) @@ -291,6 +298,7 @@ class ConnectionTab(QWidget): self._basic_sections: list[QWidget] = [ self._firmware_section, self._led_section, + self._button_backlight_section, self._input_section, self._payment_section, ] @@ -339,31 +347,34 @@ class ConnectionTab(QWidget): for btn, icon_name in self._pro_buttons: btn.setIcon(icon(icon_name, color=clr)) self._led_section.refresh_icons() + self._button_backlight_section.refresh_icons() self._input_section.refresh_icons() self._firmware_section.refresh_icons() self._payment_section.refresh_icons() + def apply_runtime_settings(self) -> None: + """Apply updated runtime settings (e.g. health-check) immediately.""" + self._apply_health_settings() + # ── PRO-режим ───────────────────────────────────────────────── def _start_basic_polling(self) -> None: - """Запустить опрос входов и подключить PaymentSection к сигналам потока.""" + """Запустить подписку на входы и подключить PaymentSection к reader-событиям.""" self._input_section.start_polling() - pt = self._input_section._poll_thread - if pt is not None: - pt.digital_ready.connect( + if not self._payment_observers_bound: + self._connection.input_received.connect( self._payment_section.on_digital, Qt.ConnectionType.QueuedConnection ) - pt.info_line.connect( + self._connection.info_received.connect( self._payment_section.on_info_line, Qt.ConnectionType.QueuedConnection ) + self._payment_observers_bound = True 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 ему не нужен. + # На время прошивки полностью останавливаем фоновое чтение, + # чтобы UploadWorker имел эксклюзивный доступ к порту. self._connection.pause_drain() def _on_fw_upload_finished(self) -> None: @@ -458,8 +469,18 @@ class ConnectionTab(QWidget): def close_serial(self) -> None: self._stop_health_timer() + # Закрыть все открытые немодальные диалоги этой вкладки + for dlg in list(self._open_dialog_windows): + try: + dlg.close() + except Exception: + pass + self._open_dialog_windows.clear() + self._open_dialog_windows_by_key.clear() + self._open_dialogs = 0 # Остановить inline-секции self._input_section.stop_polling() + self._button_backlight_section.shutdown() self._firmware_section.cancel_upload() self._connection.close() # Дождаться завершения всех фоновых потоков @@ -497,37 +518,99 @@ class ConnectionTab(QWidget): # ── Открытие диалогов ─────────────────────────────────────────────── - def _exec_dialog(self, dlg) -> None: - """Открыть диалог с трекингом (увеличивает/уменьшает счётчик).""" - self._open_dialogs += 1 - try: - dlg.exec() - finally: - self._open_dialogs = max(0, self._open_dialogs - 1) + def _apply_dialog_title_with_port(self, dlg: QWidget) -> None: + serial = self._connection.serial + port = serial.port if serial is not None else "" + base_title = dlg.property("_base_title") + if not isinstance(base_title, str) or not base_title.strip(): + base_title = dlg.windowTitle().strip() + dlg.setProperty("_base_title", base_title) + base_title = base_title.strip() + if not base_title: + return + if port: + dlg.setWindowTitle(f"{base_title} ({port})") + else: + dlg.setWindowTitle(base_title) + + def _on_dialog_destroyed(self, _obj=None) -> None: + sender = self.sender() + if sender is not None and sender in self._open_dialog_windows: + self._open_dialog_windows.remove(sender) # type: ignore[arg-type] + for key, dlg in list(self._open_dialog_windows_by_key.items()): + if dlg is sender: + self._open_dialog_windows_by_key.pop(key, None) + self._open_dialogs = len(self._open_dialog_windows_by_key) + + def _exec_dialog(self, key: str, factory: Callable[[], QDialog]) -> None: + """Открыть немодальный диалог с трекингом открытых окон.""" + existing = self._open_dialog_windows_by_key.get(key) + if existing is not None: + try: + self._apply_dialog_title_with_port(existing) + existing.show() + existing.raise_() + existing.activateWindow() + return + except RuntimeError: + self._open_dialog_windows_by_key.pop(key, None) + if existing in self._open_dialog_windows: + self._open_dialog_windows.remove(existing) + + dlg = factory() + self._apply_dialog_title_with_port(dlg) + dlg.setModal(False) + dlg.setWindowModality(Qt.WindowModality.NonModal) + dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) + dlg.destroyed.connect(self._on_dialog_destroyed) + self._open_dialog_windows.append(dlg) + self._open_dialog_windows_by_key[key] = dlg + self._open_dialogs = len(self._open_dialog_windows_by_key) + dlg.show() + dlg.raise_() + dlg.activateWindow() def _open_input_test(self) -> None: from .dialogs.input_test_dialog import InputTestDialog - self._exec_dialog(InputTestDialog(self._connection, parent=self)) + self._exec_dialog( + "input_test", + lambda: InputTestDialog(self._connection, parent=self), + ) def _open_led_test(self) -> None: from .dialogs.led_test_dialog import LedTestDialog - self._exec_dialog(LedTestDialog(self._connection, parent=self)) + self._exec_dialog( + "led_test", + lambda: LedTestDialog(self._connection, parent=self), + ) def _open_output_test(self) -> None: from .dialogs.output_test_dialog import OutputTestDialog - self._exec_dialog(OutputTestDialog(self._connection, parent=self)) + self._exec_dialog( + "output_test", + lambda: OutputTestDialog(self._connection, parent=self), + ) def _open_firmware(self) -> None: from .dialogs.firmware_dialog import FirmwareDialog - self._exec_dialog(FirmwareDialog(self._connection, parent=self)) + self._exec_dialog( + "firmware", + lambda: FirmwareDialog(self._connection, parent=self), + ) def _open_role(self) -> None: from .dialogs.role_dialog import RoleDialog - self._exec_dialog(RoleDialog(self._connection, parent=self)) + self._exec_dialog( + "role", + lambda: RoleDialog(self._connection, parent=self), + ) def _open_terminal(self) -> None: from .dialogs.command_terminal_dialog import CommandTerminalDialog - self._exec_dialog(CommandTerminalDialog(self._connection, parent=self)) + self._exec_dialog( + "terminal", + lambda: CommandTerminalDialog(self._connection, parent=self), + ) def _not_implemented(self) -> None: QMessageBox.information(self, "В разработке", "Эта функция пока не реализована.") @@ -679,7 +762,7 @@ class ConnectionTab(QWidget): return if self._open_dialogs > 0: return - # Если InputTestSection ведёт опрос — drain приостановлен, не мешаем + # Если InputTestSection активен — не мешаем проверками связи if self._input_section._polling: return if self._health_running: diff --git a/app/widgets/dialogs/command_terminal_dialog.py b/app/widgets/dialogs/command_terminal_dialog.py index 2c315e9..37103ac 100644 --- a/app/widgets/dialogs/command_terminal_dialog.py +++ b/app/widgets/dialogs/command_terminal_dialog.py @@ -5,7 +5,7 @@ from __future__ import annotations -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt from PySide6.QtGui import QKeyEvent from PySide6.QtWidgets import ( QHBoxLayout, @@ -27,17 +27,10 @@ class CommandTerminalDialog(StyledDialog): self.setWindowTitle("Ввод команд") self.setMinimumSize(540, 400) - # Приостанавливаем фоновый дренаж — терминал сам управляет чтением - self._conn._io_lock.acquire() - self._conn._cmd_owner = True - - # Таймер для чтения входящих данных - self._read_timer = QTimer(self) - self._read_timer.setInterval(100) - self._read_timer.timeout.connect(self._read_incoming) - self._setup_ui() - self._read_timer.start() + self._conn.line_received.connect( + self._on_line_received, Qt.ConnectionType.QueuedConnection + ) # История команд (макс. 50) self._history: list[str] = [] @@ -163,33 +156,19 @@ class CommandTerminalDialog(StyledDialog): self._log.append('✗ Порт не подключён') return try: - ser = self._conn.serial - assert ser is not None - ser.reset_input_buffer() - ser.write((cmd + "\r\n").encode("ascii")) + self._conn.send_async(cmd) self._log.append(f'→ {cmd}') except Exception as exc: self._log.append(f'✗ {exc}') - def _read_incoming(self) -> None: - if not self._conn.is_connected: - return - try: - ser = self._conn.serial - if ser is None: - return - if ser.in_waiting > 0: - raw = ser.read(ser.in_waiting) - text = raw.decode("ascii", errors="replace").strip() - if text: - for line in text.splitlines(): - stripped = line.strip() - if stripped: - self._log.append(f'← {stripped}') - except Exception: - pass + def _on_line_received(self, line: str) -> None: + stripped = line.strip() + if stripped: + self._log.append(f'← {stripped}') def closeEvent(self, event) -> None: - self._read_timer.stop() - self._conn.release() + try: + self._conn.line_received.disconnect(self._on_line_received) + except (RuntimeError, TypeError): + pass super().closeEvent(event) diff --git a/app/widgets/dialogs/commands_dialog.py b/app/widgets/dialogs/commands_dialog.py index 8ce1c3d..76bd985 100644 --- a/app/widgets/dialogs/commands_dialog.py +++ b/app/widgets/dialogs/commands_dialog.py @@ -4,7 +4,6 @@ from __future__ import annotations from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QComboBox, QFrame, QHBoxLayout, QLabel, @@ -16,6 +15,7 @@ from PySide6.QtWidgets import ( from ...services.commands_service import CommandSet, load_all_command_sets from ...ui.icons import get_icon_color, icon +from ..no_wheel_combo_box import NoWheelComboBox from .styled_dialog import StyledDialog @@ -85,7 +85,7 @@ class CommandsDialog(StyledDialog): self._search.textChanged.connect(self._apply_filter) filter_row.addWidget(self._search, 1) - self._set_combo = QComboBox() + self._set_combo = NoWheelComboBox() self._set_combo.setMinimumWidth(140) self._set_combo.currentIndexChanged.connect(self._on_set_changed) filter_row.addWidget(self._set_combo) diff --git a/app/widgets/dialogs/input_test_dialog.py b/app/widgets/dialogs/input_test_dialog.py index 44f115f..e81fafd 100644 --- a/app/widgets/dialogs/input_test_dialog.py +++ b/app/widgets/dialogs/input_test_dialog.py @@ -3,16 +3,14 @@ Цифровые входы: команда 91 → INPUT (26 бит: 24 входа + 2 хоппера) Аналоговые входы: команда 92 → ADC,,, -Опрос запускается автоматически при открытии диалога и работает -в фоновом потоке, чтобы не блокировать UI. +При открытии диалог подписывается на единый поток чтения ComConnection. """ from __future__ import annotations -from PySide6.QtCore import Qt, QThread, QTimer, QPointF, Signal +from PySide6.QtCore import Qt, QTimer, QPointF from PySide6.QtGui import QPainter, QPen, QBrush, QColor from PySide6.QtWidgets import ( - QComboBox, QGridLayout, QGroupBox, QHBoxLayout, @@ -33,6 +31,7 @@ from ...services.input_mappings_service import ( load_all_input_mappings, ) from ...ui.icons import get_icon_color, get_icon_muted, icon +from ..no_wheel_combo_box import NoWheelComboBox from .styled_dialog import StyledDialog _NUM_DIGITAL = 26 # 24 входа + 2 хоппера @@ -138,169 +137,6 @@ class _JoystickWidget(QWidget): p.end() -class _InputPollThread(QThread): - """Фоновый поток циклического опроса цифровых входов. - - Drain приостановлен на время жизни этого потока, поэтому - можно работать напрямую с serial без lock. - """ - - digital_ready = Signal(object) # int — битовая маска 26 бит - analog_ready = Signal(object) # list[str] — 4 значения ADC - info_line = Signal(str) # COINS / HOPPER / PRIZES и пр. - - def __init__(self, connection: ComConnection) -> None: - super().__init__() - self._conn = connection - self._running = True - self._do_analog = False # однократный запрос ADC по кнопке - self._do_debounce: int | None = None # однократная установка антидребезга - self._raw_cmd: str | None = None # произвольная команда (хоппер, монета…) - - def stop(self) -> None: - self._running = False - - def request_analog(self) -> None: - """Запросить одноразовый опрос аналоговых входов (из GUI-потока).""" - self._do_analog = True - - def request_debounce(self, ms: int) -> None: - """Запросить установку антидребезга (из GUI-потока).""" - self._do_debounce = ms - - def send_raw(self, cmd: str) -> None: - """Поставить произвольную команду на отправку (из GUI-потока).""" - self._raw_cmd = cmd - - def _readline(self, ser, deadline) -> str | None: - """Прочитать одну строку из serial до deadline. - - Использует in_waiting для минимальной задержки: если байты - уже в буфере — забираем сразу, иначе короткий read(1) с - таймаутом 2 мс, чтобы не блокировать поток. - """ - import time - buf = bytearray() - while time.monotonic() < deadline: - # Сначала забираем всё, что уже в буфере драйвера - avail = ser.in_waiting - if avail: - chunk = ser.read(avail) - for byte in chunk: - if byte in (0x0D, 0x0A): # \r \n - if buf: - return buf.decode("ascii", errors="replace") - continue - buf.append(byte) - else: - # Буфер пуст — ждём немного - remaining = max(deadline - time.monotonic(), 0.001) - ser.timeout = min(remaining, 0.002) # 2 мс - b = ser.read(1) - if not b: - if buf: - return buf.decode("ascii", errors="replace") - continue - if b[0] in (0x0D, 0x0A): - if buf: - return buf.decode("ascii", errors="replace") - continue - buf.append(b[0]) - if buf: - return buf.decode("ascii", errors="replace") - return None - - def _check_info_line(self, line: str) -> bool: - """Проверить, является ли строка информационной (COINS, HOPPER, PRIZES, SERVICE). - - Если да — отправить сигнал и вернуть True. - """ - upper = line.upper() - for prefix in ("COINS", "HOPPER", "PRIZES", "SERVICE"): - if upper.startswith(prefix): - self.info_line.emit(line) - return True - return False - - def run(self) -> None: # noqa: D401 - import time - - while self._running: - ser = self._conn.serial - if ser is None or not ser.is_open: - self.msleep(100) - continue - - old_timeout = ser.timeout - try: - # ── Однократная установка антидребезга ── - debounce = self._do_debounce - if debounce is not None: - self._do_debounce = None - ser.write(f"91{debounce}\r\n".encode()) - self.msleep(20) - continue - - # ── Произвольная команда (хоппер, монета…) ── - raw = self._raw_cmd - if raw is not None: - self._raw_cmd = None - ser.write(f"{raw}\r\n".encode()) - # Читаем ответ (может быть HOPPER OK/ERROR и т.п.) - deadline = time.monotonic() + 0.5 - while self._running and time.monotonic() < deadline: - line = self._readline(ser, deadline) - if line is None: - break - self._check_info_line(line) - continue - - # ── Однократный опрос ADC ── - if self._do_analog: - self._do_analog = False - ser.reset_input_buffer() - ser.write(b"92\r\n") - deadline = time.monotonic() + 0.2 - while self._running and time.monotonic() < deadline: - line = self._readline(ser, deadline) - if line is None: - break - if line.upper().startswith("ADC"): - parts = line[3:].split(",") - self.analog_ready.emit(parts) - break - self._check_info_line(line) - continue - - # ── Циклический опрос цифровых входов ── - ser.write(b"91\r\n") - - deadline = time.monotonic() + 0.08 # 80 мс макс. ожидание - while self._running and time.monotonic() < deadline: - line = self._readline(ser, deadline) - if line is None: - break - if line.upper().startswith("INPUT"): - val = int(line[5:]) - self.digital_ready.emit(val) - break - # ADC-строки (от автовывода 92X) перехватываем - if line.upper().startswith("ADC"): - parts = line[3:].split(",") - self.analog_ready.emit(parts) - continue - # Информационные строки перехватываем - self._check_info_line(line) - except Exception: - pass - finally: - try: - ser.timeout = old_timeout - except Exception: - pass - # Без msleep — сразу следующий цикл - - class InputTestDialog(StyledDialog): def __init__(self, connection: ComConnection, parent=None) -> None: super().__init__(parent) @@ -318,22 +154,17 @@ class InputTestDialog(StyledDialog): self._setup_ui() self._apply_mapping() - - # Приостанавливаем дренаж — опрос будет единственным читателем порта - self._conn.pause_drain() - - # Запуск опроса сразу при открытии - self._poll_thread = _InputPollThread(connection) - self._poll_thread.digital_ready.connect( + self._events_subscribed = False + self._conn.input_received.connect( self._on_digital, Qt.ConnectionType.QueuedConnection ) - self._poll_thread.analog_ready.connect( + self._conn.adc_received.connect( self._on_analog, Qt.ConnectionType.QueuedConnection ) - self._poll_thread.info_line.connect( + self._conn.info_received.connect( self._on_info_line, Qt.ConnectionType.QueuedConnection ) - self._poll_thread.start() + self._events_subscribed = True # ── UI ──────────────────────────────────────────────────────────── @@ -344,7 +175,7 @@ class InputTestDialog(StyledDialog): # ── Выбор маппинга ── mapping_row = QHBoxLayout() mapping_row.addWidget(QLabel("Раскладка:")) - self._mapping_combo = QComboBox() + self._mapping_combo = NoWheelComboBox() self._mapping_combo.addItem("— Без раскладки —") for m in self._mappings: self._mapping_combo.addItem(m.project_name) @@ -690,10 +521,13 @@ class InputTestDialog(StyledDialog): # ── Прочее ──────────────────────────────────────────────────────── def _send_raw(self, cmd: str) -> None: - """Отправить произвольную команду через поток опроса.""" + """Отправить произвольную команду через ComConnection.""" if not self._conn.is_connected or self._closed: return - self._poll_thread.send_raw(cmd) + try: + self._conn.send_async(cmd) + except Exception: + pass def _on_info_line(self, line: str) -> None: """Обработать информационную строку от платы (COINS, HOPPER, …).""" @@ -762,16 +596,16 @@ class InputTestDialog(StyledDialog): cal_label.setText("Калибровка: Нет") def _poll_analog(self) -> None: - """Запросить однократный опрос аналоговых входов через поток.""" + """Запросить однократный опрос аналоговых входов.""" if not self._conn.is_connected or self._closed: return - self._poll_thread.request_analog() + self._send_raw("92") def _set_debounce(self) -> None: if not self._conn.is_connected or self._closed: return val = self._debounce_spin.value() - self._poll_thread.request_debounce(val) + self._send_raw(f"91{val}") # ── Программная автовыдача хоппера ──────────────────────────────── @@ -890,11 +724,10 @@ class InputTestDialog(StyledDialog): subprocess.Popen(["xdg-open", str(mappings_dir)]) # noqa: S603, S607 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") + self._send_raw("92") # Если автовыдача активна — выключаем мотор if self._auto_dispense_hopper != 0: off_cmd = "300" if self._auto_dispense_hopper == 1 else "400" @@ -902,29 +735,29 @@ class InputTestDialog(StyledDialog): if self._auto_dispense_timer is not None: self._auto_dispense_timer.stop() self._auto_dispense_timer = None - # Отправляем напрямую, т.к. поток ещё жив - self._poll_thread.send_raw(off_cmd) - self._poll_thread.stop() + self._send_raw(off_cmd) + + self._closed = True + if self._events_subscribed: + try: + self._conn.input_received.disconnect(self._on_digital) + except (RuntimeError, TypeError): + pass + try: + self._conn.adc_received.disconnect(self._on_analog) + except (RuntimeError, TypeError): + pass + try: + self._conn.info_received.disconnect(self._on_info_line) + except (RuntimeError, TypeError): + pass + self._events_subscribed = False + try: - self._poll_thread.digital_ready.disconnect() - except RuntimeError: - pass - try: - self._poll_thread.analog_ready.disconnect() - except RuntimeError: - pass - try: - self._poll_thread.info_line.disconnect() - except RuntimeError: - pass - self._poll_thread.wait(3000) - # Очищаем буфер serial от возможных остаточных ответов - try: - ser = self._conn.serial - if ser is not None and ser.is_open: - ser.reset_input_buffer() + if self._auto_dispense_timer is not None: + self._auto_dispense_timer.stop() + self._auto_dispense_timer.deleteLater() + self._auto_dispense_timer = None except Exception: pass - # Возобновляем дренаж - self._conn.resume_drain() super().closeEvent(event) diff --git a/app/widgets/dialogs/led_test_dialog.py b/app/widgets/dialogs/led_test_dialog.py index 7f3a67e..1397096 100644 --- a/app/widgets/dialogs/led_test_dialog.py +++ b/app/widgets/dialogs/led_test_dialog.py @@ -15,7 +15,6 @@ import sys from PySide6.QtCore import Qt, QTimer from PySide6.QtWidgets import ( - QComboBox, QFormLayout, QGroupBox, QHBoxLayout, @@ -33,6 +32,7 @@ from ...services.light_modes_service import ( load_all_light_modes, ) from ...ui.icons import get_icon_color, get_icon_muted, icon +from ..no_wheel_combo_box import NoWheelComboBox from .styled_dialog import StyledDialog _COLOR_ORDERS: list[tuple[int, str]] = [ @@ -67,7 +67,7 @@ class LedTestDialog(StyledDialog): project_layout = QVBoxLayout(project_group) proj_row = QHBoxLayout() - self._project_combo = QComboBox() + self._project_combo = NoWheelComboBox() for mode in self._modes: self._project_combo.addItem(mode.project_name, mode) self._project_combo.currentIndexChanged.connect(self._on_project_changed) @@ -95,7 +95,7 @@ class LedTestDialog(StyledDialog): effect_form = QFormLayout(effect_group) # Канал (только для base_command = "8") - self._channel_combo = QComboBox() + self._channel_combo = NoWheelComboBox() self._channel_combo.addItem("Все каналы (0)", 0) for ch in range(1, 5): self._channel_combo.addItem(f"Канал {ch}", ch) @@ -103,7 +103,7 @@ class LedTestDialog(StyledDialog): effect_form.addRow(self._channel_label, self._channel_combo) # Список эффектов из конфига - self._effect_combo = QComboBox() + self._effect_combo = NoWheelComboBox() effect_form.addRow("Эффект:", self._effect_combo) # Длительность (только для has_duration = true) @@ -146,31 +146,27 @@ class LedTestDialog(StyledDialog): color_group = QGroupBox("Цветовое чередование (cmd 89)") color_root = QVBoxLayout(color_group) - color_row1 = QHBoxLayout() - self._color_order_combo = QComboBox() + self._color_order_combo = NoWheelComboBox() for code, name in _COLOR_ORDERS: self._color_order_combo.addItem(f"{code} — {name}", code) - color_row1.addWidget(self._color_order_combo) + color_root.addWidget(self._color_order_combo) - set_color_btn = QPushButton("Применить") + set_color_btn = QPushButton("1. Применить") set_color_btn.setProperty("role", "primary") set_color_btn.setIcon(icon("fa5s.palette", color="#ecfdf5")) set_color_btn.clicked.connect(self._send_color_order) - color_row1.addWidget(set_color_btn) - color_root.addLayout(color_row1) + color_root.addWidget(set_color_btn) - color_row2 = QHBoxLayout() - self._color_test_btn = QPushButton("Тест цвета") + self._color_test_btn = QPushButton("2. Тест цвета") 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_root.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) + color_hint.setWordWrap(True) + color_root.addWidget(color_hint) root.addWidget(color_group) @@ -294,7 +290,7 @@ class LedTestDialog(StyledDialog): self._color_test_btn.setEnabled(False) self._color_test_timer = QTimer(self) - self._color_test_timer.setInterval(300) + self._color_test_timer.setInterval(1000) self._color_test_timer.timeout.connect(self._color_test_step) self._color_test_step() # отправить первую команду сразу self._color_test_timer.start() diff --git a/app/widgets/dialogs/settings_dialog.py b/app/widgets/dialogs/settings_dialog.py index 5fa9108..29e43ce 100644 --- a/app/widgets/dialogs/settings_dialog.py +++ b/app/widgets/dialogs/settings_dialog.py @@ -33,6 +33,7 @@ class SettingsDialog(StyledDialog): def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) + self._live_updates_enabled = False self.setWindowTitle("Настройки приложения") self.setMinimumWidth(300) self.resize(340, 280) @@ -138,7 +139,7 @@ class SettingsDialog(StyledDialog): # ── Apply button ─────────────────────────────────────────── lay.addSpacing(8) - self._apply_btn = QPushButton(" Применить") + self._apply_btn = QPushButton(" Готово") self._apply_btn.setProperty("role", "primary") self._apply_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._apply_btn.setMinimumHeight(36) @@ -148,6 +149,12 @@ class SettingsDialog(StyledDialog): self._apply_btn.clicked.connect(self._on_apply) lay.addWidget(self._apply_btn) + # ── Live updates ─────────────────────────────────────────── + self._accent_group.buttonToggled.connect(self._on_live_settings_changed) + self._mode_group.buttonToggled.connect(self._on_live_settings_changed) + self._hc_interval.valueChanged.connect(self._on_live_settings_changed) + self._live_updates_enabled = True + # ── Handlers ─────────────────────────────────────────────────── def _selected_accent(self) -> str: @@ -159,37 +166,73 @@ 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 + def _find_main_window(self): from ...main_window import MainWindow parent = self.parent() while parent is not None: if isinstance(parent, MainWindow): - parent.apply_theme(mode, accent) - break + return parent parent = parent.parent() - else: - # Fallback: just apply stylesheet without icon refresh - from PySide6.QtWidgets import QApplication - from ...ui.theme import build_stylesheet, get_palette + return None - app = QApplication.instance() - if isinstance(app, QApplication): - palette = get_palette(mode, accent) - app.setStyleSheet(build_stylesheet(palette)) + def _apply_theme_fallback(self, mode: str, accent: str) -> None: + # Fallback: just apply stylesheet without icon refresh + from PySide6.QtWidgets import QApplication + from ...ui.theme import build_stylesheet, get_palette - self.theme_changed.emit() - self.health_settings_changed.emit() + app = QApplication.instance() + if isinstance(app, QApplication): + palette = get_palette(mode, accent) + app.setStyleSheet(build_stylesheet(palette)) + + def _apply_settings(self) -> None: + mode = self._selected_mode() + accent = self._selected_accent() + hc_enabled = self._hc_enabled.isChecked() + hc_interval = self._hc_interval.value() + + theme_changed = ( + mode != settings_service.theme_mode() + or accent != settings_service.theme_accent() + ) + health_changed = ( + hc_enabled != settings_service.health_check_enabled() + or hc_interval != settings_service.health_check_interval() + ) + + if theme_changed: + settings_service.put("theme_mode", mode) + settings_service.put("theme_accent", accent) + if health_changed: + settings_service.put("health_check_enabled", hc_enabled) + settings_service.put("health_check_interval", hc_interval) + + if not (theme_changed or health_changed): + return + + main_window = self._find_main_window() + if theme_changed: + if main_window is not None: + main_window.apply_theme(mode, accent) + else: + self._apply_theme_fallback(mode, accent) + self.theme_changed.emit() + + if health_changed: + if main_window is not None: + main_window.apply_runtime_settings() + self.health_settings_changed.emit() + + def _on_live_settings_changed(self, *_args) -> None: + if not self._live_updates_enabled: + return + self._apply_settings() + + def _on_hc_toggled(self, checked: bool) -> None: + self._hc_interval.setEnabled(checked) + self._on_live_settings_changed() + + def _on_apply(self) -> None: + self._apply_settings() self.close() diff --git a/app/widgets/no_wheel_combo_box.py b/app/widgets/no_wheel_combo_box.py new file mode 100644 index 0000000..850b764 --- /dev/null +++ b/app/widgets/no_wheel_combo_box.py @@ -0,0 +1,16 @@ +"""ComboBox that does not change selection on mouse wheel.""" + +from __future__ import annotations + +from PySide6.QtGui import QWheelEvent +from PySide6.QtWidgets import QComboBox + + +class NoWheelComboBox(QComboBox): + """Ignore mouse-wheel selection changes when the combo box is closed.""" + + def wheelEvent(self, event: QWheelEvent) -> None: # noqa: N802 + if self.view().isVisible(): + super().wheelEvent(event) + return + event.ignore() diff --git a/app/widgets/sections/button_backlight_section.py b/app/widgets/sections/button_backlight_section.py new file mode 100644 index 0000000..f90493d --- /dev/null +++ b/app/widgets/sections/button_backlight_section.py @@ -0,0 +1,125 @@ +"""Inline-секция проверки подсветки кнопок через 8-й силовой выход.""" + +from __future__ import annotations + +from PySide6.QtCore import QTimer +from PySide6.QtWidgets import ( + QGroupBox, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QVBoxLayout, + QWidget, +) + +from ...services.com_service import ComConnection +from ...ui.icons import get_icon_color, get_icon_muted, icon + +_BUTTON_BACKLIGHT_INDEX = 7 # Пользовательский "8-й выход" в протоколе имеет индекс 7. +_BACKLIGHT_ON_CMD = f"21{_BUTTON_BACKLIGHT_INDEX}1" +_BACKLIGHT_OFF_CMD = f"21{_BUTTON_BACKLIGHT_INDEX}0" + + +class ButtonBacklightSection(QWidget): + """Проверка подсветки кнопок в базовом режиме.""" + + def __init__(self, connection: ComConnection, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._conn = connection + self._pulse_timer = QTimer(self) + self._pulse_timer.setSingleShot(True) + self._pulse_timer.timeout.connect(self._finish_pulse_test) + self._setup_ui() + + def _setup_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(10) + + group = QGroupBox("Подсветка кнопок (8-й силовой выход)") + layout = QVBoxLayout(group) + + desc = QLabel( + "Проверяет подсветку кнопок. Нажмите «Включить» и убедитесь, что кнопки загорелись, " + "затем нажмите «Выключить» и проверьте, что подсветка погасла. " + "Кнопка «Проверить» включает подсветку на короткое время автоматически." + ) + desc.setWordWrap(True) + desc.setObjectName("HelpText") + layout.addWidget(desc) + + row = QHBoxLayout() + + self._on_btn = QPushButton("Включить") + self._on_btn.setProperty("role", "primary") + self._on_btn.setIcon(icon("fa5s.lightbulb", color="#ecfdf5")) + self._on_btn.clicked.connect(lambda: self._send(_BACKLIGHT_ON_CMD)) + row.addWidget(self._on_btn) + + self._off_btn = QPushButton("Выключить") + self._off_btn.setIcon(icon("fa5s.power-off", color=get_icon_color())) + self._off_btn.clicked.connect(lambda: self._send(_BACKLIGHT_OFF_CMD)) + row.addWidget(self._off_btn) + + self._pulse_btn = QPushButton("Проверить") + self._pulse_btn.setIcon(icon("fa5s.play", color=get_icon_muted())) + self._pulse_btn.clicked.connect(self._run_pulse_test) + row.addWidget(self._pulse_btn) + + row.addStretch() + layout.addLayout(row) + + self._log = QLabel("") + self._log.setObjectName("LogLabel") + self._log.setWordWrap(True) + layout.addWidget(self._log) + + root.addWidget(group) + + def refresh_icons(self) -> None: + clr = get_icon_color() + muted = get_icon_muted() + self._on_btn.setIcon(icon("fa5s.lightbulb", color="#ecfdf5")) + self._off_btn.setIcon(icon("fa5s.power-off", color=clr)) + self._pulse_btn.setIcon(icon("fa5s.play", color=muted)) + + def shutdown(self) -> None: + if self._pulse_timer.isActive(): + self._pulse_timer.stop() + self._pulse_btn.setEnabled(True) + if self._conn.is_connected: + self._send(_BACKLIGHT_OFF_CMD) + + def _run_pulse_test(self) -> None: + if not self._conn.is_connected: + QMessageBox.warning(self, "Ошибка", "Порт не подключён.") + return + self._pulse_btn.setEnabled(False) + if self._send(_BACKLIGHT_ON_CMD): + self._log.setText("Подсветка включена на 1,5 секунды. Проверьте, что кнопки загорелись.") + self._pulse_timer.start(1500) + else: + self._pulse_btn.setEnabled(True) + + def _finish_pulse_test(self) -> None: + self._send(_BACKLIGHT_OFF_CMD) + self._pulse_btn.setEnabled(True) + self._log.setText("Проверка завершена: подсветка должна погаснуть.") + + def _send(self, cmd: str) -> bool: + if not self._conn.is_connected: + QMessageBox.warning(self, "Ошибка", "Порт не подключён.") + return False + 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}") + return True + except Exception as exc: + self._conn.release() + self._log.setText(f"Ошибка: {exc}") + return False diff --git a/app/widgets/sections/firmware_section.py b/app/widgets/sections/firmware_section.py index c60dfbc..232dbb6 100644 --- a/app/widgets/sections/firmware_section.py +++ b/app/widgets/sections/firmware_section.py @@ -54,6 +54,15 @@ class FirmwareSection(QWidget): fw_group = QGroupBox("Обновление прошивки") fw_layout = QVBoxLayout(fw_group) + desc = QLabel( + "Выберите файл прошивки и запустите обновление. " + "Дождитесь завершения прогресса и сообщения о перезагрузке платы. " + "Во время обновления остальные проверки временно недоступны." + ) + desc.setWordWrap(True) + desc.setObjectName("HelpText") + fw_layout.addWidget(desc) + file_layout = QHBoxLayout() self._file_label = QLabel("Файл не выбран") self._file_label.setWordWrap(True) diff --git a/app/widgets/sections/input_test_section.py b/app/widgets/sections/input_test_section.py index d830faa..e4ac30c 100644 --- a/app/widgets/sections/input_test_section.py +++ b/app/widgets/sections/input_test_section.py @@ -1,8 +1,8 @@ """Встраиваемая секция «Проверка ввода» — цифровые/аналоговые входы. Повторяет функциональность InputTestDialog, но как обычный QWidget. -Поток опроса запускается при вызове start_polling() и останавливается -при stop_polling() или скрытии виджета. +Подписывается на единый поток чтения ComConnection при start_polling() +и отписывается при stop_polling() или скрытии виджета. """ from __future__ import annotations @@ -14,7 +14,6 @@ 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, @@ -34,9 +33,7 @@ from ...services.input_mappings_service import ( load_all_input_mappings, ) from ...ui.icons import get_icon_color, get_icon_muted, icon - -# Переиспользуем поток из диалога — без дублирования кода -from ..dialogs.input_test_dialog import _InputPollThread +from ..no_wheel_combo_box import NoWheelComboBox _NUM_DIGITAL = 26 # 24 входа + 2 хоппера @@ -162,7 +159,6 @@ class InputTestSection(QWidget): 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 @@ -177,10 +173,19 @@ class InputTestSection(QWidget): root.setContentsMargins(0, 0, 0, 0) root.setSpacing(10) + desc = QLabel( + "Проверяет кнопки, датчики и джойстики. " + "Нажимайте элементы на панели и следите, чтобы индикаторы менялись сразу. " + "Для джойстиков проверьте плавное движение точки и при необходимости выполните калибровку." + ) + desc.setWordWrap(True) + desc.setObjectName("HelpText") + root.addWidget(desc) + # ── Выбор маппинга ── mapping_row = QHBoxLayout() mapping_row.addWidget(QLabel("Раскладка:")) - self._mapping_combo = QComboBox() + self._mapping_combo = NoWheelComboBox() self._mapping_combo.addItem("— Без раскладки —") for m in self._mappings: self._mapping_combo.addItem(m.project_name) @@ -331,28 +336,25 @@ class InputTestSection(QWidget): # ── 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._conn.input_received.connect( self._on_digital, Qt.ConnectionType.QueuedConnection ) - self._poll_thread.analog_ready.connect( + self._conn.adc_received.connect( self._on_analog, Qt.ConnectionType.QueuedConnection ) - self._poll_thread.info_line.connect( + self._conn.info_received.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 автовывод был включён — выключить @@ -361,34 +363,20 @@ class InputTestSection(QWidget): 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._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: + self._conn.input_received.disconnect(self._on_digital) + except (RuntimeError, TypeError): + pass + try: + self._conn.adc_received.disconnect(self._on_analog) + except (RuntimeError, TypeError): + pass + try: + self._conn.info_received.disconnect(self._on_info_line) + except (RuntimeError, TypeError): pass - self._conn.resume_drain() self._polling = False def refresh_icons(self) -> None: @@ -507,8 +495,10 @@ class InputTestSection(QWidget): 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) + try: + self._conn.send_async(cmd) + except Exception: + pass def _on_info_line(self, line: str) -> None: if self._closed: @@ -570,8 +560,7 @@ class InputTestSection(QWidget): 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) + self._send_raw(f"91{val}") def _reload_mappings(self) -> None: current_name = self._mapping_combo.currentText() diff --git a/app/widgets/sections/led_test_section.py b/app/widgets/sections/led_test_section.py index 22a99b7..6a2af23 100644 --- a/app/widgets/sections/led_test_section.py +++ b/app/widgets/sections/led_test_section.py @@ -8,9 +8,7 @@ from __future__ import annotations from PySide6.QtCore import QTimer from PySide6.QtWidgets import ( - QComboBox, QGroupBox, - QHBoxLayout, QLabel, QMessageBox, QPushButton, @@ -20,6 +18,7 @@ from PySide6.QtWidgets import ( from ...services.com_service import ComConnection from ...ui.icons import icon +from ..no_wheel_combo_box import NoWheelComboBox _COLOR_ORDERS: list[tuple[int, str]] = [ (0, "RGB"), @@ -50,31 +49,35 @@ class LedTestSection(QWidget): color_group = QGroupBox("Тест цвета (cmd 89)") color_root = QVBoxLayout(color_group) - color_row1 = QHBoxLayout() - self._color_order_combo = QComboBox() + desc = QLabel( + "Выберите порядок цветов и нажмите «1. Применить». " + "Затем нажмите «2. Тест цвета»: подсветка должна показать красный, зелёный и синий цвет." + ) + desc.setWordWrap(True) + desc.setObjectName("HelpText") + color_root.addWidget(desc) + + self._color_order_combo = NoWheelComboBox() for code, name in _COLOR_ORDERS: self._color_order_combo.addItem(f"{code} — {name}", code) - color_row1.addWidget(self._color_order_combo) + color_root.addWidget(self._color_order_combo) - self._set_color_btn = QPushButton("Применить") + self._set_color_btn = QPushButton("1. Применить") 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_root.addWidget(self._set_color_btn) - color_row2 = QHBoxLayout() - self._color_test_btn = QPushButton("Тест цвета") + self._color_test_btn = QPushButton("2. Тест цвета") 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_root.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) + color_hint.setWordWrap(True) + color_root.addWidget(color_hint) root.addWidget(color_group) # ── Лог ── @@ -99,7 +102,7 @@ class LedTestSection(QWidget): 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.setInterval(1000) self._color_test_timer.timeout.connect(self._color_test_step) self._color_test_step() self._color_test_timer.start() diff --git a/app/widgets/sections/payment_section.py b/app/widgets/sections/payment_section.py index c0c3774..8b1782f 100644 --- a/app/widgets/sections/payment_section.py +++ b/app/widgets/sections/payment_section.py @@ -45,6 +45,15 @@ class PaymentSection(QWidget): root.setContentsMargins(0, 0, 0, 0) root.setSpacing(10) + desc = QLabel( + "Проверяет приём монет и выдачу призов. " + "Внесите монету, подтвердите оплату и проверьте работу хопперов. " + "При автовыдаче приз должен выдаться без ручного отключения мотора." + ) + desc.setWordWrap(True) + desc.setObjectName("HelpText") + root.addWidget(desc) + # ── Монетоприёмник ── coin_group = QGroupBox("Монетоприёмник") coin_layout = QHBoxLayout(coin_group) diff --git a/app/widgets/start_tab.py b/app/widgets/start_tab.py index e9c6ffc..3a59ea5 100644 --- a/app/widgets/start_tab.py +++ b/app/widgets/start_tab.py @@ -20,6 +20,7 @@ from .dialogs.settings_dialog import SettingsDialog class StartTab(QWidget): def __init__(self, on_new_connection) -> None: super().__init__() + self._open_dialog_windows: dict[str, QWidget] = {} layout = QVBoxLayout(self) layout.setContentsMargins(26, 26, 26, 26) layout.addStretch(1) @@ -102,10 +103,32 @@ class StartTab(QWidget): # ── Handlers ────────────────────────────────────────────────── + def _on_dialog_destroyed(self, key: str) -> None: + self._open_dialog_windows.pop(key, None) + + def _open_single_dialog(self, key: str, factory) -> None: + existing = self._open_dialog_windows.get(key) + if existing is not None: + try: + existing.show() + existing.raise_() + existing.activateWindow() + return + except RuntimeError: + self._open_dialog_windows.pop(key, None) + + dlg = factory() + dlg.setModal(False) + dlg.setWindowModality(Qt.WindowModality.NonModal) + dlg.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) + dlg.destroyed.connect(lambda _obj=None, k=key: self._on_dialog_destroyed(k)) + self._open_dialog_windows[key] = dlg + dlg.show() + dlg.raise_() + dlg.activateWindow() + def _open_commands(self) -> None: - dlg = CommandsDialog(self) - dlg.exec() + self._open_single_dialog("commands", lambda: CommandsDialog(self)) def _open_settings(self) -> None: - dlg = SettingsDialog(self) - dlg.exec() + self._open_single_dialog("settings", lambda: SettingsDialog(self)) diff --git a/input_mappings/Бродяги морей.mapping b/input_mappings/Бродяги морей.mapping index b706261..af52c54 100644 --- a/input_mappings/Бродяги морей.mapping +++ b/input_mappings/Бродяги морей.mapping @@ -2,30 +2,30 @@ # Координаты: x (0..20 — столбец), y (-4..4 — строка, +Y = вверх) # input_index — номер цифрового входа платы (0..25) -[[buttons]] -name = "Вправо" -input_index = 0 -x = 2 -y = 1 - [[buttons]] name = "Вниз" -input_index = 1 +input_index = 0 x = 1 y = 0 [[buttons]] name = "Влево" -input_index = 2 +input_index = 1 x = 0 y = 1 [[buttons]] name = "Вверх" -input_index = 3 +input_index = 2 x = 1 y = 3 +[[buttons]] +name = "Вправо" +input_index = 3 +x = 2 +y = 1 + [[buttons]] name = "Камера лево" input_index = 4 diff --git a/light_modes/Базовые.lm b/light_modes/Базовые.lm index fda4945..854c8ad 100644 --- a/light_modes/Базовые.lm +++ b/light_modes/Базовые.lm @@ -3,14 +3,6 @@ base_command = "8" -[[effects]] -code = 0 -name = "Выключить (нет вывода)" - -[[effects]] -code = 1 -name = "Повторный вывод буфера" - [[effects]] code = 2 name = "Погасить (нули)" diff --git a/pre-releases/Release_1.3.0 b/pre-releases/Release_1.3.0 new file mode 100644 index 0000000..636765b --- /dev/null +++ b/pre-releases/Release_1.3.0 @@ -0,0 +1,16 @@ +Release 1.3.0 + +Диапазон: v1.2.0..v1.3.0 + +Основное + +- Обновлён простой режим: добавлен общий раздел «Тест подсветки» с тестом цвета и проверкой подсветки кнопок через 8-й силовой выход. +- Упрощены пользовательские описания проверок в простом режиме: прошивка, подсветка, ввод, оплата и хопперы теперь описаны короче и без технических деталей. +- Изменён тест цвета: кнопка «1. Применить» расположена под выбором порядка цветов, кнопка «2. Тест цвета» отделена от подсказки, шаги теста длятся по 1 секунде. +- Выпадающие списки больше не меняют значение от прокрутки колеса мыши, чтобы исключить случайные переключения настроек. +- Улучшена работа с COM-портом: введён единый поток чтения событий, асинхронная отправка команд и подписка UI на входы, ADC и информационные сообщения платы. +- PRO-диалоги стали немодальными, повторное открытие поднимает уже открытое окно, а в заголовках отображается текущий порт. +- Добавлены настройки проверки соединения и применение runtime-настроек к открытым вкладкам. +- Обновлены визуальные детали приложения: акцентная рамка окна, стартовый экран, цвета иконок, стили вкладок и элементов управления. +- Обновлены пользовательские ресурсы: раскладка «Бродяги морей» и базовые режимы подсветки. +- Добавлены проектные инструкции для агентов и исключён локальный каталог `.codex/` из Git.