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