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.