"""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