753 lines
31 KiB
Python
753 lines
31 KiB
Python
"""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)
|