Files
RadioPhotonic_PCB_PC_software/laser_control/gui/window.py
2026-04-26 18:39:55 +03:00

753 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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