plate-config-app/app/widgets/dialogs/led_test_dialog.py
Danil Iarosh 0ce06fc7f8 Rename physical channels in WS2812 settings and bump version to 1.4.1
- Переименованы элементы выбора физического канала в настройках WS2812: "1-й канал" -> "2-й канал", "2-й" -> "3-й", "3-й" -> "4-й".
- Удален неиспользуемый "4-й канал (4)", так как команды 894XY не существует.
- Изменения внесены в LedTestDialog и LedTestSection.
- Версия приложения обновлена до 1.4.1.
2026-07-02 11:03:16 +03:00

631 lines
25 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.

"""Диалог «Тест подсветки» — управление адресными диодами платы.
Загружает конфигурации эффектов из .lm файлов в папке light_modes/.
Поддерживает два формата команд:
- Простой «8XYY»: канал X (0=все, 1-4), программа YY
- С длительностью «85XXYY»: XX — эффект, YY — время в секундах
- Цветовое чередование «89[X]» (0=RGB … 5=GBR)
"""
from __future__ import annotations
import os
import subprocess
import sys
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (
QCheckBox,
QColorDialog,
QFormLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
from ...services.com_service import ComConnection
from ...services.light_modes_service import (
LightMode,
get_light_modes_dir,
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]] = [
(0, "RGB"),
(1, "RBG"),
(2, "BRG"),
(3, "BGR"),
(4, "GRB"),
(5, "GBR"),
]
class LedTestDialog(StyledDialog):
def __init__(self, connection: ComConnection, parent=None) -> None:
super().__init__(parent)
self._conn = connection
self._modes: list[LightMode] = []
self.setWindowTitle("Тест подсветки")
self.setMinimumWidth(480)
self._load_modes()
self._setup_ui()
def _load_modes(self) -> None:
self._modes = load_all_light_modes()
def _setup_ui(self) -> None:
root = self.containerLayout()
root.setSpacing(12)
# ── Выбор проекта (игры) ──
project_group = QGroupBox("Игровой проект")
project_layout = QVBoxLayout(project_group)
proj_row = QHBoxLayout()
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)
proj_row.addWidget(self._project_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_modes)
proj_row.addWidget(reload_btn)
open_dir_btn = QPushButton()
open_dir_btn.setIcon(icon("fa5s.folder-open", color=get_icon_color()))
open_dir_btn.setToolTip("Открыть папку light_modes")
open_dir_btn.setFixedWidth(36)
open_dir_btn.clicked.connect(self._open_modes_dir)
proj_row.addWidget(open_dir_btn)
project_layout.addLayout(proj_row)
root.addWidget(project_group)
# ── Эффекты ──
effect_group = QGroupBox("Эффекты")
effect_form = QFormLayout(effect_group)
# Канал (только для base_command = "8")
self._channel_combo = NoWheelComboBox()
self._channel_combo.addItem("Все каналы (0)", 0)
for ch in range(1, 5):
self._channel_combo.addItem(f"Канал {ch}", ch)
self._channel_label = QLabel("Канал:")
effect_form.addRow(self._channel_label, self._channel_combo)
# Список эффектов из конфига
self._effect_combo = NoWheelComboBox()
effect_form.addRow("Эффект:", self._effect_combo)
# Длительность (только для has_duration = true)
self._duration_spin = QSpinBox()
self._duration_spin.setRange(1, 99)
self._duration_spin.setValue(5)
self._duration_spin.setSuffix(" сек")
self._duration_label = QLabel("Длительность:")
effect_form.addRow(self._duration_label, self._duration_spin)
# Ручной код эффекта
self._custom_code_spin = QSpinBox()
self._custom_code_spin.setRange(0, 99)
self._custom_code_spin.setValue(10)
effect_form.addRow("Ручной код эффекта:", self._custom_code_spin)
# Кнопки отправки
btn_row = QHBoxLayout()
send_effect_btn = QPushButton("Отправить эффект")
send_effect_btn.setProperty("role", "primary")
send_effect_btn.setIcon(icon("fa5s.paper-plane", color="#ecfdf5"))
send_effect_btn.clicked.connect(self._send_effect)
btn_row.addWidget(send_effect_btn)
send_custom_btn = QPushButton("Ручной код")
send_custom_btn.setIcon(icon("fa5s.paper-plane", color=get_icon_muted()))
send_custom_btn.clicked.connect(self._send_custom_effect)
btn_row.addWidget(send_custom_btn)
# Кнопка «выключить» (base_command без параметра)
self._stop_btn = QPushButton("Выключить эффекты")
self._stop_btn.setIcon(icon("fa5s.stop", color=get_icon_muted()))
self._stop_btn.clicked.connect(self._send_stop)
btn_row.addWidget(self._stop_btn)
effect_form.addRow(btn_row)
root.addWidget(effect_group)
# ── Настройки WS2812 (cmd 89) ──
color_group = QGroupBox("Настройки WS2812 (cmd 89)")
color_form = QFormLayout(color_group)
color_form.setSpacing(6)
# Выбор физического канала
self._cmd89_channel_combo = NoWheelComboBox()
self._cmd89_channel_combo.addItem("Все ленты (0)", 0)
self._cmd89_channel_combo.addItem("2-й канал (1)", 1)
self._cmd89_channel_combo.addItem("3-й канал (2)", 2)
self._cmd89_channel_combo.addItem("4-й канал (3)", 3)
color_form.addRow("Физический канал:", self._cmd89_channel_combo)
# Порядок цветов
self._color_order_combo = NoWheelComboBox()
for code, name in _COLOR_ORDERS:
self._color_order_combo.addItem(f"{code} — {name}", code)
apply_order_btn = QPushButton("Применить порядок")
apply_order_btn.setProperty("role", "primary")
apply_order_btn.setIcon(icon("fa5s.palette", color="#ecfdf5"))
apply_order_btn.clicked.connect(self._send_color_order)
order_layout = QHBoxLayout()
order_layout.addWidget(self._color_order_combo, 1)
order_layout.addWidget(apply_order_btn)
color_form.addRow("Порядок цветов:", order_layout)
# Яркость
self._cmd89_brightness_spin = QSpinBox()
self._cmd89_brightness_spin.setRange(0, 255)
self._cmd89_brightness_spin.setValue(255)
apply_bright_btn = QPushButton("Применить яркость")
apply_bright_btn.setIcon(icon("fa5s.sun", color=get_icon_color()))
apply_bright_btn.clicked.connect(self._send_brightness)
bright_layout = QHBoxLayout()
bright_layout.addWidget(self._cmd89_brightness_spin, 1)
bright_layout.addWidget(apply_bright_btn)
color_form.addRow("Яркость:", bright_layout)
# Гамма-коррекция
self._cmd89_gamma_check = QCheckBox("Включить гамма-коррекцию")
apply_gamma_btn = QPushButton("Применить гамму")
apply_gamma_btn.setIcon(icon("fa5s.adjust", color=get_icon_color()))
apply_gamma_btn.clicked.connect(self._send_gamma)
gamma_layout = QHBoxLayout()
gamma_layout.addWidget(self._cmd89_gamma_check, 1)
gamma_layout.addWidget(apply_gamma_btn)
color_form.addRow("Гамма-коррекция:", gamma_layout)
# Пользовательский цвет
self._custom_color_btn = QPushButton("Выбрать и отправить цвет")
self._custom_color_btn.setIcon(icon("fa5s.palette", color=get_icon_color()))
self._custom_color_btn.clicked.connect(self._send_custom_color)
color_form.addRow("Свой цвет (08):", self._custom_color_btn)
# Тест цвета
self._color_test_btn = QPushButton("Тест цвета")
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_form.addRow(self._color_test_btn)
color_hint = QLabel("Должны вывестись: красный → зелёный → синий")
color_hint.setStyleSheet("color: #9ca3af; font-size: 11px;")
color_hint.setWordWrap(True)
color_form.addRow(color_hint)
root.addWidget(color_group)
# ── Бродяги морей (cmd 81) ──
wanderer_group = QGroupBox("Бродяги морей (cmd 81)")
wanderer_form = QFormLayout(wanderer_group)
wanderer_form.setSpacing(6)
# Объект 1
self._w1_level = QSpinBox()
self._w1_level.setRange(0, 21)
self._w1_level.setValue(5)
w1_apply_level = QPushButton("Задать уровень")
w1_apply_level.setIcon(icon("fa5s.angle-double-right", color=get_icon_color()))
w1_apply_level.clicked.connect(self._send_w1_level)
w1_level_layout = QHBoxLayout()
w1_level_layout.addWidget(self._w1_level, 1)
w1_level_layout.addWidget(w1_apply_level)
wanderer_form.addRow("Шкала 1 (уровень 0..21):", w1_level_layout)
self._w1_effect = NoWheelComboBox()
self._w1_effect.addItem("Эффект 1 (синий/фиолетовый)", 1)
self._w1_effect.addItem("Эффект 2 (фиолетовый/голубой)", 2)
self._w1_effect.addItem("Эффект 3 (мигание белым)", 3)
w1_apply_eff = QPushButton("Запустить эффект")
w1_apply_eff.setProperty("role", "primary")
w1_apply_eff.setIcon(icon("fa5s.play", color="#ecfdf5"))
w1_apply_eff.clicked.connect(self._send_w1_effect)
w1_eff_layout = QHBoxLayout()
w1_eff_layout.addWidget(self._w1_effect, 1)
w1_eff_layout.addWidget(w1_apply_eff)
wanderer_form.addRow("Эффекты шкалы 1:", w1_eff_layout)
# Объект 2
self._w2_level = QSpinBox()
self._w2_level.setRange(0, 44)
self._w2_level.setValue(5)
w2_apply_level = QPushButton("Задать уровень")
w2_apply_level.setIcon(icon("fa5s.angle-double-right", color=get_icon_color()))
w2_apply_level.clicked.connect(self._send_w2_level)
w2_level_layout = QHBoxLayout()
w2_level_layout.addWidget(self._w2_level, 1)
w2_level_layout.addWidget(w2_apply_level)
wanderer_form.addRow("Шкала 2 (уровень 0..44):", w2_level_layout)
self._w2_effect = NoWheelComboBox()
self._w2_effect.addItem("Эффект 1 (синий/фиолетовый)", 1)
self._w2_effect.addItem("Эффект 2 (фиолетовый/голубой)", 2)
self._w2_effect.addItem("Эффект 3 (мигание белым)", 3)
w2_apply_eff = QPushButton("Запустить эффект")
w2_apply_eff.setProperty("role", "primary")
w2_apply_eff.setIcon(icon("fa5s.play", color="#ecfdf5"))
w2_apply_eff.clicked.connect(self._send_w2_effect)
w2_eff_layout = QHBoxLayout()
w2_eff_layout.addWidget(self._w2_effect, 1)
w2_eff_layout.addWidget(w2_apply_eff)
wanderer_form.addRow("Эффекты шкалы 2:", w2_eff_layout)
# Сброс обоих
w_clear_btn = QPushButton("Очистить обе шкалы (8130)")
w_clear_btn.setIcon(icon("fa5s.eraser", color=get_icon_color()))
w_clear_btn.clicked.connect(self._clear_wanderer)
wanderer_form.addRow(w_clear_btn)
root.addWidget(wanderer_group)
# ── Управление виртуальной матрицей (cmd 85/86/87) ──
matrix_group = QGroupBox("Виртуальная матрица (cmd 85/86/87)")
matrix_form = QFormLayout(matrix_group)
matrix_form.setSpacing(6)
# Автокарусель / Рендеринг
carousel_layout = QHBoxLayout()
self._mc_on_btn = QPushButton("Карусель Вкл")
self._mc_on_btn.setIcon(icon("fa5s.play-circle", color=get_icon_color()))
self._mc_on_btn.clicked.connect(self._send_carousel_on)
carousel_layout.addWidget(self._mc_on_btn)
self._mc_off_btn = QPushButton("Карусель Выкл")
self._mc_off_btn.setIcon(icon("fa5s.stop-circle", color=get_icon_color()))
self._mc_off_btn.clicked.connect(self._send_carousel_off)
carousel_layout.addWidget(self._mc_off_btn)
self._mc_stop_btn = QPushButton("Rendering OFF")
self._mc_stop_btn.setProperty("role", "primary")
self._mc_stop_btn.setIcon(icon("fa5s.power-off", color="#ecfdf5"))
self._mc_stop_btn.clicked.connect(self._send_rendering_off)
carousel_layout.addWidget(self._mc_stop_btn)
matrix_form.addRow("Режимы рендера:", carousel_layout)
# Тесты матриц (8525 / 8625 / 8725)
test_type_layout = QHBoxLayout()
self._m_test_type = NoWheelComboBox()
self._m_test_type.addItem("Выключить тест (0)", 0)
self._m_test_type.addItem("Общий тест / Змейка (1)", 1)
self._m_test_type.addItem("Тест строки (2)", 2)
self._m_test_type.addItem("Тест столбца (3)", 3)
self._m_test_type.addItem("Тест пикселя (4)", 4)
self._m_test_type.currentIndexChanged.connect(self._on_matrix_test_type_changed)
test_type_layout.addWidget(self._m_test_type, 1)
self._m_strip_label = QLabel("Лента:")
self._m_strip_label.setVisible(False)
test_type_layout.addWidget(self._m_strip_label)
self._m_strip_spin = QSpinBox()
self._m_strip_spin.setRange(1, 4)
self._m_strip_spin.setValue(1)
self._m_strip_spin.setVisible(False)
test_type_layout.addWidget(self._m_strip_spin)
self._m_test_param = QSpinBox()
self._m_test_param.setRange(0, 999)
self._m_test_param.setValue(0)
self._m_test_param.setVisible(False)
test_type_layout.addWidget(self._m_test_param)
m_test_btn = QPushButton("Запустить")
m_test_btn.setIcon(icon("fa5s.vial", color=get_icon_color()))
m_test_btn.clicked.connect(self._send_matrix_test)
test_type_layout.addWidget(m_test_btn)
matrix_form.addRow("Аппаратный тест:", test_type_layout)
# Direct-шкалы (только для s_b)
self._scales_container = QWidget()
scales_layout = QVBoxLayout(self._scales_container)
scales_layout.setContentsMargins(0, 0, 0, 0)
scales_layout.setSpacing(4)
scales_title = QLabel("<b>Direct-шкалы (только для Морского боя s_b)</b>")
scales_layout.addWidget(scales_title)
self._m_scales: list[QSpinBox] = []
for i in range(4):
scale_row = QHBoxLayout()
spin = QSpinBox()
spin.setRange(0, 22)
spin.setValue(0)
self._m_scales.append(spin)
scale_row.addWidget(spin, 1)
btn = QPushButton(f"Задать шкалу {i+1}")
btn.setIcon(icon("fa5s.level-up-alt", color=get_icon_color()))
btn.clicked.connect(lambda checked=False, idx=i: self._send_matrix_scale(idx))
scale_row.addWidget(btn)
scales_layout.addLayout(scale_row)
clear_scales_btn = QPushButton("Очистить direct-шкалы (8530)")
clear_scales_btn.setIcon(icon("fa5s.trash-alt", color=get_icon_color()))
clear_scales_btn.clicked.connect(self._clear_matrix_scales)
scales_layout.addWidget(clear_scales_btn)
matrix_form.addRow(self._scales_container)
root.addWidget(matrix_group)
# ── Лог ──
self._log = QLabel("")
self._log.setObjectName("LogLabel")
self._log.setWordWrap(True)
root.addWidget(self._log)
# ── Закрыть ──
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)
# Инициализация состояния
self._on_project_changed()
# ── Переключение проектов ──
def _current_mode(self) -> LightMode | None:
return self._project_combo.currentData()
def _on_project_changed(self) -> None:
"""Обновить список эффектов и видимость элементов при смене проекта."""
mode = self._current_mode()
self._effect_combo.clear()
if mode is None:
return
for eff in mode.effects:
self._effect_combo.addItem(f"{eff.code:02d} — {eff.name}", eff.code)
# Канал — только для простых команд (base_command = "8")
is_channel_mode = mode.base_command == "8"
self._channel_combo.setVisible(is_channel_mode)
self._channel_label.setVisible(is_channel_mode)
# Длительность — только для has_duration
self._duration_spin.setVisible(mode.has_duration)
self._duration_label.setVisible(mode.has_duration)
# Кнопка «выключить» — только для has_duration
self._stop_btn.setVisible(mode.has_duration)
# Показывать direct-шкалы только для Морского боя s_b (где base_command == "85")
is_sb = (mode.base_command == "85")
self._scales_container.setVisible(is_sb)
def _reload_modes(self) -> None:
"""Перечитать .lm файлы из папки."""
current_name = self._project_combo.currentText()
self._load_modes()
self._project_combo.clear()
for mode in self._modes:
self._project_combo.addItem(mode.project_name, mode)
# Попробуем восстановить выбор
idx = self._project_combo.findText(current_name)
if idx >= 0:
self._project_combo.setCurrentIndex(idx)
self._log.setText(f"Загружено {len(self._modes)} конфигураций")
def _open_modes_dir(self) -> None:
"""Открыть папку light_modes/ в проводнике."""
modes_dir = get_light_modes_dir()
if not modes_dir.exists():
modes_dir.mkdir(parents=True, exist_ok=True)
if sys.platform == "win32":
os.startfile(str(modes_dir)) # noqa: S606
elif sys.platform == "darwin":
subprocess.Popen(["open", str(modes_dir)]) # noqa: S603, S607
else:
subprocess.Popen(["xdg-open", str(modes_dir)]) # noqa: S603, S607
# ── Построение команды ──
def _build_command(self, effect_code: int) -> str:
"""Собрать команду на основе текущего проекта."""
mode = self._current_mode()
if mode is None:
return ""
if mode.has_duration:
# Формат: base_command + XX (код) + YY (время)
duration = self._duration_spin.value()
return f"{mode.base_command}{effect_code:02d}{duration:02d}"
else:
# Формат: base_command + X (канал) + YY (код)
channel = self._channel_combo.currentData() or 0
return f"{mode.base_command}{channel}{effect_code:02d}"
# ── Отправка ──
def _send_effect(self) -> None:
code = self._effect_combo.currentData()
if code is None:
return
cmd = self._build_command(code)
self._send(cmd)
def _send_custom_effect(self) -> None:
code = self._custom_code_spin.value()
cmd = self._build_command(code)
self._send(cmd)
def _send_stop(self) -> None:
"""Отправить base_command без параметра (отключить эффекты)."""
mode = self._current_mode()
if mode is None:
return
self._send(mode.base_command)
def _send_color_order(self) -> None:
ch = self._cmd89_channel_combo.currentData()
code = self._color_order_combo.currentData()
cmd = f"89{ch}0{code}"
self._send(cmd)
def _send_brightness(self) -> None:
ch = self._cmd89_channel_combo.currentData()
val = self._cmd89_brightness_spin.value()
cmd = f"89{ch}1{val}"
self._send(cmd)
def _send_gamma(self) -> None:
ch = self._cmd89_channel_combo.currentData()
val = 1 if self._cmd89_gamma_check.isChecked() else 0
cmd = f"89{ch}2{val}"
self._send(cmd)
def _send_custom_color(self) -> None:
color = QColorDialog.getColor(Qt.GlobalColor.white, self, "Выберите цвет")
if color.isValid():
ch = self._cmd89_channel_combo.currentData()
r = color.red()
g = color.green()
b = color.blue()
cmd = f"8{ch}08{r:03d}{g:03d}{b:03d}"
self._send(cmd)
def _run_color_test(self) -> None:
"""Последовательно отправить 8003→8004→8005→8002→8000 с паузой 300 мс."""
self._color_test_cmds = ["8003", "8004", "8005", "8002", "8000"]
self._color_test_idx = 0
self._color_test_btn.setEnabled(False)
self._color_test_timer = QTimer(self)
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()
def _color_test_step(self) -> None:
if self._color_test_idx >= len(self._color_test_cmds):
self._color_test_timer.stop()
self._color_test_btn.setEnabled(True)
return
cmd = self._color_test_cmds[self._color_test_idx]
self._color_test_idx += 1
self._send(cmd)
def _send_w1_level(self) -> None:
val = self._w1_level.value()
cmd = f"8131{val:02d}"
self._send(cmd)
def _send_w1_effect(self) -> None:
val = self._w1_effect.currentData()
cmd = f"8131{val}"
self._send(cmd)
def _send_w2_level(self) -> None:
val = self._w2_level.value()
cmd = f"8132{val:02d}"
self._send(cmd)
def _send_w2_effect(self) -> None:
val = self._w2_effect.currentData()
cmd = f"8132{val}"
self._send(cmd)
def _clear_wanderer(self) -> None:
self._send("8130")
def _send_carousel_on(self) -> None:
mode = self._current_mode()
if mode:
self._send(f"{mode.base_command}01")
def _send_carousel_off(self) -> None:
mode = self._current_mode()
if mode:
self._send(f"{mode.base_command}00")
def _send_rendering_off(self) -> None:
mode = self._current_mode()
if mode:
self._send(mode.base_command)
def _on_matrix_test_type_changed(self, index: int) -> None:
test_type = self._m_test_type.itemData(index)
is_pixel_test = (test_type == 4)
self._m_strip_label.setVisible(is_pixel_test)
self._m_strip_spin.setVisible(is_pixel_test)
self._m_test_param.setVisible(test_type >= 2)
def _send_matrix_test(self) -> None:
mode = self._current_mode()
if not mode:
return
test_type = self._m_test_type.currentData()
if test_type == 0 or test_type == 1:
cmd = f"{mode.base_command}25{test_type}"
elif test_type == 2 or test_type == 3:
val = self._m_test_param.value()
cmd = f"{mode.base_command}25{test_type}{val:02d}"
elif test_type == 4:
strip = self._m_strip_spin.value()
pixel = self._m_test_param.value()
cmd = f"{mode.base_command}254{strip}{pixel:02d}"
self._send(cmd)
def _send_matrix_scale(self, index: int) -> None:
val = self._m_scales[index].value()
cmd = f"853{index+1}{val:02d}"
self._send(cmd)
def _clear_matrix_scales(self) -> None:
self._send("8530")
def _send(self, cmd: str) -> None:
if not cmd:
return
if not self._conn.is_connected:
QMessageBox.warning(self, "Ошибка", "Порт не подключён.")
return
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}")
except Exception as exc:
self._conn.release()
self._log.setText(f"Ошибка: {exc}")