"""Диалог «Тест ввода» — отображение цифровых и аналоговых входов платы. Цифровые входы: команда 91 → INPUT (26 бит: 24 входа + 2 хоппера) Аналоговые входы: команда 92 → ADC,,, При открытии диалог подписывается на единый поток чтения 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)