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:
parent
cce2912014
commit
9fd10a3acd
1
.gitignore
vendored
1
.gitignore
vendored
@ -20,6 +20,7 @@ dist/
|
||||
# ── IDE / Editor ──────────────────────────────────────
|
||||
.vscode/
|
||||
.idea/
|
||||
.codex/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
22
AGENTS.md
Normal file
22
AGENTS.md
Normal 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-инструмент хуже подходит для конкретной задачи, недоступен или создаёт лишние ограничения, агент может использовать встроенные инструменты как запасной вариант.
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 {{
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
16
app/widgets/no_wheel_combo_box.py
Normal file
16
app/widgets/no_wheel_combo_box.py
Normal 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()
|
||||
125
app/widgets/sections/button_backlight_section.py
Normal file
125
app/widgets/sections/button_backlight_section.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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
|
||||
|
||||
@ -3,14 +3,6 @@
|
||||
|
||||
base_command = "8"
|
||||
|
||||
[[effects]]
|
||||
code = 0
|
||||
name = "Выключить (нет вывода)"
|
||||
|
||||
[[effects]]
|
||||
code = 1
|
||||
name = "Повторный вывод буфера"
|
||||
|
||||
[[effects]]
|
||||
code = 2
|
||||
name = "Погасить (нули)"
|
||||
|
||||
16
pre-releases/Release_1.3.0
Normal file
16
pre-releases/Release_1.3.0
Normal 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.
|
||||
Loading…
x
Reference in New Issue
Block a user