"""Встраиваемая секция «Проверка ввода» — цифровые/аналоговые входы. Повторяет функциональность InputTestDialog, но как обычный QWidget. Подписывается на единый поток чтения ComConnection при start_polling() и отписывается при stop_polling() или скрытии виджета. """ from __future__ import annotations import os import subprocess import sys as _sys from PySide6.QtCore import Qt, QTimer, QPointF from PySide6.QtGui import QPainter, QPen, QBrush, QColor from PySide6.QtWidgets import ( QGridLayout, QGroupBox, QHBoxLayout, QLabel, 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 _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 # нормализованные 0..1 self._y = 0.5 # Калибровка: min/max для каждой оси 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: """Начать калибровку: сбросить min/max и начать запоминать.""" 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: """Завершить калибровку — min/max зафиксированы.""" self._calibrating = False def reset_calibration(self) -> None: """Сбросить калибровку — вернуться к диапазону 0‥4095.""" 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: """Нормализовать значение в 0..1 с учётом калибровки.""" 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: """Принять сырые ADC-значения (0‥4095) и перерисовать.""" # Обновляем калибровку 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 InputTestSection(QWidget): """Inline-секция проверки ввода (для базового режима).""" def __init__(self, connection: ComConnection, parent: QWidget | None = None) -> None: super().__init__(parent) self._conn = connection self._closed = False self._polling = False self._mappings = load_all_input_mappings() self._current_mapping: InputMapping | None = None self._setup_ui() self._apply_mapping() # ── UI ──────────────────────────────────────────────────────────── def _setup_ui(self) -> None: root = QVBoxLayout(self) 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 = 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) self._reload_btn = QPushButton() self._reload_btn.setIcon(icon("fa5s.sync-alt", color=get_icon_color())) self._reload_btn.setToolTip("Перечитать файлы раскладок") self._reload_btn.setFixedWidth(36) self._reload_btn.clicked.connect(self._reload_mappings) mapping_row.addWidget(self._reload_btn) self._open_dir_btn = QPushButton() self._open_dir_btn.setIcon(icon("fa5s.folder-open", color=get_icon_color())) self._open_dir_btn.setToolTip("Открыть папку input_mappings") self._open_dir_btn.setFixedWidth(36) self._open_dir_btn.clicked.connect(self._open_mappings_dir) mapping_row.addWidget(self._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] = {} 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_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 # ── Лог событий ── 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) self._set_debounce_btn = QPushButton("Применить") self._set_debounce_btn.setIcon(icon("fa5s.check", color=get_icon_color())) self._set_debounce_btn.clicked.connect(self._set_debounce) debounce_layout.addWidget(self._set_debounce_btn) debounce_layout.addStretch() root.addLayout(debounce_layout) # ── Public API ──────────────────────────────────────────────────── def start_polling(self) -> None: """Подписаться на события входов. Вызывать после подключения.""" if self._polling: return if not self._conn.is_connected: return self._closed = 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._polling = True def stop_polling(self) -> None: """Отписаться от событий входов.""" if not self._polling: return # Если ADC автовывод был включён — выключить if self._adc_polling_active: 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) self._send_raw("92") self._closed = True 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._polling = False def refresh_icons(self) -> None: clr = get_icon_color() self._reload_btn.setIcon(icon("fa5s.sync-alt", color=clr)) self._open_dir_btn.setIcon(icon("fa5s.folder-open", color=clr)) if not self._adc_polling_active: self._adc_toggle_btn.setIcon(icon("fa5s.play", color=clr)) else: self._adc_toggle_btn.setIcon(icon("fa5s.stop", color=clr)) self._set_debounce_btn.setIcon(icon("fa5s.check", color=clr)) self._joy1_cal_btn.setIcon(icon("fa5s.crosshairs", color=clr)) self._joy1_reset_btn.setIcon(icon("fa5s.undo", color=clr)) self._joy2_cal_btn.setIcon(icon("fa5s.crosshairs", color=clr)) self._joy2_reset_btn.setIcon(icon("fa5s.undo", color=clr)) # ── Маппинги ────────────────────────────────────────────────────── 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() 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) 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 for i, lbl in enumerate(self._digital_labels): active = bool(val & (1 << i)) pressed = not active 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 # values = ["v0", "v1", "v2", "v3"] 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: 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: if self._closed: return 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: # Прекратить — отправить «92» (без параметра) 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: # Начать — отправить «92» 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 _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 _reload_mappings(self) -> None: 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: 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