Add new PyQt UI
This commit is contained in:
1
laser_control/gui/__init__.py
Normal file
1
laser_control/gui/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""PyQt GUI package for the laser-control application."""
|
||||
74
laser_control/gui/dialogs.py
Normal file
74
laser_control/gui/dialogs.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Small dialogs used by the main laser-control window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import QRegularExpression
|
||||
from PyQt6.QtGui import QRegularExpressionValidator
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from laser_control.constants import PROFILE_NAME_ALLOWED_PATTERN, PROFILE_NAME_MAX_LENGTH
|
||||
|
||||
|
||||
class ProfileSaveDialog(QDialog):
|
||||
"""Ask the user for a short profile name before saving it to the device SD card."""
|
||||
|
||||
def __init__(self, *, custom_waveform_available: bool, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Сохранить профиль на SD")
|
||||
self.setModal(True)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
note = QLabel(
|
||||
"Введите короткое имя профиля для маленького LCD на устройстве. "
|
||||
f"Допустимо до {PROFILE_NAME_MAX_LENGTH} ASCII-символов: буквы, цифры, пробел, '-' и '_'."
|
||||
)
|
||||
note.setWordWrap(True)
|
||||
|
||||
self._name_edit = QLineEdit(self)
|
||||
self._name_edit.setPlaceholderText("Например: Factory Saw")
|
||||
self._name_edit.setMaxLength(PROFILE_NAME_MAX_LENGTH)
|
||||
self._name_edit.setValidator(
|
||||
QRegularExpressionValidator(QRegularExpression(PROFILE_NAME_ALLOWED_PATTERN), self)
|
||||
)
|
||||
|
||||
self._waveform_checkbox = QCheckBox(
|
||||
"Сохранить и пользовательскую форму из вкладки «Своя форма»",
|
||||
self,
|
||||
)
|
||||
self._waveform_checkbox.setVisible(custom_waveform_available)
|
||||
|
||||
self._buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel,
|
||||
parent=self,
|
||||
)
|
||||
self._buttons.accepted.connect(self.accept)
|
||||
self._buttons.rejected.connect(self.reject)
|
||||
self._buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(False)
|
||||
|
||||
self._name_edit.textChanged.connect(self._update_accept_state)
|
||||
|
||||
layout.addWidget(note)
|
||||
layout.addWidget(self._name_edit)
|
||||
layout.addWidget(self._waveform_checkbox)
|
||||
layout.addWidget(self._buttons)
|
||||
|
||||
def profile_name(self) -> str:
|
||||
"""Return the trimmed display name entered by the user."""
|
||||
return self._name_edit.text().strip()
|
||||
|
||||
def include_custom_waveform(self) -> bool:
|
||||
"""Return True when a valid custom waveform should be saved with the profile."""
|
||||
return self._waveform_checkbox.isVisible() and self._waveform_checkbox.isChecked()
|
||||
|
||||
def _update_accept_state(self) -> None:
|
||||
self._buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(
|
||||
bool(self.profile_name())
|
||||
)
|
||||
32
laser_control/gui/main.py
Normal file
32
laser_control/gui/main.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Application entry point for the PyQt-based laser-control GUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
import pyqtgraph as pg
|
||||
|
||||
from .theme import apply_theme
|
||||
from .window import MainWindow
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the GUI event loop."""
|
||||
os.environ.setdefault("PYQTGRAPH_QT_LIB", "PyQt6")
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
pg.setConfigOptions(antialias=True, background="#0f1720", foreground="#dce6f2")
|
||||
apply_theme(app)
|
||||
|
||||
window = MainWindow(auto_connect=True)
|
||||
screen = app.primaryScreen()
|
||||
if screen is not None:
|
||||
window.setGeometry(screen.availableGeometry())
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
563
laser_control/gui/sections.py
Normal file
563
laser_control/gui/sections.py
Normal file
@ -0,0 +1,563 @@
|
||||
"""Small UI builders used by the main laser-control window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QTabWidget,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from laser_control.constants import (
|
||||
AD9102_PAT_BASE_MAX,
|
||||
AD9102_PAT_BASE_MIN,
|
||||
AD9102_PAT_PERIOD_MAX,
|
||||
AD9102_PAT_PERIOD_MIN,
|
||||
AD9102_SAW_STEP_MAX,
|
||||
AD9102_SAW_STEP_MIN,
|
||||
AD9102_SRAM_AMPLITUDE_MAX,
|
||||
AD9102_SRAM_AMPLITUDE_MIN,
|
||||
AD9102_SRAM_HOLD_MAX,
|
||||
AD9102_SRAM_HOLD_MIN,
|
||||
AD9102_SRAM_SAMPLE_MAX,
|
||||
AD9102_SRAM_SAMPLE_MIN,
|
||||
CURRENT_MAX_MA,
|
||||
CURRENT_MIN_MA,
|
||||
DEFAULT_AD9102_AMPLITUDE,
|
||||
DEFAULT_AD9102_SAW_FREQUENCY_HZ,
|
||||
DEFAULT_AD9102_SRAM_FREQUENCY_HZ,
|
||||
DEFAULT_AD9102_HOLD_CYCLES,
|
||||
DEFAULT_AD9102_PAT_BASE,
|
||||
DEFAULT_AD9102_PAT_PERIOD,
|
||||
DEFAULT_AD9102_SAMPLE_COUNT,
|
||||
DEFAULT_AD9102_SAW_STEP,
|
||||
DEFAULT_AD9833_FREQUENCY_HZ,
|
||||
DEFAULT_CURRENT1_MA,
|
||||
DEFAULT_CURRENT2_MA,
|
||||
DEFAULT_DS1809_COUNT,
|
||||
DEFAULT_DS1809_PROFILE_POSITION,
|
||||
DEFAULT_DS1809_PULSE_MS,
|
||||
DEFAULT_STM32_DAC_CODE,
|
||||
DEFAULT_TEMP1_C,
|
||||
DEFAULT_TEMP2_C,
|
||||
DS1809_COUNT_MAX,
|
||||
DS1809_COUNT_MIN,
|
||||
DS1809_PROFILE_POSITION_MAX,
|
||||
DS1809_PROFILE_POSITION_MIN,
|
||||
DS1809_PULSE_MS_MAX,
|
||||
DS1809_PULSE_MS_MIN,
|
||||
AD9833_MCLK_HZ,
|
||||
AD9833_OUTPUT_FREQ_MAX_HZ,
|
||||
AD9833_OUTPUT_FREQ_MIN_HZ,
|
||||
STM32_DAC_CODE_MAX,
|
||||
STM32_DAC_CODE_MIN,
|
||||
TEMP_MAX_C,
|
||||
TEMP_MIN_C,
|
||||
)
|
||||
|
||||
|
||||
def _double_spinbox(
|
||||
minimum: float,
|
||||
maximum: float,
|
||||
value: float,
|
||||
*,
|
||||
decimals: int = 2,
|
||||
step: float = 0.1,
|
||||
suffix: str = "",
|
||||
) -> QDoubleSpinBox:
|
||||
box = QDoubleSpinBox()
|
||||
box.setRange(minimum, maximum)
|
||||
box.setDecimals(decimals)
|
||||
box.setSingleStep(step)
|
||||
box.setValue(value)
|
||||
if suffix:
|
||||
box.setSuffix(suffix)
|
||||
return box
|
||||
|
||||
|
||||
def _int_spinbox(minimum: int, maximum: int, value: int, *, suffix: str = "") -> QSpinBox:
|
||||
box = QSpinBox()
|
||||
box.setRange(minimum, maximum)
|
||||
box.setValue(value)
|
||||
if suffix:
|
||||
box.setSuffix(suffix)
|
||||
return box
|
||||
|
||||
|
||||
def _expanding_button(label: str, *, primary: bool = False) -> QPushButton:
|
||||
button = QPushButton(label)
|
||||
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
if primary:
|
||||
button.setObjectName("primaryButton")
|
||||
return button
|
||||
|
||||
|
||||
def build_manual_group(owner) -> QGroupBox:
|
||||
"""Create manual control inputs."""
|
||||
group = QGroupBox("Ручной режим")
|
||||
layout = QFormLayout(group)
|
||||
layout.setHorizontalSpacing(12)
|
||||
layout.setVerticalSpacing(8)
|
||||
|
||||
owner._manual_temp1 = _double_spinbox(TEMP_MIN_C, TEMP_MAX_C, DEFAULT_TEMP1_C, suffix=" °C")
|
||||
owner._manual_temp2 = _double_spinbox(TEMP_MIN_C, TEMP_MAX_C, DEFAULT_TEMP2_C, suffix=" °C")
|
||||
owner._manual_current1 = _double_spinbox(
|
||||
CURRENT_MIN_MA,
|
||||
CURRENT_MAX_MA,
|
||||
DEFAULT_CURRENT1_MA,
|
||||
decimals=3,
|
||||
step=0.05,
|
||||
suffix=" мА",
|
||||
)
|
||||
owner._manual_current2 = _double_spinbox(
|
||||
CURRENT_MIN_MA,
|
||||
CURRENT_MAX_MA,
|
||||
DEFAULT_CURRENT2_MA,
|
||||
decimals=3,
|
||||
step=0.05,
|
||||
suffix=" мА",
|
||||
)
|
||||
|
||||
layout.addRow("Температура лазера 1", owner._manual_temp1)
|
||||
layout.addRow("Температура лазера 2", owner._manual_temp2)
|
||||
layout.addRow("Ток лазера 1", owner._manual_current1)
|
||||
layout.addRow("Ток лазера 2", owner._manual_current2)
|
||||
|
||||
owner._apply_manual_button = _expanding_button("Применить", primary=True)
|
||||
owner._apply_manual_button.clicked.connect(owner._on_apply_manual)
|
||||
layout.addRow(owner._apply_manual_button)
|
||||
return group
|
||||
|
||||
|
||||
def build_device_group(owner) -> QGroupBox:
|
||||
"""Create compact tabs for supported peripheral commands."""
|
||||
group = QGroupBox("Периферия")
|
||||
layout = QVBoxLayout(group)
|
||||
|
||||
tabs = QTabWidget(group)
|
||||
tabs.addTab(_build_ad9102_tab(owner), "Генератор AD9102")
|
||||
tabs.addTab(_build_ad9833_tab(owner), "Генератор AD9833")
|
||||
tabs.addTab(_build_aux_tab(owner), "Выходы и DS1809")
|
||||
tabs.addTab(_build_wave_tab(owner), "Своя форма")
|
||||
layout.addWidget(tabs)
|
||||
return group
|
||||
|
||||
|
||||
def _build_ad9102_tab(owner) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(10)
|
||||
|
||||
note = QLabel(
|
||||
"AD9102 в этой прошивке умеет два режима. "
|
||||
"Первый: встроенная пила или треугольник самого AD9102. "
|
||||
"Второй: STM32 сама строит пилу или треугольник из точек и записывает их в память AD9102. "
|
||||
"Для упрощённого режима ниже задаются понятные величины, а регистровые поля спрятаны в расширенных настройках."
|
||||
)
|
||||
note.setWordWrap(True)
|
||||
note.setObjectName("captionLabel")
|
||||
|
||||
basic_group = QGroupBox("Основные настройки")
|
||||
basic_layout = QFormLayout(basic_group)
|
||||
basic_layout.setHorizontalSpacing(12)
|
||||
basic_layout.setVerticalSpacing(8)
|
||||
|
||||
owner._ad9102_enable = QCheckBox("Подать сигнал на выход")
|
||||
owner._ad9102_enable.setChecked(True)
|
||||
owner._ad9102_mode = QComboBox()
|
||||
owner._ad9102_mode.addItem("Встроенная пила/треугольник AD9102", "saw")
|
||||
owner._ad9102_mode.addItem("Пила/треугольник через память AD9102", "sram")
|
||||
owner._ad9102_shape = QComboBox()
|
||||
owner._ad9102_shape.addItem("Пила", "saw")
|
||||
owner._ad9102_shape.addItem("Треугольник", "triangle")
|
||||
owner._ad9102_shape.setCurrentIndex(1)
|
||||
owner._ad9102_frequency_hz = _int_spinbox(
|
||||
1,
|
||||
DEFAULT_AD9102_SRAM_FREQUENCY_HZ,
|
||||
DEFAULT_AD9102_SAW_FREQUENCY_HZ,
|
||||
suffix=" Гц",
|
||||
)
|
||||
owner._ad9102_frequency_hz.setSingleStep(100)
|
||||
owner._ad9102_frequency_hz.setGroupSeparatorShown(True)
|
||||
owner._ad9102_basic_hint = QLabel()
|
||||
owner._ad9102_basic_hint.setWordWrap(True)
|
||||
owner._ad9102_basic_hint.setObjectName("captionLabel")
|
||||
owner._ad9102_preview = QLabel("Реальная частота: —")
|
||||
owner._ad9102_preview.setObjectName("valueLabel")
|
||||
owner._ad9102_advanced_toggle = QCheckBox("Показать расширенные параметры AD9102")
|
||||
|
||||
owner._ad9102_saw_step = _int_spinbox(
|
||||
AD9102_SAW_STEP_MIN,
|
||||
AD9102_SAW_STEP_MAX,
|
||||
DEFAULT_AD9102_SAW_STEP,
|
||||
)
|
||||
owner._ad9102_pat_base = _int_spinbox(
|
||||
AD9102_PAT_BASE_MIN,
|
||||
AD9102_PAT_BASE_MAX,
|
||||
DEFAULT_AD9102_PAT_BASE,
|
||||
)
|
||||
owner._ad9102_pat_period = _int_spinbox(
|
||||
AD9102_PAT_PERIOD_MIN,
|
||||
AD9102_PAT_PERIOD_MAX,
|
||||
DEFAULT_AD9102_PAT_PERIOD,
|
||||
)
|
||||
owner._ad9102_sample_count = _int_spinbox(
|
||||
AD9102_SRAM_SAMPLE_MIN,
|
||||
AD9102_SRAM_SAMPLE_MAX,
|
||||
DEFAULT_AD9102_SAMPLE_COUNT,
|
||||
)
|
||||
owner._ad9102_hold_cycles = _int_spinbox(
|
||||
AD9102_SRAM_HOLD_MIN,
|
||||
AD9102_SRAM_HOLD_MAX,
|
||||
DEFAULT_AD9102_HOLD_CYCLES,
|
||||
)
|
||||
owner._ad9102_amplitude = _int_spinbox(
|
||||
AD9102_SRAM_AMPLITUDE_MIN,
|
||||
AD9102_SRAM_AMPLITUDE_MAX,
|
||||
DEFAULT_AD9102_AMPLITUDE,
|
||||
)
|
||||
owner._ad9102_use_amplitude = QCheckBox(
|
||||
"Использовать формат \"размах + число точек\" вместо \"пауза + число точек\""
|
||||
)
|
||||
owner._ad9102_mode.currentIndexChanged.connect(owner._on_ad9102_mode_changed)
|
||||
owner._ad9102_shape.currentIndexChanged.connect(owner._update_ad9102_form)
|
||||
owner._ad9102_frequency_hz.valueChanged.connect(owner._update_ad9102_form)
|
||||
owner._ad9102_advanced_toggle.toggled.connect(owner._update_ad9102_form)
|
||||
owner._ad9102_use_amplitude.toggled.connect(owner._update_ad9102_form)
|
||||
owner._ad9102_sample_count.valueChanged.connect(owner._update_ad9102_form)
|
||||
owner._ad9102_hold_cycles.valueChanged.connect(owner._update_ad9102_form)
|
||||
owner._ad9102_saw_step.valueChanged.connect(owner._update_ad9102_form)
|
||||
owner._ad9102_amplitude.valueChanged.connect(owner._update_ad9102_form)
|
||||
owner._ad9102_use_amplitude.setChecked(True)
|
||||
|
||||
basic_layout.addRow(owner._ad9102_enable)
|
||||
basic_layout.addRow("Режим генерации", owner._ad9102_mode)
|
||||
basic_layout.addRow("Форма сигнала", owner._ad9102_shape)
|
||||
basic_layout.addRow("Частота сигнала", owner._ad9102_frequency_hz)
|
||||
basic_layout.addRow("Размах сигнала в режиме памяти (0..8191)", owner._ad9102_amplitude)
|
||||
basic_layout.addRow(owner._ad9102_preview)
|
||||
basic_layout.addRow(owner._ad9102_basic_hint)
|
||||
|
||||
owner._ad9102_advanced_group = QGroupBox("Расширенные параметры")
|
||||
advanced_layout = QFormLayout(owner._ad9102_advanced_group)
|
||||
advanced_layout.setHorizontalSpacing(12)
|
||||
advanced_layout.setVerticalSpacing(8)
|
||||
advanced_layout.addRow("Скорость нарастания пилы (1..63)", owner._ad9102_saw_step)
|
||||
advanced_layout.addRow("Масштаб периода (0..15)", owner._ad9102_pat_base)
|
||||
advanced_layout.addRow("Длина периода (0..65535)", owner._ad9102_pat_period)
|
||||
advanced_layout.addRow("Число точек формы (2..4096)", owner._ad9102_sample_count)
|
||||
advanced_layout.addRow("Пауза на каждой точке (0..15)", owner._ad9102_hold_cycles)
|
||||
advanced_layout.addRow(owner._ad9102_use_amplitude)
|
||||
|
||||
layout.addWidget(note)
|
||||
layout.addWidget(basic_group)
|
||||
layout.addWidget(owner._ad9102_advanced_toggle)
|
||||
layout.addWidget(owner._ad9102_advanced_group)
|
||||
|
||||
owner._apply_ad9102_button = _expanding_button("Применить настройки генератора", primary=True)
|
||||
owner._apply_ad9102_button.clicked.connect(owner._on_apply_ad9102)
|
||||
layout.addWidget(owner._apply_ad9102_button)
|
||||
layout.addStretch(1)
|
||||
|
||||
owner._ad9102_saw_step.setToolTip(
|
||||
"Код шага нарастания пилообразного сигнала. Диапазон 1..63. "
|
||||
"Чем больше значение, тем быстрее растёт сигнал внутри одного периода."
|
||||
)
|
||||
owner._ad9102_pat_base.setToolTip(
|
||||
"Грубый масштаб периода повторения. Диапазон 0..15. "
|
||||
"Используется вместе с длиной периода и задаёт базу времени генератора."
|
||||
)
|
||||
owner._ad9102_pat_period.setToolTip(
|
||||
"Точная длина периода повторения. Диапазон 0..65535. "
|
||||
"Совместно с масштабом периода определяет, как часто форма начинается заново."
|
||||
)
|
||||
owner._ad9102_sample_count.setToolTip(
|
||||
"Количество отсчётов формы в памяти SRAM. Диапазон 2..4096. "
|
||||
"Один период в режиме памяти состоит из этого числа точек."
|
||||
)
|
||||
owner._ad9102_hold_cycles.setToolTip(
|
||||
"Количество внутренних циклов удержания одной точки формы. Диапазон 0..15. "
|
||||
"Чем больше значение, тем дольше каждая точка удерживается перед переходом к следующей."
|
||||
)
|
||||
owner._ad9102_amplitude.setToolTip(
|
||||
"Амплитудный коэффициент для режима, где STM32 сама строит пилу или треугольник "
|
||||
"и записывает их в память AD9102. Диапазон 0..8191. Чем больше значение, тем больше размах сигнала."
|
||||
)
|
||||
owner._ad9102_frequency_hz.setToolTip(
|
||||
"Желаемая частота сигнала в герцах. "
|
||||
"Интерфейс автоматически подберёт ближайшие поддерживаемые параметры AD9102."
|
||||
)
|
||||
owner._ad9102_preview.setToolTip(
|
||||
"Показывает, какая реальная частота получится после округления к поддерживаемым параметрам чипа."
|
||||
)
|
||||
owner._ad9102_use_amplitude.setToolTip(
|
||||
"Ограничение текущей короткой STM-команды: в режиме памяти можно передать "
|
||||
"либо \"размах + число точек\", либо \"пауза + число точек\"."
|
||||
)
|
||||
return tab
|
||||
|
||||
|
||||
def _build_ad9833_tab(owner) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QFormLayout(tab)
|
||||
layout.setHorizontalSpacing(12)
|
||||
layout.setVerticalSpacing(8)
|
||||
|
||||
owner._ad9833_enable = QCheckBox("Подать сигнал на выход")
|
||||
owner._ad9833_enable.setChecked(True)
|
||||
owner._ad9833_shape = QComboBox()
|
||||
owner._ad9833_shape.addItem("Синус", "sine")
|
||||
owner._ad9833_shape.addItem("Треугольник", "triangle")
|
||||
owner._ad9833_shape.setCurrentIndex(1)
|
||||
owner._ad9833_frequency_hz = _int_spinbox(
|
||||
AD9833_OUTPUT_FREQ_MIN_HZ,
|
||||
AD9833_OUTPUT_FREQ_MAX_HZ,
|
||||
DEFAULT_AD9833_FREQUENCY_HZ,
|
||||
suffix=" Гц",
|
||||
)
|
||||
owner._ad9833_frequency_hz.setSingleStep(1000)
|
||||
owner._ad9833_frequency_hz.setGroupSeparatorShown(True)
|
||||
owner._ad9833_frequency_hz.valueChanged.connect(owner._update_ad9833_preview)
|
||||
owner._ad9833_word_preview = QLabel("Внутренний код: —")
|
||||
owner._ad9833_word_preview.setObjectName("valueLabel")
|
||||
|
||||
note = QLabel(
|
||||
f"AD9833 тактируется от {AD9833_MCLK_HZ:,} Гц. "
|
||||
f"Поэтому здесь задаётся сразу частота сигнала в герцах. "
|
||||
f"Рабочий диапазон интерфейса: {AD9833_OUTPUT_FREQ_MIN_HZ:,}..{AD9833_OUTPUT_FREQ_MAX_HZ:,} Гц "
|
||||
f"(до половины тактовой частоты). Внутренний код рассчитывается автоматически."
|
||||
)
|
||||
note.setWordWrap(True)
|
||||
note.setObjectName("captionLabel")
|
||||
|
||||
layout.addRow(owner._ad9833_enable)
|
||||
layout.addRow("Форма сигнала", owner._ad9833_shape)
|
||||
layout.addRow(
|
||||
f"Частота сигнала ({AD9833_OUTPUT_FREQ_MIN_HZ:,}..{AD9833_OUTPUT_FREQ_MAX_HZ:,} Гц)",
|
||||
owner._ad9833_frequency_hz,
|
||||
)
|
||||
layout.addRow("Внутренний код AD9833", owner._ad9833_word_preview)
|
||||
layout.addRow(note)
|
||||
|
||||
owner._apply_ad9833_button = _expanding_button("Применить настройки генератора", primary=True)
|
||||
owner._apply_ad9833_button.clicked.connect(owner._on_apply_ad9833)
|
||||
layout.addRow(owner._apply_ad9833_button)
|
||||
owner._ad9833_frequency_hz.setToolTip(
|
||||
"Частота выходного сигнала в герцах. "
|
||||
"Интерфейс ограничен диапазоном 0..10 МГц для тактовой частоты 20 МГц."
|
||||
)
|
||||
owner._ad9833_word_preview.setToolTip(
|
||||
"28-битный frequency word, который будет автоматически передан в AD9833."
|
||||
)
|
||||
return tab
|
||||
|
||||
|
||||
def _build_aux_tab(owner) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(10)
|
||||
|
||||
dac_group = QGroupBox("Аналоговый выход STM32 (PA4)")
|
||||
dac_layout = QFormLayout(dac_group)
|
||||
dac_layout.setHorizontalSpacing(12)
|
||||
dac_layout.setVerticalSpacing(8)
|
||||
|
||||
dac_note = QLabel(
|
||||
"Это встроенный 12-битный ЦАП микроконтроллера STM32. "
|
||||
"Диапазон кода: 0..4095. Это соответствует примерно 0..Vref+, "
|
||||
"то есть обычно около 0..3.3 В."
|
||||
)
|
||||
dac_note.setWordWrap(True)
|
||||
dac_note.setObjectName("captionLabel")
|
||||
|
||||
owner._stm32_dac_enable = QCheckBox("Подать напряжение на выход")
|
||||
owner._stm32_dac_enable.setChecked(True)
|
||||
owner._stm32_dac_code = _int_spinbox(
|
||||
STM32_DAC_CODE_MIN,
|
||||
STM32_DAC_CODE_MAX,
|
||||
DEFAULT_STM32_DAC_CODE,
|
||||
)
|
||||
owner._apply_stm32_dac_button = _expanding_button("Применить уровень выхода", primary=True)
|
||||
owner._apply_stm32_dac_button.clicked.connect(owner._on_apply_stm32_dac)
|
||||
dac_layout.addRow(dac_note)
|
||||
dac_layout.addRow(owner._stm32_dac_enable)
|
||||
dac_layout.addRow("Уровень выхода (0..4095)", owner._stm32_dac_code)
|
||||
dac_layout.addRow(owner._apply_stm32_dac_button)
|
||||
owner._stm32_dac_code.setToolTip(
|
||||
"Код встроенного 12-битного ЦАП STM32. "
|
||||
"0 = примерно 0 В, 4095 = примерно верхний предел питания ЦАП "
|
||||
"(обычно около 3.3 В)."
|
||||
)
|
||||
|
||||
ds_group = QGroupBox("Цифровой подстроечный резистор DS1809")
|
||||
ds_layout = QFormLayout(ds_group)
|
||||
ds_layout.setHorizontalSpacing(12)
|
||||
ds_layout.setVerticalSpacing(8)
|
||||
owner._ds1809_direction = QComboBox()
|
||||
owner._ds1809_direction.addItem("Увеличить", "inc")
|
||||
owner._ds1809_direction.addItem("Уменьшить", "dec")
|
||||
owner._ds1809_count = _int_spinbox(DS1809_COUNT_MIN, DS1809_COUNT_MAX, DEFAULT_DS1809_COUNT)
|
||||
owner._ds1809_pulse_ms = _int_spinbox(
|
||||
DS1809_PULSE_MS_MIN,
|
||||
DS1809_PULSE_MS_MAX,
|
||||
DEFAULT_DS1809_PULSE_MS,
|
||||
suffix=" мс",
|
||||
)
|
||||
owner._pulse_ds1809_button = _expanding_button("Сделать шаги резистора", primary=True)
|
||||
owner._pulse_ds1809_button.clicked.connect(owner._on_pulse_ds1809)
|
||||
owner._ds1809_profile_apply = QCheckBox("Сохранять абсолютную позицию в профиль")
|
||||
owner._ds1809_profile_apply.setChecked(True)
|
||||
owner._ds1809_profile_position = _int_spinbox(
|
||||
DS1809_PROFILE_POSITION_MIN,
|
||||
DS1809_PROFILE_POSITION_MAX,
|
||||
DEFAULT_DS1809_PROFILE_POSITION,
|
||||
)
|
||||
ds_layout.addRow("Куда менять", owner._ds1809_direction)
|
||||
ds_layout.addRow("Число шагов", owner._ds1809_count)
|
||||
ds_layout.addRow("Длительность шага", owner._ds1809_pulse_ms)
|
||||
ds_layout.addRow(owner._ds1809_profile_apply)
|
||||
ds_layout.addRow("Позиция для профиля (от минимума)", owner._ds1809_profile_position)
|
||||
ds_layout.addRow(owner._pulse_ds1809_button)
|
||||
owner._ds1809_count.setToolTip("На сколько шагов изменить цифровой резистор.")
|
||||
owner._ds1809_pulse_ms.setToolTip("Сколько миллисекунд длится один управляющий импульс.")
|
||||
owner._ds1809_profile_apply.setToolTip(
|
||||
"Если флажок включён, при сохранении профиля прошивка сначала загонит DS1809 в минимум, "
|
||||
"а затем поднимет его до указанной позиции."
|
||||
)
|
||||
owner._ds1809_profile_position.setToolTip(
|
||||
"Абсолютная позиция DS1809 относительно минимального положения. "
|
||||
"Используется только при сохранении профиля на SD."
|
||||
)
|
||||
|
||||
layout.addWidget(dac_group)
|
||||
layout.addWidget(ds_group)
|
||||
layout.addStretch(1)
|
||||
return tab
|
||||
|
||||
|
||||
def _build_wave_tab(owner) -> QWidget:
|
||||
tab = QWidget()
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setSpacing(10)
|
||||
|
||||
note = QLabel(
|
||||
"Здесь можно вручную загрузить свою форму сигнала для AD9102. "
|
||||
"Каждое число - это одна точка формы. Допустимый диапазон: от -8192 до 8191."
|
||||
)
|
||||
note.setWordWrap(True)
|
||||
note.setObjectName("captionLabel")
|
||||
layout.addWidget(note)
|
||||
|
||||
owner._wave_info_label = QLabel("Отсчётов: 0")
|
||||
owner._wave_info_label.setObjectName("valueLabel")
|
||||
layout.addWidget(owner._wave_info_label)
|
||||
|
||||
owner._wave_samples_box = QPlainTextEdit()
|
||||
owner._wave_samples_box.setPlaceholderText("0 1024 2048 1024 0 -1024 -2048 -1024")
|
||||
owner._wave_samples_box.setMinimumHeight(180)
|
||||
owner._wave_samples_box.textChanged.connect(owner._on_wave_text_changed)
|
||||
layout.addWidget(owner._wave_samples_box)
|
||||
|
||||
buttons = QWidget()
|
||||
buttons_layout = QHBoxLayout(buttons)
|
||||
buttons_layout.setContentsMargins(0, 0, 0, 0)
|
||||
buttons_layout.setSpacing(8)
|
||||
|
||||
owner._load_wave_file_button = _expanding_button("Открыть файл")
|
||||
owner._upload_wave_button = _expanding_button("Загрузить форму", primary=True)
|
||||
owner._cancel_wave_button = _expanding_button("Отменить загрузку")
|
||||
owner._load_wave_file_button.clicked.connect(owner._on_load_wave_file)
|
||||
owner._upload_wave_button.clicked.connect(owner._on_upload_waveform)
|
||||
owner._cancel_wave_button.clicked.connect(owner._on_cancel_waveform)
|
||||
|
||||
buttons_layout.addWidget(owner._load_wave_file_button)
|
||||
buttons_layout.addWidget(owner._upload_wave_button)
|
||||
buttons_layout.addWidget(owner._cancel_wave_button)
|
||||
layout.addWidget(buttons)
|
||||
return tab
|
||||
|
||||
|
||||
def build_status_group(owner) -> QGroupBox:
|
||||
"""Create status and telemetry labels."""
|
||||
group = QGroupBox("Телеметрия и статус")
|
||||
layout = QVBoxLayout(group)
|
||||
layout.setSpacing(10)
|
||||
|
||||
owner._status_header = QLabel("Отключено")
|
||||
owner._status_header.setObjectName("statusError")
|
||||
layout.addWidget(owner._status_header)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setHorizontalSpacing(12)
|
||||
grid.setVerticalSpacing(6)
|
||||
|
||||
rows = [
|
||||
("Порт", "_port_value"),
|
||||
("Статус", "_state_value"),
|
||||
("Доп. код", "_detail_value"),
|
||||
("ID сообщения", "_message_id_value"),
|
||||
("Температура 1", "_telemetry_temp1"),
|
||||
("Температура 2", "_telemetry_temp2"),
|
||||
("Фотодиод 1", "_telemetry_current1"),
|
||||
("Фотодиод 2", "_telemetry_current2"),
|
||||
("Внешняя температура 1", "_telemetry_temp_ext1"),
|
||||
("Внешняя температура 2", "_telemetry_temp_ext2"),
|
||||
("Питание 3.3 В", "_telemetry_3v3"),
|
||||
("Питание 5V1", "_telemetry_5v1"),
|
||||
("Питание 5V2", "_telemetry_5v2"),
|
||||
("Питание 7V0", "_telemetry_7v0"),
|
||||
]
|
||||
|
||||
for row_index, (label_text, attr_name) in enumerate(rows):
|
||||
label = QLabel(label_text)
|
||||
label.setObjectName("captionLabel")
|
||||
value = QLabel("—")
|
||||
value.setObjectName("valueLabel")
|
||||
setattr(owner, attr_name, value)
|
||||
grid.addWidget(label, row_index, 0)
|
||||
grid.addWidget(value, row_index, 1)
|
||||
|
||||
layout.addLayout(grid)
|
||||
|
||||
buttons = QWidget()
|
||||
buttons_layout = QHBoxLayout(buttons)
|
||||
buttons_layout.setContentsMargins(0, 0, 0, 0)
|
||||
buttons_layout.setSpacing(8)
|
||||
|
||||
owner._reconnect_button = _expanding_button("Переподключить")
|
||||
owner._reset_button = _expanding_button("Сброс")
|
||||
owner._save_profile_button = _expanding_button("Сохранить профиль", primary=True)
|
||||
owner._reconnect_button.clicked.connect(owner._on_reconnect)
|
||||
owner._reset_button.clicked.connect(owner._on_reset_device)
|
||||
owner._save_profile_button.clicked.connect(owner._on_save_profile)
|
||||
buttons_layout.addWidget(owner._reconnect_button)
|
||||
buttons_layout.addWidget(owner._reset_button)
|
||||
layout.addWidget(buttons)
|
||||
layout.addWidget(owner._save_profile_button)
|
||||
return group
|
||||
|
||||
|
||||
def build_log_group(owner) -> QGroupBox:
|
||||
"""Create compact runtime log output."""
|
||||
group = QGroupBox("Runtime Log")
|
||||
layout = QVBoxLayout(group)
|
||||
|
||||
owner._log_box = QTextEdit()
|
||||
owner._log_box.setObjectName("logBox")
|
||||
owner._log_box.setReadOnly(True)
|
||||
owner._log_box.setMinimumHeight(180)
|
||||
layout.addWidget(owner._log_box)
|
||||
return group
|
||||
156
laser_control/gui/theme.py
Normal file
156
laser_control/gui/theme.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""Shared Qt theme for the laser-control desktop UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtGui import QColor, QPalette
|
||||
from PyQt6.QtWidgets import QApplication, QStyleFactory
|
||||
|
||||
|
||||
_STYLESHEET = """
|
||||
QMainWindow {
|
||||
background-color: #edf2f7;
|
||||
}
|
||||
|
||||
QWidget {
|
||||
color: #17212b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
QGroupBox {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 14px;
|
||||
margin-top: 14px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 12px;
|
||||
padding: 0 6px;
|
||||
color: #506274;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton {
|
||||
background-color: #f7fafc;
|
||||
border: 1px solid #c8d3df;
|
||||
border-radius: 8px;
|
||||
padding: 7px 12px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #edf3f9;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #e2ebf4;
|
||||
}
|
||||
|
||||
QPushButton#primaryButton {
|
||||
background-color: #1f6feb;
|
||||
border-color: #1f6feb;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QPushButton#primaryButton:hover {
|
||||
background-color: #2b7bf7;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
color: #95a3b3;
|
||||
background-color: #f3f6f9;
|
||||
border-color: #dde5ee;
|
||||
}
|
||||
|
||||
QLabel#captionLabel {
|
||||
color: #6b7b8d;
|
||||
}
|
||||
|
||||
QLabel#valueLabel {
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
QLabel#statusOk {
|
||||
color: #156f3d;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QLabel#statusError {
|
||||
color: #b42318;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
QDoubleSpinBox,
|
||||
QSpinBox,
|
||||
QComboBox,
|
||||
QLineEdit,
|
||||
QTextEdit,
|
||||
QPlainTextEdit {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #c8d3df;
|
||||
border-radius: 8px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
|
||||
QDoubleSpinBox:focus,
|
||||
QSpinBox:focus,
|
||||
QComboBox:focus,
|
||||
QLineEdit:focus,
|
||||
QTextEdit:focus,
|
||||
QPlainTextEdit:focus {
|
||||
border: 1px solid #1f6feb;
|
||||
}
|
||||
|
||||
QTextEdit#logBox,
|
||||
QPlainTextEdit {
|
||||
font-family: "DejaVu Sans Mono";
|
||||
font-size: 12px;
|
||||
background-color: #fbfdff;
|
||||
}
|
||||
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #d8e0ea;
|
||||
border-radius: 10px;
|
||||
background-color: #f8fbfe;
|
||||
top: -1px;
|
||||
}
|
||||
|
||||
QTabBar::tab {
|
||||
background-color: #eef4fa;
|
||||
border: 1px solid #d8e0ea;
|
||||
border-bottom: none;
|
||||
padding: 7px 12px;
|
||||
margin-right: 4px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
|
||||
QTabBar::tab:selected {
|
||||
background-color: #f8fbfe;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def apply_theme(app: QApplication) -> None:
|
||||
"""Apply a light desktop theme aligned with the radar_system UI style."""
|
||||
app.setStyle(QStyleFactory.create("Fusion"))
|
||||
|
||||
palette = QPalette()
|
||||
palette.setColor(QPalette.ColorRole.Window, QColor("#edf2f7"))
|
||||
palette.setColor(QPalette.ColorRole.WindowText, QColor("#17212b"))
|
||||
palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff"))
|
||||
palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#f6f9fc"))
|
||||
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffff"))
|
||||
palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#17212b"))
|
||||
palette.setColor(QPalette.ColorRole.Text, QColor("#17212b"))
|
||||
palette.setColor(QPalette.ColorRole.Button, QColor("#f7fafc"))
|
||||
palette.setColor(QPalette.ColorRole.ButtonText, QColor("#17212b"))
|
||||
palette.setColor(QPalette.ColorRole.Highlight, QColor("#1f6feb"))
|
||||
palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
|
||||
app.setPalette(palette)
|
||||
app.setStyleSheet(_STYLESHEET)
|
||||
752
laser_control/gui/window.py
Normal file
752
laser_control/gui/window.py
Normal file
@ -0,0 +1,752 @@
|
||||
"""Main PyQt window for the laser-controller desktop application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
from PyQt6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
import pyqtgraph as pg
|
||||
|
||||
from laser_control.constants import (
|
||||
AD9833_MCLK_HZ,
|
||||
DEFAULT_AD9102_AMPLITUDE,
|
||||
DEFAULT_AD9102_HOLD_CYCLES,
|
||||
DEFAULT_AD9102_PAT_BASE,
|
||||
DEFAULT_AD9102_PAT_PERIOD,
|
||||
DEFAULT_AD9102_SAMPLE_COUNT,
|
||||
DEFAULT_AD9102_SAW_FREQUENCY_HZ,
|
||||
DEFAULT_AD9102_SRAM_FREQUENCY_HZ,
|
||||
DEFAULT_PI_I,
|
||||
DEFAULT_PI_P,
|
||||
GUI_POLL_INTERVAL_MS,
|
||||
PLOT_POINTS,
|
||||
)
|
||||
from laser_control.conversions import current_ma_to_n, temp_c_to_n
|
||||
from laser_control.controller import (
|
||||
ad9102_saw_frequency_from_step_hz,
|
||||
ad9102_saw_frequency_limits_hz,
|
||||
ad9102_saw_step_from_frequency_hz,
|
||||
ad9102_sram_frequency_from_playback_hz,
|
||||
ad9102_sram_frequency_limits_hz,
|
||||
ad9102_sram_sample_count_from_frequency_hz,
|
||||
)
|
||||
from laser_control.models import DeviceStatus, Measurements, ProfileSaveRequest
|
||||
|
||||
from .dialogs import ProfileSaveDialog
|
||||
from .sections import (
|
||||
build_device_group,
|
||||
build_log_group,
|
||||
build_manual_group,
|
||||
build_status_group,
|
||||
)
|
||||
from .worker import ControllerWorker
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Compact GUI composed around live plots and explicit control cards."""
|
||||
|
||||
request_connect = pyqtSignal()
|
||||
request_apply_manual = pyqtSignal(float, float, float, float)
|
||||
request_reset = pyqtSignal()
|
||||
request_apply_ad9102 = pyqtSignal(dict)
|
||||
request_apply_ad9833 = pyqtSignal(bool, bool, int)
|
||||
request_pulse_ds1809 = pyqtSignal(bool, int, int)
|
||||
request_set_stm32_dac = pyqtSignal(bool, int)
|
||||
request_upload_wave = pyqtSignal(object)
|
||||
request_cancel_wave = pyqtSignal()
|
||||
request_save_profile = pyqtSignal(object)
|
||||
request_poll = pyqtSignal()
|
||||
request_shutdown = pyqtSignal()
|
||||
|
||||
def __init__(self, *, auto_connect: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle("Управление лазерной схемой")
|
||||
|
||||
self._connected = False
|
||||
self._port_name = ""
|
||||
self._poll_in_flight = False
|
||||
self._command_in_flight = False
|
||||
|
||||
self._temp1_history = deque(maxlen=PLOT_POINTS)
|
||||
self._temp2_history = deque(maxlen=PLOT_POINTS)
|
||||
self._current1_history = deque(maxlen=PLOT_POINTS)
|
||||
self._current2_history = deque(maxlen=PLOT_POINTS)
|
||||
|
||||
self._build_ui()
|
||||
self._update_ad9102_form()
|
||||
self._update_ad9833_preview()
|
||||
self._on_wave_text_changed()
|
||||
self._update_control_state()
|
||||
self._build_worker()
|
||||
|
||||
self._poll_timer = QTimer(self)
|
||||
self._poll_timer.setInterval(GUI_POLL_INTERVAL_MS)
|
||||
self._poll_timer.timeout.connect(self._request_poll_if_idle)
|
||||
self._poll_timer.start()
|
||||
|
||||
self._append_log("INFO", "GUI started")
|
||||
if auto_connect:
|
||||
self._emit_connect_request()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
root = QWidget(self)
|
||||
self.setCentralWidget(root)
|
||||
|
||||
layout = QHBoxLayout(root)
|
||||
layout.setContentsMargins(14, 14, 14, 14)
|
||||
layout.setSpacing(14)
|
||||
|
||||
layout.addWidget(self._build_plot_panel(), stretch=11)
|
||||
layout.addWidget(self._build_side_panel(), stretch=5)
|
||||
|
||||
def _build_plot_panel(self) -> QWidget:
|
||||
panel = QWidget(self)
|
||||
grid = QGridLayout(panel)
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
grid.setHorizontalSpacing(12)
|
||||
grid.setVerticalSpacing(12)
|
||||
|
||||
self._plot_temp1, self._curve_temp1 = self._build_plot_card(
|
||||
"Температура лазера 1",
|
||||
"#ffb703",
|
||||
0,
|
||||
50,
|
||||
)
|
||||
self._plot_temp2, self._curve_temp2 = self._build_plot_card(
|
||||
"Температура лазера 2",
|
||||
"#fb8500",
|
||||
0,
|
||||
50,
|
||||
)
|
||||
self._plot_current1, self._curve_current1 = self._build_plot_card(
|
||||
"Фотодиод 1",
|
||||
"#219ebc",
|
||||
0,
|
||||
1.2,
|
||||
)
|
||||
self._plot_current2, self._curve_current2 = self._build_plot_card(
|
||||
"Фотодиод 2",
|
||||
"#2a9d8f",
|
||||
0,
|
||||
1.2,
|
||||
)
|
||||
|
||||
grid.addWidget(self._plot_temp1, 0, 0)
|
||||
grid.addWidget(self._plot_temp2, 0, 1)
|
||||
grid.addWidget(self._plot_current1, 1, 0)
|
||||
grid.addWidget(self._plot_current2, 1, 1)
|
||||
grid.setColumnStretch(0, 1)
|
||||
grid.setColumnStretch(1, 1)
|
||||
grid.setRowStretch(0, 1)
|
||||
grid.setRowStretch(1, 1)
|
||||
return panel
|
||||
|
||||
def _build_plot_card(
|
||||
self,
|
||||
title: str,
|
||||
color: str,
|
||||
y_min: float,
|
||||
y_max: float,
|
||||
) -> tuple[QWidget, pg.PlotDataItem]:
|
||||
container = QWidget(self)
|
||||
container_layout = QVBoxLayout(container)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.setSpacing(8)
|
||||
|
||||
label = QLabel(title, container)
|
||||
label.setObjectName("valueLabel")
|
||||
container_layout.addWidget(label)
|
||||
|
||||
plot = pg.PlotWidget(background="#0f1720", enableMenu=False)
|
||||
plot.showGrid(x=True, y=True, alpha=0.16)
|
||||
plot.setYRange(y_min, y_max)
|
||||
plot.setXRange(0, max(1, PLOT_POINTS - 1))
|
||||
plot.setMouseEnabled(x=False, y=False)
|
||||
plot.getPlotItem().hideButtons()
|
||||
plot.getPlotItem().setClipToView(True)
|
||||
plot.getPlotItem().setDownsampling(mode="peak")
|
||||
plot.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
curve = plot.plot(pen=pg.mkPen(color=color, width=2))
|
||||
container_layout.addWidget(plot, stretch=1)
|
||||
return container, curve
|
||||
|
||||
def _build_side_panel(self) -> QWidget:
|
||||
panel = QWidget(self)
|
||||
panel.setMinimumWidth(420)
|
||||
layout = QVBoxLayout(panel)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(12)
|
||||
|
||||
self._subtitle = QLabel("Автоподключение при запуске без автоприменения параметров")
|
||||
self._subtitle.setObjectName("captionLabel")
|
||||
layout.addWidget(self._subtitle)
|
||||
layout.addWidget(build_manual_group(self))
|
||||
layout.addWidget(build_device_group(self))
|
||||
layout.addWidget(build_status_group(self))
|
||||
layout.addWidget(build_log_group(self), stretch=1)
|
||||
layout.addStretch(1)
|
||||
|
||||
scroll = QScrollArea(self)
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
scroll.setWidget(panel)
|
||||
return scroll
|
||||
|
||||
def _build_worker(self) -> None:
|
||||
self._worker_thread = QThread(self)
|
||||
self._worker = ControllerWorker()
|
||||
self._worker.moveToThread(self._worker_thread)
|
||||
|
||||
self.request_connect.connect(self._worker.connect_device)
|
||||
self.request_apply_manual.connect(self._worker.apply_manual)
|
||||
self.request_reset.connect(self._worker.reset_device)
|
||||
self.request_apply_ad9102.connect(self._worker.apply_ad9102)
|
||||
self.request_apply_ad9833.connect(self._worker.apply_ad9833)
|
||||
self.request_pulse_ds1809.connect(self._worker.pulse_ds1809)
|
||||
self.request_set_stm32_dac.connect(self._worker.set_stm32_dac)
|
||||
self.request_upload_wave.connect(self._worker.upload_ad9102_waveform)
|
||||
self.request_cancel_wave.connect(self._worker.cancel_ad9102_waveform_upload)
|
||||
self.request_save_profile.connect(self._worker.save_profile)
|
||||
self.request_poll.connect(self._worker.poll)
|
||||
self.request_shutdown.connect(self._worker.shutdown)
|
||||
|
||||
self._worker.connected_changed.connect(self._on_connected_changed)
|
||||
self._worker.measurements_ready.connect(self._on_measurements_ready)
|
||||
self._worker.status_ready.connect(self._on_status_ready)
|
||||
self._worker.log_message.connect(self._append_log)
|
||||
self._worker.command_finished.connect(self._on_command_finished)
|
||||
self._worker.poll_finished.connect(self._on_poll_finished)
|
||||
|
||||
self._worker_thread.start()
|
||||
|
||||
def _emit_connect_request(self) -> None:
|
||||
self._dispatch_command(self.request_connect.emit)
|
||||
|
||||
def _dispatch_command(self, emit_request) -> None:
|
||||
if self._command_in_flight:
|
||||
return
|
||||
|
||||
self._command_in_flight = True
|
||||
self._poll_timer.stop()
|
||||
self._update_control_state()
|
||||
emit_request()
|
||||
|
||||
def _request_poll_if_idle(self) -> None:
|
||||
if not self._connected or self._command_in_flight or self._poll_in_flight:
|
||||
return
|
||||
|
||||
self._poll_in_flight = True
|
||||
self.request_poll.emit()
|
||||
|
||||
def _on_command_finished(self) -> None:
|
||||
self._command_in_flight = False
|
||||
self._update_control_state()
|
||||
if self._connected and not self._poll_timer.isActive():
|
||||
self._poll_timer.start()
|
||||
|
||||
def _on_poll_finished(self) -> None:
|
||||
self._poll_in_flight = False
|
||||
|
||||
def _on_apply_manual(self) -> None:
|
||||
self._dispatch_command(
|
||||
lambda: self.request_apply_manual.emit(
|
||||
self._manual_temp1.value(),
|
||||
self._manual_temp2.value(),
|
||||
self._manual_current1.value(),
|
||||
self._manual_current2.value(),
|
||||
)
|
||||
)
|
||||
|
||||
def _on_reset_device(self) -> None:
|
||||
self._dispatch_command(self.request_reset.emit)
|
||||
|
||||
def _on_apply_ad9102(self) -> None:
|
||||
use_sram = self._ad9102_mode.currentData() == "sram"
|
||||
advanced = self._ad9102_advanced_toggle.isChecked()
|
||||
config = {
|
||||
"use_basic": not advanced,
|
||||
"enabled": self._ad9102_enable.isChecked(),
|
||||
"use_sram": use_sram,
|
||||
"triangle": self._ad9102_shape.currentData() == "triangle",
|
||||
"frequency_hz": self._ad9102_frequency_hz.value(),
|
||||
"saw_step": self._ad9102_saw_step.value(),
|
||||
"pat_period_base": self._ad9102_pat_base.value(),
|
||||
"pat_period": self._ad9102_pat_period.value(),
|
||||
"sample_count": self._ad9102_sample_count.value(),
|
||||
"hold_cycles": self._ad9102_hold_cycles.value(),
|
||||
"amplitude": self._ad9102_amplitude.value(),
|
||||
"use_amplitude_format": use_sram and (not advanced or self._ad9102_use_amplitude.isChecked()),
|
||||
}
|
||||
self._dispatch_command(lambda: self.request_apply_ad9102.emit(config))
|
||||
|
||||
def _on_apply_ad9833(self) -> None:
|
||||
self._dispatch_command(
|
||||
lambda: self.request_apply_ad9833.emit(
|
||||
self._ad9833_enable.isChecked(),
|
||||
self._ad9833_shape.currentData() == "triangle",
|
||||
self._ad9833_frequency_hz.value(),
|
||||
)
|
||||
)
|
||||
|
||||
def _on_pulse_ds1809(self) -> None:
|
||||
self._dispatch_command(
|
||||
lambda: self.request_pulse_ds1809.emit(
|
||||
self._ds1809_direction.currentData() == "inc",
|
||||
self._ds1809_count.value(),
|
||||
self._ds1809_pulse_ms.value(),
|
||||
)
|
||||
)
|
||||
|
||||
def _on_apply_stm32_dac(self) -> None:
|
||||
self._dispatch_command(
|
||||
lambda: self.request_set_stm32_dac.emit(
|
||||
self._stm32_dac_enable.isChecked(),
|
||||
self._stm32_dac_code.value(),
|
||||
)
|
||||
)
|
||||
|
||||
def _on_save_profile(self) -> None:
|
||||
dialog = ProfileSaveDialog(
|
||||
custom_waveform_available=self._custom_waveform_is_available(),
|
||||
parent=self,
|
||||
)
|
||||
if dialog.exec() != ProfileSaveDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
try:
|
||||
request = self._build_profile_save_request(
|
||||
profile_name=dialog.profile_name(),
|
||||
include_custom_waveform=dialog.include_custom_waveform(),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._append_log("ERROR", str(exc))
|
||||
return
|
||||
|
||||
self._dispatch_command(lambda: self.request_save_profile.emit(request))
|
||||
|
||||
def _on_upload_waveform(self) -> None:
|
||||
try:
|
||||
samples = self._parse_wave_samples(self._wave_samples_box.toPlainText())
|
||||
if len(samples) < 2:
|
||||
raise ValueError("Для загрузки waveform нужно минимум 2 отсчёта")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._append_log("ERROR", str(exc))
|
||||
return
|
||||
self._dispatch_command(lambda: self.request_upload_wave.emit(samples))
|
||||
|
||||
def _on_cancel_waveform(self) -> None:
|
||||
self._dispatch_command(self.request_cancel_wave.emit)
|
||||
|
||||
def _on_load_wave_file(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Открыть файл waveform",
|
||||
"",
|
||||
"Text files (*.txt *.csv *.dat);;All files (*)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
self._wave_samples_box.setPlainText(handle.read())
|
||||
self._append_log("INFO", f"Waveform file loaded: {path}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._append_log("ERROR", f"Не удалось открыть файл: {exc}")
|
||||
|
||||
def _on_wave_text_changed(self) -> None:
|
||||
text = self._wave_samples_box.toPlainText().strip()
|
||||
if not text:
|
||||
self._wave_info_label.setText("Отсчётов: 0")
|
||||
return
|
||||
try:
|
||||
count = len(self._parse_wave_samples(text))
|
||||
self._wave_info_label.setText(f"Отсчётов: {count}")
|
||||
except Exception:
|
||||
self._wave_info_label.setText("Отсчётов: ошибка формата")
|
||||
|
||||
def _on_reconnect(self) -> None:
|
||||
self._append_log("INFO", "Reconnect requested from UI")
|
||||
self._emit_connect_request()
|
||||
|
||||
def _on_connected_changed(self, connected: bool, port_name: str) -> None:
|
||||
self._connected = connected
|
||||
self._port_name = port_name
|
||||
if not connected:
|
||||
self._poll_in_flight = False
|
||||
self._command_in_flight = False
|
||||
if connected:
|
||||
self._status_header.setText("Подключено")
|
||||
self._status_header.setObjectName("statusOk")
|
||||
self._subtitle.setText(f"Подключено к {port_name}")
|
||||
else:
|
||||
self._status_header.setText("Отключено")
|
||||
self._status_header.setObjectName("statusError")
|
||||
self._subtitle.setText("Автоподключение при запуске без автоприменения параметров")
|
||||
self._status_header.style().unpolish(self._status_header)
|
||||
self._status_header.style().polish(self._status_header)
|
||||
self._update_control_state()
|
||||
|
||||
def _update_control_state(self) -> None:
|
||||
connected = self._connected and not self._command_in_flight
|
||||
|
||||
self._apply_manual_button.setEnabled(connected)
|
||||
self._apply_ad9102_button.setEnabled(connected)
|
||||
self._apply_ad9833_button.setEnabled(connected)
|
||||
self._apply_stm32_dac_button.setEnabled(connected)
|
||||
self._pulse_ds1809_button.setEnabled(connected)
|
||||
self._upload_wave_button.setEnabled(connected)
|
||||
self._cancel_wave_button.setEnabled(connected)
|
||||
self._save_profile_button.setEnabled(connected)
|
||||
self._reset_button.setEnabled(connected)
|
||||
self._reconnect_button.setEnabled(not self._command_in_flight)
|
||||
self._load_wave_file_button.setEnabled(True)
|
||||
|
||||
def _on_ad9102_mode_changed(self) -> None:
|
||||
if not hasattr(self, "_ad9102_frequency_hz"):
|
||||
return
|
||||
|
||||
use_sram = self._ad9102_mode.currentData() == "sram"
|
||||
if use_sram:
|
||||
min_hz, max_hz = ad9102_sram_frequency_limits_hz()
|
||||
frequency_hz = DEFAULT_AD9102_SRAM_FREQUENCY_HZ
|
||||
else:
|
||||
min_hz, max_hz = ad9102_saw_frequency_limits_hz(
|
||||
triangle=self._ad9102_shape.currentData() == "triangle",
|
||||
)
|
||||
frequency_hz = DEFAULT_AD9102_SAW_FREQUENCY_HZ
|
||||
|
||||
frequency_hz = max(min_hz, min(max_hz, frequency_hz))
|
||||
self._ad9102_frequency_hz.blockSignals(True)
|
||||
self._ad9102_frequency_hz.setRange(min_hz, max_hz)
|
||||
self._ad9102_frequency_hz.setValue(frequency_hz)
|
||||
self._ad9102_frequency_hz.blockSignals(False)
|
||||
|
||||
if use_sram:
|
||||
self._ad9102_sample_count.blockSignals(True)
|
||||
self._ad9102_hold_cycles.blockSignals(True)
|
||||
self._ad9102_amplitude.blockSignals(True)
|
||||
self._ad9102_use_amplitude.blockSignals(True)
|
||||
self._ad9102_sample_count.setValue(DEFAULT_AD9102_SAMPLE_COUNT)
|
||||
self._ad9102_hold_cycles.setValue(DEFAULT_AD9102_HOLD_CYCLES)
|
||||
self._ad9102_amplitude.setValue(DEFAULT_AD9102_AMPLITUDE)
|
||||
self._ad9102_use_amplitude.setChecked(True)
|
||||
self._ad9102_sample_count.blockSignals(False)
|
||||
self._ad9102_hold_cycles.blockSignals(False)
|
||||
self._ad9102_amplitude.blockSignals(False)
|
||||
self._ad9102_use_amplitude.blockSignals(False)
|
||||
|
||||
self._update_ad9102_form()
|
||||
|
||||
def _update_ad9102_form(self) -> None:
|
||||
if not hasattr(self, "_ad9102_advanced_group"):
|
||||
return
|
||||
|
||||
use_sram = self._ad9102_mode.currentData() == "sram"
|
||||
advanced = self._ad9102_advanced_toggle.isChecked()
|
||||
triangle = self._ad9102_shape.currentData() == "triangle"
|
||||
use_amplitude = use_sram and (not advanced or self._ad9102_use_amplitude.isChecked())
|
||||
|
||||
self._ad9102_advanced_group.setVisible(advanced)
|
||||
self._ad9102_frequency_hz.setEnabled(not advanced)
|
||||
|
||||
self._ad9102_saw_step.setEnabled(advanced and not use_sram)
|
||||
self._ad9102_pat_base.setEnabled(advanced and not use_sram)
|
||||
self._ad9102_pat_period.setEnabled(advanced and not use_sram)
|
||||
|
||||
self._ad9102_sample_count.setEnabled(advanced and use_sram)
|
||||
self._ad9102_use_amplitude.setEnabled(advanced and use_sram)
|
||||
self._ad9102_hold_cycles.setEnabled(advanced and use_sram and not self._ad9102_use_amplitude.isChecked())
|
||||
self._ad9102_amplitude.setEnabled(use_sram and use_amplitude)
|
||||
|
||||
if advanced:
|
||||
if use_sram:
|
||||
sample_count = self._ad9102_sample_count.value()
|
||||
hold_cycles = (
|
||||
1
|
||||
if self._ad9102_use_amplitude.isChecked()
|
||||
else (self._ad9102_hold_cycles.value() or 1)
|
||||
)
|
||||
actual_frequency = ad9102_sram_frequency_from_playback_hz(
|
||||
sample_count=sample_count,
|
||||
hold_cycles=hold_cycles,
|
||||
)
|
||||
self._ad9102_preview.setText(
|
||||
"Реальная частота: "
|
||||
f"{self._format_hz(actual_frequency)} "
|
||||
f"(точек: {sample_count}, удержание: {hold_cycles})"
|
||||
)
|
||||
if self._ad9102_use_amplitude.isChecked():
|
||||
hint = (
|
||||
"Расширенный режим памяти. Сейчас задаются размах и число точек. "
|
||||
"Удержание фиксировано значением из прошивки: 1 такт на точку."
|
||||
)
|
||||
else:
|
||||
hint = (
|
||||
"Расширенный режим памяти. Сейчас задаются число точек и удержание. "
|
||||
"Размах при этом возьмётся из прошивочного значения по умолчанию."
|
||||
)
|
||||
else:
|
||||
actual_frequency = ad9102_saw_frequency_from_step_hz(
|
||||
triangle=triangle,
|
||||
saw_step=self._ad9102_saw_step.value(),
|
||||
)
|
||||
self._ad9102_preview.setText(
|
||||
"Реальная частота: "
|
||||
f"{self._format_hz(actual_frequency)} "
|
||||
f"(код шага: {self._ad9102_saw_step.value()})"
|
||||
)
|
||||
hint = (
|
||||
"Расширенный встроенный режим AD9102. Частота определяется в основном кодом шага, "
|
||||
)
|
||||
else:
|
||||
if use_sram:
|
||||
min_hz, max_hz = ad9102_sram_frequency_limits_hz()
|
||||
else:
|
||||
min_hz, max_hz = ad9102_saw_frequency_limits_hz(triangle=triangle)
|
||||
self._ad9102_frequency_hz.blockSignals(True)
|
||||
self._ad9102_frequency_hz.setRange(min_hz, max_hz)
|
||||
if self._ad9102_frequency_hz.value() < min_hz:
|
||||
self._ad9102_frequency_hz.setValue(min_hz)
|
||||
elif self._ad9102_frequency_hz.value() > max_hz:
|
||||
self._ad9102_frequency_hz.setValue(max_hz)
|
||||
self._ad9102_frequency_hz.blockSignals(False)
|
||||
|
||||
desired_frequency = self._ad9102_frequency_hz.value()
|
||||
if use_sram:
|
||||
sample_count, actual_frequency = ad9102_sram_sample_count_from_frequency_hz(
|
||||
frequency_hz=desired_frequency,
|
||||
)
|
||||
self._ad9102_sample_count.blockSignals(True)
|
||||
self._ad9102_hold_cycles.blockSignals(True)
|
||||
self._ad9102_use_amplitude.blockSignals(True)
|
||||
self._ad9102_sample_count.setValue(sample_count)
|
||||
self._ad9102_hold_cycles.setValue(1)
|
||||
self._ad9102_use_amplitude.setChecked(True)
|
||||
self._ad9102_sample_count.blockSignals(False)
|
||||
self._ad9102_hold_cycles.blockSignals(False)
|
||||
self._ad9102_use_amplitude.blockSignals(False)
|
||||
self._ad9102_preview.setText(
|
||||
"Реальная частота: "
|
||||
f"{self._format_hz(actual_frequency)} "
|
||||
f"(точек: {sample_count}, удержание: 1)"
|
||||
)
|
||||
hint = (
|
||||
f"Доступный диапазон: {min_hz:,}..{max_hz:,} Гц. "
|
||||
).replace(",", " ")
|
||||
else:
|
||||
saw_step, actual_frequency = ad9102_saw_step_from_frequency_hz(
|
||||
triangle=triangle,
|
||||
frequency_hz=desired_frequency,
|
||||
)
|
||||
self._ad9102_saw_step.blockSignals(True)
|
||||
self._ad9102_pat_base.blockSignals(True)
|
||||
self._ad9102_pat_period.blockSignals(True)
|
||||
self._ad9102_saw_step.setValue(saw_step)
|
||||
self._ad9102_pat_base.setValue(DEFAULT_AD9102_PAT_BASE)
|
||||
self._ad9102_pat_period.setValue(DEFAULT_AD9102_PAT_PERIOD)
|
||||
self._ad9102_saw_step.blockSignals(False)
|
||||
self._ad9102_pat_base.blockSignals(False)
|
||||
self._ad9102_pat_period.blockSignals(False)
|
||||
self._ad9102_preview.setText(
|
||||
"Реальная частота: "
|
||||
f"{self._format_hz(actual_frequency)} "
|
||||
f"(код шага: {saw_step})"
|
||||
)
|
||||
hint = (
|
||||
f"Доступный диапазон: {min_hz:,}..{max_hz:,} Гц. "
|
||||
"Амплитуда этой STM-командой не управляется."
|
||||
).replace(",", " ")
|
||||
|
||||
self._ad9102_basic_hint.setText(hint)
|
||||
|
||||
@staticmethod
|
||||
def _format_hz(value: float) -> str:
|
||||
return f"{value:,.1f} Гц".replace(",", " ")
|
||||
|
||||
def _update_ad9833_preview(self) -> None:
|
||||
frequency_hz = self._ad9833_frequency_hz.value()
|
||||
frequency_word = int(round(frequency_hz * (1 << 28) / AD9833_MCLK_HZ))
|
||||
self._ad9833_word_preview.setText(
|
||||
f"Внутренний код: {frequency_word:,}".replace(",", " ")
|
||||
)
|
||||
|
||||
def _on_measurements_ready(self, measurements: Measurements) -> None:
|
||||
self._telemetry_temp1.setText(f"{measurements.temp1:.2f} °C")
|
||||
self._telemetry_temp2.setText(f"{measurements.temp2:.2f} °C")
|
||||
self._telemetry_current1.setText(f"{measurements.current1:.3f} мА")
|
||||
self._telemetry_current2.setText(f"{measurements.current2:.3f} мА")
|
||||
self._telemetry_temp_ext1.setText(f"{measurements.temp_ext1:.2f} °C")
|
||||
self._telemetry_temp_ext2.setText(f"{measurements.temp_ext2:.2f} °C")
|
||||
self._telemetry_3v3.setText(f"{measurements.voltage_3v3:.3f} В")
|
||||
self._telemetry_5v1.setText(f"{measurements.voltage_5v1:.3f} В")
|
||||
self._telemetry_5v2.setText(f"{measurements.voltage_5v2:.3f} В")
|
||||
self._telemetry_7v0.setText(f"{measurements.voltage_7v0:.3f} В")
|
||||
self._message_id_value.setText(str(measurements.message_id))
|
||||
|
||||
self._temp1_history.append(measurements.temp1)
|
||||
self._temp2_history.append(measurements.temp2)
|
||||
self._current1_history.append(measurements.current1)
|
||||
self._current2_history.append(measurements.current2)
|
||||
self._refresh_plot_curves()
|
||||
|
||||
def _on_status_ready(self, status: DeviceStatus) -> None:
|
||||
self._port_value.setText(self._port_name or "auto")
|
||||
self._state_value.setText(status.error_message or "All ok.")
|
||||
self._detail_value.setText(f"0x{status.detail:02X}")
|
||||
self._message_id_value.setText(
|
||||
str(status.last_command_id) if status.last_command_id is not None else "—"
|
||||
)
|
||||
|
||||
if status.has_error:
|
||||
self._status_header.setText("Есть ошибки")
|
||||
self._status_header.setObjectName("statusError")
|
||||
elif self._connected:
|
||||
self._status_header.setText("Подключено")
|
||||
self._status_header.setObjectName("statusOk")
|
||||
|
||||
self._status_header.style().unpolish(self._status_header)
|
||||
self._status_header.style().polish(self._status_header)
|
||||
|
||||
def _refresh_plot_curves(self) -> None:
|
||||
x1 = list(range(len(self._temp1_history)))
|
||||
x2 = list(range(len(self._temp2_history)))
|
||||
x3 = list(range(len(self._current1_history)))
|
||||
x4 = list(range(len(self._current2_history)))
|
||||
self._curve_temp1.setData(x1, list(self._temp1_history))
|
||||
self._curve_temp2.setData(x2, list(self._temp2_history))
|
||||
self._curve_current1.setData(x3, list(self._current1_history))
|
||||
self._curve_current2.setData(x4, list(self._current2_history))
|
||||
|
||||
def _append_log(self, level: str, message: str) -> None:
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
self._log_box.append(f"[{timestamp}] {level:<5} {message}")
|
||||
self._log_box.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
def _custom_waveform_is_available(self) -> bool:
|
||||
try:
|
||||
return len(self._parse_wave_samples(self._wave_samples_box.toPlainText())) >= 2
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _build_profile_save_request(
|
||||
self,
|
||||
*,
|
||||
profile_name: str,
|
||||
include_custom_waveform: bool,
|
||||
) -> ProfileSaveRequest:
|
||||
custom_wave_samples: list[int] = []
|
||||
if include_custom_waveform:
|
||||
custom_wave_samples = self._parse_wave_samples(self._wave_samples_box.toPlainText())
|
||||
if len(custom_wave_samples) < 2:
|
||||
raise ValueError("Для сохранения пользовательской формы нужно минимум 2 отсчёта")
|
||||
|
||||
return ProfileSaveRequest(
|
||||
profile_name=profile_name.strip(),
|
||||
profile_text=self._build_profile_text(
|
||||
profile_name=profile_name.strip(),
|
||||
custom_wave_samples=custom_wave_samples,
|
||||
),
|
||||
waveform_text=self._build_waveform_text(custom_wave_samples) if custom_wave_samples else "",
|
||||
)
|
||||
|
||||
def _build_profile_text(self, *, profile_name: str, custom_wave_samples: list[int]) -> str:
|
||||
waveform_mode = "custom_sram" if custom_wave_samples else (
|
||||
"generated_sram" if self._ad9102_mode.currentData() == "sram" else "saw"
|
||||
)
|
||||
waveform_sample_count = len(custom_wave_samples) if custom_wave_samples else self._ad9102_sample_count.value()
|
||||
waveform_hold_cycles = 1 if custom_wave_samples else self._ad9102_hold_cycles.value()
|
||||
waveform_triangle = 1 if self._ad9102_shape.currentData() == "triangle" else 0
|
||||
ad9833_frequency_word = int(round(self._ad9833_frequency_hz.value() * (1 << 28) / AD9833_MCLK_HZ))
|
||||
pid_p = DEFAULT_PI_P / 256.0
|
||||
pid_i = DEFAULT_PI_I / 256.0
|
||||
|
||||
lines = [
|
||||
"# Saved from the desktop GUI.",
|
||||
f"profile_name={profile_name}",
|
||||
"boot_enabled=true",
|
||||
"auto_run=true",
|
||||
"",
|
||||
"work_enable=1",
|
||||
"u5v1_enable=1",
|
||||
"u5v2_enable=1",
|
||||
"ld1_enable=1",
|
||||
"ld2_enable=1",
|
||||
"ref1_enable=1",
|
||||
"ref2_enable=1",
|
||||
"tec1_enable=1",
|
||||
"tec2_enable=1",
|
||||
"ts1_enable=1",
|
||||
"ts2_enable=1",
|
||||
"",
|
||||
"pid1_from_host=1",
|
||||
"pid2_from_host=1",
|
||||
"averages=0",
|
||||
"message_id=0",
|
||||
"",
|
||||
f"laser1_target_temp={temp_c_to_n(self._manual_temp1.value())}",
|
||||
f"laser2_target_temp={temp_c_to_n(self._manual_temp2.value())}",
|
||||
f"laser1_current={current_ma_to_n(self._manual_current1.value())}",
|
||||
f"laser2_current={current_ma_to_n(self._manual_current2.value())}",
|
||||
f"laser1_pid_p={pid_p:.6g}",
|
||||
f"laser1_pid_i={pid_i:.6g}",
|
||||
f"laser2_pid_p={pid_p:.6g}",
|
||||
f"laser2_pid_i={pid_i:.6g}",
|
||||
"",
|
||||
f"waveform_mode={waveform_mode}",
|
||||
f"waveform_enable={1 if self._ad9102_enable.isChecked() else 0}",
|
||||
f"waveform_triangle={waveform_triangle}",
|
||||
f"waveform_saw_step={self._ad9102_saw_step.value()}",
|
||||
f"waveform_pat_base={self._ad9102_pat_base.value()}",
|
||||
f"waveform_pat_period={self._ad9102_pat_period.value()}",
|
||||
f"waveform_sample_count={waveform_sample_count}",
|
||||
f"waveform_hold_cycles={waveform_hold_cycles}",
|
||||
f"waveform_amplitude={self._ad9102_amplitude.value()}",
|
||||
"",
|
||||
f"ad9833_enable={1 if self._ad9833_enable.isChecked() else 0}",
|
||||
f"ad9833_triangle={1 if self._ad9833_shape.currentData() == 'triangle' else 0}",
|
||||
f"ad9833_frequency_word={ad9833_frequency_word}",
|
||||
"",
|
||||
f"stm32_dac_enable={1 if self._stm32_dac_enable.isChecked() else 0}",
|
||||
f"stm32_dac_code={self._stm32_dac_code.value()}",
|
||||
"",
|
||||
f"ds1809_apply={'true' if self._ds1809_profile_apply.isChecked() else 'false'}",
|
||||
f"ds1809_position_from_min={self._ds1809_profile_position.value()}",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _build_waveform_text(samples: list[int]) -> str:
|
||||
return "\n".join(str(sample) for sample in samples) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _parse_wave_samples(text: str) -> list[int]:
|
||||
cleaned = (
|
||||
text.replace("[", " ")
|
||||
.replace("]", " ")
|
||||
.replace("(", " ")
|
||||
.replace(")", " ")
|
||||
)
|
||||
tokens = [token for token in re.split(r"[\s,;]+", cleaned.strip()) if token]
|
||||
return [int(token, 0) for token in tokens]
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
self._poll_timer.stop()
|
||||
self.request_shutdown.emit()
|
||||
self._worker_thread.quit()
|
||||
self._worker_thread.wait(3000)
|
||||
super().closeEvent(event)
|
||||
290
laser_control/gui/worker.py
Normal file
290
laser_control/gui/worker.py
Normal file
@ -0,0 +1,290 @@
|
||||
"""Worker object hosting the controller in a dedicated QThread."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
import time
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
|
||||
|
||||
from laser_control import (
|
||||
CommunicationError,
|
||||
DeviceNotRespondingError,
|
||||
LaserController,
|
||||
)
|
||||
|
||||
|
||||
class ControllerWorker(QObject):
|
||||
"""Run blocking serial I/O away from the GUI thread."""
|
||||
|
||||
connected_changed = pyqtSignal(bool, str)
|
||||
measurements_ready = pyqtSignal(object)
|
||||
status_ready = pyqtSignal(object)
|
||||
log_message = pyqtSignal(str, str)
|
||||
command_finished = pyqtSignal()
|
||||
poll_finished = pyqtSignal()
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._controller = LaserController()
|
||||
self._poll_in_progress = False
|
||||
self._last_status_time = 0.0
|
||||
|
||||
@pyqtSlot()
|
||||
def connect_device(self) -> None:
|
||||
"""Connect to the board and query current status without changing setpoints."""
|
||||
self._run_command(self._connect_device_impl)
|
||||
|
||||
@pyqtSlot(float, float, float, float)
|
||||
def apply_manual(
|
||||
self,
|
||||
temp1: float,
|
||||
temp2: float,
|
||||
current1: float,
|
||||
current2: float,
|
||||
) -> None:
|
||||
"""Apply manual setpoints on the device."""
|
||||
self._run_command(
|
||||
lambda: (
|
||||
self._ensure_connected(),
|
||||
self._apply_manual_impl(temp1, temp2, current1, current2),
|
||||
)
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def reset_device(self) -> None:
|
||||
"""Send the firmware default command."""
|
||||
self._run_command(
|
||||
lambda: (
|
||||
self._ensure_connected(),
|
||||
self._reset_device_impl(),
|
||||
)
|
||||
)
|
||||
|
||||
@pyqtSlot(dict)
|
||||
def apply_ad9102(self, config: dict) -> None:
|
||||
"""Configure AD9102 generator state."""
|
||||
self._run_command(
|
||||
lambda: (
|
||||
self._ensure_connected(),
|
||||
self._apply_ad9102_impl(config),
|
||||
)
|
||||
)
|
||||
|
||||
@pyqtSlot(bool, bool, int)
|
||||
def apply_ad9833(self, enabled: bool, triangle: bool, frequency_hz: int) -> None:
|
||||
"""Configure AD9833 generator state."""
|
||||
self._run_command(
|
||||
lambda: (
|
||||
self._ensure_connected(),
|
||||
self._apply_ad9833_impl(enabled, triangle, frequency_hz),
|
||||
)
|
||||
)
|
||||
|
||||
@pyqtSlot(bool, int, int)
|
||||
def pulse_ds1809(self, increment: bool, count: int, pulse_ms: int) -> None:
|
||||
"""Pulse the DS1809 potentiometer."""
|
||||
self._run_command(
|
||||
lambda: (
|
||||
self._ensure_connected(),
|
||||
self._pulse_ds1809_impl(increment, count, pulse_ms),
|
||||
)
|
||||
)
|
||||
|
||||
@pyqtSlot(bool, int)
|
||||
def set_stm32_dac(self, enabled: bool, dac_code: int) -> None:
|
||||
"""Configure the STM32 DAC."""
|
||||
self._run_command(
|
||||
lambda: (
|
||||
self._ensure_connected(),
|
||||
self._set_stm32_dac_impl(enabled, dac_code),
|
||||
)
|
||||
)
|
||||
|
||||
@pyqtSlot(object)
|
||||
def save_profile(self, request: object) -> None:
|
||||
"""Save the current GUI configuration to the device SD card."""
|
||||
self._run_command(
|
||||
lambda: (
|
||||
self._ensure_connected(),
|
||||
self._save_profile_impl(request),
|
||||
)
|
||||
)
|
||||
|
||||
@pyqtSlot(object)
|
||||
def upload_ad9102_waveform(self, samples: object) -> None:
|
||||
"""Upload a custom waveform to AD9102 SRAM."""
|
||||
self._run_command(
|
||||
lambda: (
|
||||
self._ensure_connected(),
|
||||
self._upload_ad9102_waveform_impl(samples),
|
||||
)
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def cancel_ad9102_waveform_upload(self) -> None:
|
||||
"""Cancel an in-progress waveform upload."""
|
||||
self._run_command(
|
||||
lambda: (
|
||||
self._ensure_connected(),
|
||||
self._cancel_ad9102_waveform_upload_impl(),
|
||||
)
|
||||
)
|
||||
|
||||
@pyqtSlot()
|
||||
def poll(self) -> None:
|
||||
"""Fetch measurements regularly and refresh status once per second."""
|
||||
if self._poll_in_progress or not self._controller.is_connected:
|
||||
return
|
||||
|
||||
self._poll_in_progress = True
|
||||
try:
|
||||
measurements = self._controller.get_measurements()
|
||||
if measurements is not None:
|
||||
self.measurements_ready.emit(measurements)
|
||||
|
||||
now = time.monotonic()
|
||||
if now - self._last_status_time >= 1.0:
|
||||
self._emit_status()
|
||||
except (CommunicationError, DeviceNotRespondingError) as exc:
|
||||
self.log_message.emit("ERROR", str(exc))
|
||||
self._disconnect_silently()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.log_message.emit("ERROR", str(exc))
|
||||
finally:
|
||||
self._poll_in_progress = False
|
||||
self.poll_finished.emit()
|
||||
|
||||
@pyqtSlot()
|
||||
def shutdown(self) -> None:
|
||||
"""Disconnect gracefully when the GUI closes."""
|
||||
self._disconnect_silently()
|
||||
|
||||
def _run_command(self, action: Callable[[], None]) -> None:
|
||||
try:
|
||||
action()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.log_message.emit("ERROR", str(exc))
|
||||
finally:
|
||||
self.command_finished.emit()
|
||||
|
||||
def _connect_device_impl(self) -> None:
|
||||
self._disconnect_silently()
|
||||
try:
|
||||
self._controller.connect()
|
||||
self.connected_changed.emit(True, self._controller.port_name or "")
|
||||
self.log_message.emit(
|
||||
"INFO",
|
||||
f"Connected to {self._controller.port_name or 'auto-detected port'}",
|
||||
)
|
||||
self._emit_status()
|
||||
measurements = self._controller.get_measurements()
|
||||
if measurements is not None:
|
||||
self.measurements_ready.emit(measurements)
|
||||
except Exception:
|
||||
self._disconnect_silently()
|
||||
raise
|
||||
|
||||
def _apply_manual_impl(self, temp1: float, temp2: float, current1: float, current2: float) -> None:
|
||||
self._controller.set_manual_mode(temp1, temp2, current1, current2)
|
||||
self.log_message.emit(
|
||||
"INFO",
|
||||
f"Manual mode applied: T1={temp1:.2f} T2={temp2:.2f} I1={current1:.3f} I2={current2:.3f}",
|
||||
)
|
||||
self._emit_status()
|
||||
|
||||
def _reset_device_impl(self) -> None:
|
||||
self._controller.reset()
|
||||
self.log_message.emit("INFO", "DEFAULT_ENABLE sent")
|
||||
self._emit_status()
|
||||
|
||||
def _apply_ad9102_impl(self, config: dict) -> None:
|
||||
if config.pop("use_basic", False):
|
||||
simple_config = {
|
||||
"enabled": config["enabled"],
|
||||
"use_sram": config["use_sram"],
|
||||
"triangle": config["triangle"],
|
||||
"frequency_hz": config["frequency_hz"],
|
||||
"amplitude": config["amplitude"],
|
||||
}
|
||||
result = self._controller.configure_ad9102_simple(**simple_config)
|
||||
actual_frequency_hz = float(result["actual_frequency_hz"])
|
||||
if simple_config["use_sram"]:
|
||||
self.log_message.emit(
|
||||
"INFO",
|
||||
"AD9102 memory waveform applied: "
|
||||
f"{actual_frequency_hz:.1f} Hz, "
|
||||
f"samples={result['sample_count']}, "
|
||||
f"amplitude={simple_config['amplitude']}",
|
||||
)
|
||||
else:
|
||||
self.log_message.emit(
|
||||
"INFO",
|
||||
"AD9102 built-in waveform applied: "
|
||||
f"{actual_frequency_hz:.1f} Hz, "
|
||||
f"saw_step={result['saw_step']}",
|
||||
)
|
||||
else:
|
||||
self._controller.configure_ad9102(**config)
|
||||
self.log_message.emit("INFO", "AD9102 advanced settings applied")
|
||||
self._emit_status()
|
||||
|
||||
def _apply_ad9833_impl(self, enabled: bool, triangle: bool, frequency_hz: int) -> None:
|
||||
frequency_word = self._controller.configure_ad9833_frequency(
|
||||
enabled=enabled,
|
||||
triangle=triangle,
|
||||
frequency_hz=frequency_hz,
|
||||
)
|
||||
self.log_message.emit(
|
||||
"INFO",
|
||||
f"AD9833 settings applied: {frequency_hz} Hz, code={frequency_word}",
|
||||
)
|
||||
self._emit_status()
|
||||
|
||||
def _pulse_ds1809_impl(self, increment: bool, count: int, pulse_ms: int) -> None:
|
||||
self._controller.pulse_ds1809(
|
||||
increment=increment,
|
||||
count=count,
|
||||
pulse_ms=pulse_ms,
|
||||
)
|
||||
direction = "increment" if increment else "decrement"
|
||||
self.log_message.emit("INFO", f"DS1809 pulse: {direction}, count={count}, pulse={pulse_ms} ms")
|
||||
self._emit_status()
|
||||
|
||||
def _set_stm32_dac_impl(self, enabled: bool, dac_code: int) -> None:
|
||||
self._controller.set_stm32_dac(enabled=enabled, dac_code=dac_code)
|
||||
self.log_message.emit("INFO", f"STM32 DAC set to code {dac_code}")
|
||||
self._emit_status()
|
||||
|
||||
def _save_profile_impl(self, request: object) -> None:
|
||||
self._controller.save_profile_to_sd(request)
|
||||
profile_name = getattr(request, "profile_name", "<unnamed>")
|
||||
self.log_message.emit("INFO", f"Profile saved to SD: {profile_name}")
|
||||
self._emit_status()
|
||||
|
||||
def _upload_ad9102_waveform_impl(self, samples: object) -> None:
|
||||
sample_list = list(samples)
|
||||
self._controller.upload_ad9102_waveform(sample_list)
|
||||
self.log_message.emit("INFO", f"AD9102 waveform uploaded ({len(sample_list)} samples)")
|
||||
self._emit_status()
|
||||
|
||||
def _cancel_ad9102_waveform_upload_impl(self) -> None:
|
||||
self._controller.cancel_ad9102_waveform_upload()
|
||||
self.log_message.emit("INFO", "AD9102 waveform upload cancelled")
|
||||
self._emit_status()
|
||||
|
||||
def _emit_status(self) -> None:
|
||||
status = self._controller.get_status()
|
||||
self._last_status_time = time.monotonic()
|
||||
self.status_ready.emit(status)
|
||||
|
||||
def _ensure_connected(self) -> None:
|
||||
if not self._controller.is_connected:
|
||||
raise CommunicationError("Device is not connected")
|
||||
|
||||
def _disconnect_silently(self) -> None:
|
||||
try:
|
||||
if self._controller.is_connected:
|
||||
self._controller.disconnect()
|
||||
finally:
|
||||
self.connected_changed.emit(False, "")
|
||||
Reference in New Issue
Block a user