plate-config-app/app/widgets/sections/input_test_section.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

589 lines
26 KiB
Python
Raw 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.

"""Встраиваемая секция «Проверка ввода» — цифровые/аналоговые входы.
Повторяет функциональность 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>»
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