plate-config-app/app/widgets/dialogs/input_test_dialog.py
Danil Iarosh 9fd10a3acd v1.3.0: simple mode backlight and UI polish
- обновлён простой режим: тест цвета и проверка подсветки кнопок объединены в раздел «Тест подсветки»

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

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

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

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

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

- обновлены визуальные детали приложения, пользовательские ресурсы, инструкции AGENTS и Release Notes 1.3.0
2026-05-26 12:59:21 +03:00

764 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Диалог «Тест ввода» — отображение цифровых и аналоговых входов платы.
Цифровые входы: команда 91 → INPUT<decimal> (26 бит: 24 входа + 2 хоппера)
Аналоговые входы: команда 92 → ADC<v1>,<v2>,<v3>,<v4>
При открытии диалог подписывается на единый поток чтения ComConnection.
"""
from __future__ import annotations
from PySide6.QtCore import Qt, QTimer, QPointF
from PySide6.QtGui import QPainter, QPen, QBrush, QColor
from PySide6.QtWidgets import (
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSizePolicy,
QSpinBox,
QVBoxLayout,
QWidget,
)
from ...services.com_service import ComConnection
from ...services.input_mappings_service import (
InputMapping,
_MAX_Y,
get_input_mappings_dir,
load_all_input_mappings,
)
from ...ui.icons import get_icon_color, get_icon_muted, icon
from ..no_wheel_combo_box import NoWheelComboBox
from .styled_dialog import StyledDialog
_NUM_DIGITAL = 26 # 24 входа + 2 хоппера
_JOY_SIZE = 120 # размер виджета джойстика в пикселях
class _JoystickWidget(QWidget):
"""Визуальный круг джойстика — показывает позицию по двум осям (0‥4095).
Поддерживает калибровку: при включённом режиме калибровки запоминает
min/max по каждой оси; нормализация ведётся по откалиброванному диапазону.
"""
_ADC_MAX = 4095.0
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self.setFixedSize(_JOY_SIZE, _JOY_SIZE)
self._x = 0.5
self._y = 0.5
self._calibrating = False
self._cal_x_min: int | None = None
self._cal_x_max: int | None = None
self._cal_y_min: int | None = None
self._cal_y_max: int | None = None
# ── Калибровка ────────────────────────────────────────────────────
def start_calibration(self) -> None:
self._calibrating = True
self._cal_x_min = None
self._cal_x_max = None
self._cal_y_min = None
self._cal_y_max = None
def finish_calibration(self) -> None:
self._calibrating = False
def reset_calibration(self) -> None:
self._calibrating = False
self._cal_x_min = None
self._cal_x_max = None
self._cal_y_min = None
self._cal_y_max = None
@property
def is_calibrated(self) -> bool:
return self._cal_x_min is not None and self._cal_x_max is not None
def calibration_text(self) -> str:
if not self.is_calibrated:
return "Нет"
return (
f"X: {self._cal_x_min}–{self._cal_x_max} "
f"Y: {self._cal_y_min}–{self._cal_y_max}"
)
# ── Данные ────────────────────────────────────────────────────────
def _normalize(self, raw: int, cal_min: int | None, cal_max: int | None) -> float:
if cal_min is not None and cal_max is not None and cal_max > cal_min:
return max(0.0, min((raw - cal_min) / (cal_max - cal_min), 1.0))
return max(0.0, min(raw / self._ADC_MAX, 1.0))
def set_values(self, raw_x: int, raw_y: int) -> None:
if self._calibrating:
if self._cal_x_min is None or raw_x < self._cal_x_min:
self._cal_x_min = raw_x
if self._cal_x_max is None or raw_x > self._cal_x_max:
self._cal_x_max = raw_x
if self._cal_y_min is None or raw_y < self._cal_y_min:
self._cal_y_min = raw_y
if self._cal_y_max is None or raw_y > self._cal_y_max:
self._cal_y_max = raw_y
self._x = self._normalize(raw_x, self._cal_x_min, self._cal_x_max)
self._y = self._normalize(raw_y, self._cal_y_min, self._cal_y_max)
self.update()
# ── Рисование ─────────────────────────────────────────────────────
def paintEvent(self, event) -> None: # noqa: N802
p = QPainter(self)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
w, h = self.width(), self.height()
cx, cy = w / 2.0, h / 2.0
radius = min(w, h) / 2.0 - 4
p.setPen(QPen(QColor("#555555"), 1.5))
p.setBrush(QBrush(QColor(40, 40, 40, 80)))
p.drawEllipse(QPointF(cx, cy), radius, radius)
p.setPen(QPen(QColor("#444444"), 0.8))
p.drawLine(QPointF(cx - radius, cy), QPointF(cx + radius, cy))
p.drawLine(QPointF(cx, cy - radius), QPointF(cx, cy + radius))
px = cx + (self._x - 0.5) * 2.0 * radius
py = cy + (self._y - 0.5) * 2.0 * radius
dot_r = 6.0
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QBrush(QColor("#10b981")))
p.drawEllipse(QPointF(px, py), dot_r, dot_r)
p.end()
class InputTestDialog(StyledDialog):
def __init__(self, connection: ComConnection, parent=None) -> None:
super().__init__(parent)
self._conn = connection
self._closed = False
self.setWindowTitle("Тест ввода")
self.setMinimumSize(600, 560)
# Состояние программной автовыдачи хоппера (0 = неактивна, 1 или 2 = номер хоппера)
self._auto_dispense_hopper: int = 0
self._auto_dispense_timer: QTimer | None = None
self._mappings = load_all_input_mappings()
self._current_mapping: InputMapping | None = None
self._setup_ui()
self._apply_mapping()
self._events_subscribed = False
self._conn.input_received.connect(
self._on_digital, Qt.ConnectionType.QueuedConnection
)
self._conn.adc_received.connect(
self._on_analog, Qt.ConnectionType.QueuedConnection
)
self._conn.info_received.connect(
self._on_info_line, Qt.ConnectionType.QueuedConnection
)
self._events_subscribed = True
# ── UI ────────────────────────────────────────────────────────────
def _setup_ui(self) -> None:
root = self.containerLayout()
root.setSpacing(12)
# ── Выбор маппинга ──
mapping_row = QHBoxLayout()
mapping_row.addWidget(QLabel("Раскладка:"))
self._mapping_combo = NoWheelComboBox()
self._mapping_combo.addItem("— Без раскладки —")
for m in self._mappings:
self._mapping_combo.addItem(m.project_name)
self._mapping_combo.currentIndexChanged.connect(self._on_mapping_changed)
mapping_row.addWidget(self._mapping_combo, 1)
reload_btn = QPushButton()
reload_btn.setIcon(icon("fa5s.sync-alt", color=get_icon_color()))
reload_btn.setToolTip("Перечитать файлы раскладок")
reload_btn.setFixedWidth(36)
reload_btn.clicked.connect(self._reload_mappings)
mapping_row.addWidget(reload_btn)
open_dir_btn = QPushButton()
open_dir_btn.setIcon(icon("fa5s.folder-open", color=get_icon_color()))
open_dir_btn.setToolTip("Открыть папку input_mappings")
open_dir_btn.setFixedWidth(36)
open_dir_btn.clicked.connect(self._open_mappings_dir)
mapping_row.addWidget(open_dir_btn)
root.addLayout(mapping_row)
# ── Холст кнопок (по координатам из маппинга) ──
self._canvas_group = QGroupBox("Кнопки")
self._canvas_grid = QGridLayout(self._canvas_group)
self._canvas_grid.setSpacing(4)
self._canvas_labels: dict[int, QLabel] = {} # input_index → QLabel
root.addWidget(self._canvas_group)
# ── Цифровые входы (классическая сетка) ──
self._digital_group = QGroupBox("Цифровые входы (24 + 2 хоппера)")
digital_grid = QGridLayout(self._digital_group)
digital_grid.setSpacing(4)
self._digital_labels: list[QLabel] = []
for i in range(_NUM_DIGITAL):
lbl = QLabel(f"IN{i}")
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl.setFixedSize(52, 28)
lbl.setObjectName("InputIndicator")
lbl.setProperty("active", False)
self._digital_labels.append(lbl)
row, col = divmod(i, 8)
digital_grid.addWidget(lbl, row, col)
root.addWidget(self._digital_group)
# ── Аналоговые входы (джойстики) ──
analog_group = QGroupBox("Аналоговые входы — Джойстики")
analog_outer = QVBoxLayout(analog_group)
# Два круга — джойстик 1 и джойстик 2
joysticks_row = QHBoxLayout()
# Джойстик 1 (ADC0, ADC1)
joy1_frame = QGroupBox("Джойстик 1")
joy1_layout = QVBoxLayout(joy1_frame)
self._joy1_canvas = _JoystickWidget()
joy1_layout.addWidget(self._joy1_canvas, alignment=Qt.AlignmentFlag.AlignCenter)
self._joy1_label = QLabel("X: — Y: —")
self._joy1_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
joy1_layout.addWidget(self._joy1_label)
self._joy1_cal_label = QLabel("Калибровка: Нет")
self._joy1_cal_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._joy1_cal_label.setStyleSheet("color: #888; font-size: 10px;")
joy1_layout.addWidget(self._joy1_cal_label)
joy1_btn_row = QHBoxLayout()
self._joy1_cal_btn = QPushButton("Калибровать")
self._joy1_cal_btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color()))
self._joy1_cal_btn.setToolTip("Покрутите джойстик по кругу, затем нажмите 'Готово'")
self._joy1_cal_btn.clicked.connect(lambda: self._toggle_calibration(1))
joy1_btn_row.addWidget(self._joy1_cal_btn)
self._joy1_reset_btn = QPushButton("Сброс")
self._joy1_reset_btn.setIcon(icon("fa5s.undo", color=get_icon_color()))
self._joy1_reset_btn.setToolTip("Сбросить калибровку джойстика 1")
self._joy1_reset_btn.clicked.connect(lambda: self._reset_calibration(1))
joy1_btn_row.addWidget(self._joy1_reset_btn)
joy1_layout.addLayout(joy1_btn_row)
joysticks_row.addWidget(joy1_frame)
# Джойстик 2 (ADC2, ADC3)
joy2_frame = QGroupBox("Джойстик 2")
joy2_layout = QVBoxLayout(joy2_frame)
self._joy2_canvas = _JoystickWidget()
joy2_layout.addWidget(self._joy2_canvas, alignment=Qt.AlignmentFlag.AlignCenter)
self._joy2_label = QLabel("X: — Y: —")
self._joy2_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
joy2_layout.addWidget(self._joy2_label)
self._joy2_cal_label = QLabel("Калибровка: Нет")
self._joy2_cal_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self._joy2_cal_label.setStyleSheet("color: #888; font-size: 10px;")
joy2_layout.addWidget(self._joy2_cal_label)
joy2_btn_row = QHBoxLayout()
self._joy2_cal_btn = QPushButton("Калибровать")
self._joy2_cal_btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color()))
self._joy2_cal_btn.setToolTip("Покрутите джойстик по кругу, затем нажмите 'Готово'")
self._joy2_cal_btn.clicked.connect(lambda: self._toggle_calibration(2))
joy2_btn_row.addWidget(self._joy2_cal_btn)
self._joy2_reset_btn = QPushButton("Сброс")
self._joy2_reset_btn.setIcon(icon("fa5s.undo", color=get_icon_color()))
self._joy2_reset_btn.setToolTip("Сбросить калибровку джойстика 2")
self._joy2_reset_btn.clicked.connect(lambda: self._reset_calibration(2))
joy2_btn_row.addWidget(self._joy2_reset_btn)
joy2_layout.addLayout(joy2_btn_row)
joysticks_row.addWidget(joy2_frame)
analog_outer.addLayout(joysticks_row)
# Управление автоопросом ADC
adc_ctrl_row = QHBoxLayout()
adc_ctrl_row.addWidget(QLabel("Интервал (мс):"))
self._adc_interval_spin = QSpinBox()
self._adc_interval_spin.setRange(50, 5000)
self._adc_interval_spin.setValue(100)
self._adc_interval_spin.setSingleStep(50)
adc_ctrl_row.addWidget(self._adc_interval_spin)
self._adc_toggle_btn = QPushButton("Начать тест")
self._adc_toggle_btn.setIcon(icon("fa5s.play", color=get_icon_color()))
self._adc_toggle_btn.clicked.connect(self._toggle_adc_polling)
adc_ctrl_row.addWidget(self._adc_toggle_btn)
adc_ctrl_row.addStretch()
analog_outer.addLayout(adc_ctrl_row)
root.addWidget(analog_group)
self._adc_polling_active = False
# ── Монетоприёмник ──
coin_group = QGroupBox("Монетоприёмник")
coin_layout = QHBoxLayout(coin_group)
self._coin_label = QLabel("Монеты: —")
self._coin_label.setMinimumWidth(120)
coin_layout.addWidget(self._coin_label)
coin_confirm_btn = QPushButton("Подтвердить (10)")
coin_confirm_btn.setIcon(icon("fa5s.check-circle", color=get_icon_color()))
coin_confirm_btn.setToolTip("Отправить подтверждение COIN (команда 10)")
coin_confirm_btn.clicked.connect(lambda: self._send_raw("10"))
coin_layout.addWidget(coin_confirm_btn)
coin_layout.addStretch()
root.addWidget(coin_group)
# ── Хопперы ──
hopper_group = QGroupBox("Хопперы")
hopper_layout = QVBoxLayout(hopper_group)
# Хоппер 1
h1_row = QHBoxLayout()
h1_row.addWidget(QLabel("Хоппер 1:"))
self._hopper1_label = QLabel("—")
self._hopper1_label.setMinimumWidth(100)
h1_row.addWidget(self._hopper1_label)
h1_on = QPushButton("Вкл")
h1_on.setIcon(icon("fa5s.play", color=get_icon_color()))
h1_on.setToolTip("Включить мотор (301)")
h1_on.clicked.connect(lambda: self._send_raw("301"))
h1_row.addWidget(h1_on)
h1_off = QPushButton("Выкл")
h1_off.setIcon(icon("fa5s.stop", color=get_icon_color()))
h1_off.setToolTip("Выключить мотор (300)")
h1_off.clicked.connect(lambda: self._send_raw("300"))
h1_row.addWidget(h1_off)
self._h1_auto_btn = QPushButton("Автовыдача")
self._h1_auto_btn.setIcon(icon("fa5s.cog", color=get_icon_color()))
self._h1_auto_btn.setToolTip("Программная автовыдача: вкл мотор → ждёт датчик (10 сек)")
self._h1_auto_btn.clicked.connect(lambda: self._start_auto_dispense(1))
h1_row.addWidget(self._h1_auto_btn)
h1_prize = QPushButton("Выдать приз")
h1_prize.setIcon(icon("fa5s.gift", color=get_icon_color()))
h1_prize.setToolTip("Авторежим выдачи платы (309)")
h1_prize.clicked.connect(lambda: self._send_raw("309"))
h1_row.addWidget(h1_prize)
h1_row.addStretch()
hopper_layout.addLayout(h1_row)
# Хоппер 2
h2_row = QHBoxLayout()
h2_row.addWidget(QLabel("Хоппер 2:"))
self._hopper2_label = QLabel("—")
self._hopper2_label.setMinimumWidth(100)
h2_row.addWidget(self._hopper2_label)
h2_on = QPushButton("Вкл")
h2_on.setIcon(icon("fa5s.play", color=get_icon_color()))
h2_on.setToolTip("Включить мотор (401)")
h2_on.clicked.connect(lambda: self._send_raw("401"))
h2_row.addWidget(h2_on)
h2_off = QPushButton("Выкл")
h2_off.setIcon(icon("fa5s.stop", color=get_icon_color()))
h2_off.setToolTip("Выключить мотор (400)")
h2_off.clicked.connect(lambda: self._send_raw("400"))
h2_row.addWidget(h2_off)
self._h2_auto_btn = QPushButton("Автовыдача")
self._h2_auto_btn.setIcon(icon("fa5s.cog", color=get_icon_color()))
self._h2_auto_btn.setToolTip("Программная автовыдача: вкл мотор → ждёт датчик (10 сек)")
self._h2_auto_btn.clicked.connect(lambda: self._start_auto_dispense(2))
h2_row.addWidget(self._h2_auto_btn)
h2_prize = QPushButton("Выдать приз")
h2_prize.setIcon(icon("fa5s.gift", color=get_icon_color()))
h2_prize.setToolTip("Авторежим выдачи платы (409)")
h2_prize.clicked.connect(lambda: self._send_raw("409"))
h2_row.addWidget(h2_prize)
h2_row.addStretch()
hopper_layout.addLayout(h2_row)
root.addWidget(hopper_group)
# ── Лог событий ──
self._event_log = QLabel("")
self._event_log.setWordWrap(True)
self._event_log.setStyleSheet("color: #888; font-size: 11px;")
root.addWidget(self._event_log)
# ── Антидребезг ──
debounce_layout = QHBoxLayout()
debounce_layout.addWidget(QLabel("Антидребезг (мс):"))
self._debounce_spin = QSpinBox()
self._debounce_spin.setRange(10, 100)
self._debounce_spin.setValue(50)
debounce_layout.addWidget(self._debounce_spin)
set_debounce_btn = QPushButton("Применить")
set_debounce_btn.setIcon(icon("fa5s.check", color=get_icon_color()))
set_debounce_btn.clicked.connect(self._set_debounce)
debounce_layout.addWidget(set_debounce_btn)
debounce_layout.addStretch()
root.addLayout(debounce_layout)
# ── Закрыть ──
btn_layout = QHBoxLayout()
btn_layout.addStretch()
close_btn = QPushButton("Закрыть")
close_btn.setIcon(icon("fa5s.times", color=get_icon_muted()))
close_btn.clicked.connect(self.close)
btn_layout.addWidget(close_btn)
root.addLayout(btn_layout)
# ── Маппинги ──────────────────────────────────────────────────────
def _on_mapping_changed(self, index: int) -> None:
if index <= 0:
self._current_mapping = None
else:
self._current_mapping = self._mappings[index - 1]
self._apply_mapping()
def _apply_mapping(self) -> None:
"""Перестроить холст кнопок по текущему маппингу."""
# Очистить старые виджеты
for lbl in self._canvas_labels.values():
self._canvas_grid.removeWidget(lbl)
lbl.deleteLater()
self._canvas_labels.clear()
# Сбросить stretch у всех колонок/строк
for c in range(self._canvas_grid.columnCount()):
self._canvas_grid.setColumnStretch(c, 0)
for r in range(self._canvas_grid.rowCount()):
self._canvas_grid.setRowStretch(r, 0)
mapping = self._current_mapping
if mapping is None or not mapping.buttons:
self._canvas_group.hide()
self._digital_group.show()
return
self._canvas_group.show()
self._canvas_group.setTitle(f"Кнопки — {mapping.project_name}")
self._digital_group.hide()
used_cols: set[int] = set()
used_rows: set[int] = set()
for btn in mapping.buttons:
lbl = QLabel(btn.name)
lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
lbl.setObjectName("InputIndicator")
lbl.setProperty("active", False)
lbl.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred
)
lbl.setMinimumHeight(28)
# Y инвертирован: +Y = вверх → меньший row в grid
grid_row = _MAX_Y - btn.y
self._canvas_grid.addWidget(lbl, grid_row, btn.x)
self._canvas_labels[btn.input_index] = lbl
used_cols.add(btn.x)
used_rows.add(grid_row)
# Равномерно растянуть используемые колонки
for c in used_cols:
self._canvas_grid.setColumnStretch(c, 1)
for r in used_rows:
self._canvas_grid.setRowStretch(r, 1)
# ── Обновление индикаторов ────────────────────────────────────────
def _on_digital(self, val: int) -> None:
if self._closed:
return
# Проверяем автовыдачу хоппера
self._check_auto_dispense(val)
# Обновляем классическую сетку
for i, lbl in enumerate(self._digital_labels):
active = bool(val & (1 << i))
pressed = not active # pull-up: 1 = не нажата, 0 = нажата
if lbl.property("active") != pressed:
lbl.setProperty("active", pressed)
lbl.style().unpolish(lbl)
lbl.style().polish(lbl)
lbl.update()
# Обновляем холст маппинга
for input_idx, lbl in self._canvas_labels.items():
if input_idx < _NUM_DIGITAL:
active = bool(val & (1 << input_idx))
pressed = not active
if lbl.property("active") != pressed:
lbl.setProperty("active", pressed)
lbl.style().unpolish(lbl)
lbl.style().polish(lbl)
lbl.update()
def _on_analog(self, values: list[str]) -> None:
if self._closed:
return
vals = [0, 0, 0, 0]
for i, v in enumerate(values[:4]):
try:
vals[i] = int(v)
except ValueError:
vals[i] = 0
# Джойстик 1: ADC0 (X), ADC1 (Y)
self._joy1_canvas.set_values(vals[0], vals[1])
self._joy1_label.setText(f"X: {vals[0]} Y: {vals[1]}")
if self._joy1_canvas._calibrating:
self._joy1_cal_label.setText("Калибровка: идёт… покрутите джойстик")
# Джойстик 2: ADC2 (X), ADC3 (Y)
self._joy2_canvas.set_values(vals[2], vals[3])
self._joy2_label.setText(f"X: {vals[2]} Y: {vals[3]}")
if self._joy2_canvas._calibrating:
self._joy2_cal_label.setText("Калибровка: идёт… покрутите джойстик")
# ── Прочее ────────────────────────────────────────────────────────
def _send_raw(self, cmd: str) -> None:
"""Отправить произвольную команду через ComConnection."""
if not self._conn.is_connected or self._closed:
return
try:
self._conn.send_async(cmd)
except Exception:
pass
def _on_info_line(self, line: str) -> None:
"""Обработать информационную строку от платы (COINS, HOPPER, …)."""
if self._closed:
return
upper = line.upper()
if upper.startswith("COINS"):
self._coin_label.setText(f"Монеты: {line}")
elif upper.startswith("HOPPER1"):
self._hopper1_label.setText(line)
elif upper.startswith("HOPPER2"):
self._hopper2_label.setText(line)
elif upper.startswith("HOPPER"):
# Общий формат без номера
self._hopper1_label.setText(line)
elif upper.startswith("PRIZES"):
self._coin_label.setText(f"Монеты: {line}")
# Обновляем лог последнего события
self._event_log.setText(f"← {line}")
def _toggle_adc_polling(self) -> None:
"""Начать или прекратить периодический опрос ADC."""
if not self._conn.is_connected or self._closed:
return
if self._adc_polling_active:
self._send_raw("92")
self._adc_polling_active = False
self._adc_toggle_btn.setText("Начать тест")
self._adc_toggle_btn.setIcon(icon("fa5s.play", color=get_icon_color()))
self._adc_interval_spin.setEnabled(True)
else:
interval = self._adc_interval_spin.value()
self._send_raw(f"92{interval}")
self._adc_polling_active = True
self._adc_toggle_btn.setText("Прекратить тест")
self._adc_toggle_btn.setIcon(icon("fa5s.stop", color=get_icon_color()))
self._adc_interval_spin.setEnabled(False)
# ── Калибровка джойстиков ─────────────────────────────────────────
def _toggle_calibration(self, joy: int) -> None:
"""Начать/завершить калибровку джойстика 1 или 2."""
canvas = self._joy1_canvas if joy == 1 else self._joy2_canvas
btn = self._joy1_cal_btn if joy == 1 else self._joy2_cal_btn
cal_label = self._joy1_cal_label if joy == 1 else self._joy2_cal_label
if canvas._calibrating:
canvas.finish_calibration()
btn.setText("Калибровать")
btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color()))
cal_label.setText(f"Калибровка: {canvas.calibration_text()}")
else:
canvas.start_calibration()
btn.setText("Готово")
btn.setIcon(icon("fa5s.check", color=get_icon_color()))
cal_label.setText("Калибровка: идёт… покрутите джойстик")
def _reset_calibration(self, joy: int) -> None:
"""Сбросить калибровку джойстика."""
canvas = self._joy1_canvas if joy == 1 else self._joy2_canvas
btn = self._joy1_cal_btn if joy == 1 else self._joy2_cal_btn
cal_label = self._joy1_cal_label if joy == 1 else self._joy2_cal_label
canvas.reset_calibration()
btn.setText("Калибровать")
btn.setIcon(icon("fa5s.crosshairs", color=get_icon_color()))
cal_label.setText("Калибровка: Нет")
def _poll_analog(self) -> None:
"""Запросить однократный опрос аналоговых входов."""
if not self._conn.is_connected or self._closed:
return
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._send_raw(f"91{val}")
# ── Программная автовыдача хоппера ────────────────────────────────
def _start_auto_dispense(self, hopper: int) -> None:
"""Запустить программную автовыдачу для хоппера 1 или 2.
Алгоритм:
1. Включить мотор (301 / 401)
2. Следить за битом датчика хоппера в INPUT (бит 24 / 25)
3. Если бит сработал (0) за 10 сек → выключить мотор → OK
4. Если 10 сек прошло → выключить мотор → ошибка
"""
if not self._conn.is_connected or self._closed:
return
if self._auto_dispense_hopper != 0:
# Уже идёт автовыдача
return
self._auto_dispense_hopper = hopper
label = self._hopper1_label if hopper == 1 else self._hopper2_label
btn = self._h1_auto_btn if hopper == 1 else self._h2_auto_btn
label.setText("Автовыдача…")
btn.setEnabled(False)
# Включаем мотор
on_cmd = "301" if hopper == 1 else "401"
self._send_raw(on_cmd)
# Таймаут 10 секунд
self._auto_dispense_timer = QTimer(self)
self._auto_dispense_timer.setSingleShot(True)
self._auto_dispense_timer.timeout.connect(self._auto_dispense_timeout)
self._auto_dispense_timer.start(10_000)
def _check_auto_dispense(self, val: int) -> None:
"""Проверить, сработал ли датчик хоппера (вызывается из _on_digital)."""
hopper = self._auto_dispense_hopper
if hopper == 0:
return
# Бит 24 = хоппер 1, бит 25 = хоппер 2
bit_index = 23 + hopper # 24 или 25
sensor_active = not bool(val & (1 << bit_index)) # pull-up: 0 = сработал
if sensor_active:
self._finish_auto_dispense(success=True)
def _auto_dispense_timeout(self) -> None:
"""Таймаут 10 сек — датчик не сработал."""
if self._auto_dispense_hopper != 0:
self._finish_auto_dispense(success=False)
def _finish_auto_dispense(self, *, success: bool) -> None:
"""Завершить автовыдачу: выключить мотор и показать результат."""
hopper = self._auto_dispense_hopper
if hopper == 0:
return
# Останавливаем таймер
if self._auto_dispense_timer is not None:
self._auto_dispense_timer.stop()
self._auto_dispense_timer.deleteLater()
self._auto_dispense_timer = None
# Выключаем мотор
off_cmd = "300" if hopper == 1 else "400"
self._send_raw(off_cmd)
label = self._hopper1_label if hopper == 1 else self._hopper2_label
btn = self._h1_auto_btn if hopper == 1 else self._h2_auto_btn
self._auto_dispense_hopper = 0
btn.setEnabled(True)
if success:
label.setText("Автовыдача: OK ✓")
self._event_log.setText(f"← Хоппер {hopper}: приз выдан")
else:
label.setText("Автовыдача: ОШИБКА ✗")
self._event_log.setText(f"← Хоппер {hopper}: таймаут 10 сек")
QMessageBox.warning(
self,
f"Хоппер {hopper}",
f"Ошибка выдачи: датчик хоппера {hopper} не сработал за 10 секунд.",
)
def _reload_mappings(self) -> None:
"""Перечитать .mapping файлы из папки."""
current_name = self._mapping_combo.currentText()
self._mappings = load_all_input_mappings()
self._mapping_combo.blockSignals(True)
self._mapping_combo.clear()
self._mapping_combo.addItem("— Без раскладки —")
for m in self._mappings:
self._mapping_combo.addItem(m.project_name)
# Попробуем восстановить выбор
idx = self._mapping_combo.findText(current_name)
if idx >= 0:
self._mapping_combo.setCurrentIndex(idx)
self._mapping_combo.blockSignals(False)
self._on_mapping_changed(self._mapping_combo.currentIndex())
def _open_mappings_dir(self) -> None:
"""Открыть папку input_mappings/ в проводнике."""
import os
import subprocess
import sys as _sys
mappings_dir = get_input_mappings_dir()
if not mappings_dir.exists():
mappings_dir.mkdir(parents=True, exist_ok=True)
if _sys.platform == "win32":
os.startfile(str(mappings_dir)) # noqa: S606
elif _sys.platform == "darwin":
subprocess.Popen(["open", str(mappings_dir)]) # noqa: S603, S607
else:
subprocess.Popen(["xdg-open", str(mappings_dir)]) # noqa: S603, S607
def closeEvent(self, event) -> None:
# Если ADC автовывод был включён — выключить
if self._adc_polling_active:
self._adc_polling_active = False
self._send_raw("92")
# Если автовыдача активна — выключаем мотор
if self._auto_dispense_hopper != 0:
off_cmd = "300" if self._auto_dispense_hopper == 1 else "400"
self._auto_dispense_hopper = 0
if self._auto_dispense_timer is not None:
self._auto_dispense_timer.stop()
self._auto_dispense_timer = None
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:
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
super().closeEvent(event)