v1.3.0: simple mode backlight and UI polish

- обновлён простой режим: тест цвета и проверка подсветки кнопок объединены в раздел «Тест подсветки»

- добавлена проверка подсветки кнопок через 8-й силовой выход с автоматическим коротким тестом

- упрощены описания пользовательских проверок и убраны лишние технические подробности из простого режима

- изменена раскладка теста цвета, увеличена длительность шага до 1 секунды и убраны описания из PRO-блока

- добавлена защита выпадающих списков от случайного изменения колесом мыши

- улучшены немодальные PRO-диалоги, настройки соединения и обработка событий COM-порта

- обновлены визуальные детали приложения, пользовательские ресурсы, инструкции AGENTS и Release Notes 1.3.0
This commit is contained in:
Данил Ярош 2026-05-26 12:59:21 +03:00
parent cce2912014
commit 9fd10a3acd
22 changed files with 699 additions and 419 deletions

1
.gitignore vendored
View File

@ -20,6 +20,7 @@ dist/
# ── IDE / Editor ──────────────────────────────────────
.vscode/
.idea/
.codex/
*.swp
*.swo
*~

22
AGENTS.md Normal file
View File

@ -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-инструмент хуже подходит для конкретной задачи, недоступен или создаёт лишние ограничения, агент может использовать встроенные инструменты как запасной вариант.

View File

@ -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()

View File

@ -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)

View File

@ -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:

View File

@ -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 {{

View File

@ -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:

View File

@ -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('<span style="color:#f87171;">✗ Порт не подключён</span>')
return
try:
ser = self._conn.serial
assert ser is not None
ser.reset_input_buffer()
ser.write((cmd + "\r\n").encode("ascii"))
self._conn.send_async(cmd)
self._log.append(f'<span style="color:#4ade80;">→ {cmd}</span>')
except Exception as exc:
self._log.append(f'<span style="color:#f87171;">✗ {exc}</span>')
def _read_incoming(self) -> None:
if not self._conn.is_connected:
return
try:
ser = self._conn.serial
if ser is None:
return
if ser.in_waiting > 0:
raw = ser.read(ser.in_waiting)
text = raw.decode("ascii", errors="replace").strip()
if text:
for line in text.splitlines():
stripped = line.strip()
if stripped:
self._log.append(f'<span style="color:#93c5fd;">← {stripped}</span>')
except Exception:
pass
def _on_line_received(self, line: str) -> None:
stripped = line.strip()
if stripped:
self._log.append(f'<span style="color:#93c5fd;">← {stripped}</span>')
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)

View File

@ -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)

View File

@ -3,16 +3,14 @@
Цифровые входы: команда 91 → INPUT<decimal> (26 бит: 24 входа + 2 хоппера)
Аналоговые входы: команда 92 → ADC<v1>,<v2>,<v3>,<v4>
Опрос запускается автоматически при открытии диалога и работает
в фоновом потоке, чтобы не блокировать 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)

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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()

View File

@ -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()

View File

@ -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)

View File

@ -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))

View File

@ -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

View File

@ -3,14 +3,6 @@
base_command = "8"
[[effects]]
code = 0
name = "Выключить (нет вывода)"
[[effects]]
code = 1
name = "Повторный вывод буфера"
[[effects]]
code = 2
name = "Погасить (нули)"

View File

@ -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.