Add new PyQt UI
This commit is contained in:
752
laser_control/gui/window.py
Normal file
752
laser_control/gui/window.py
Normal file
@ -0,0 +1,752 @@
|
||||
"""Main PyQt window for the laser-controller desktop application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
import re
|
||||
|
||||
from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal
|
||||
from PyQt6.QtGui import QTextCursor
|
||||
from PyQt6.QtWidgets import (
|
||||
QFileDialog,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
import pyqtgraph as pg
|
||||
|
||||
from laser_control.constants import (
|
||||
AD9833_MCLK_HZ,
|
||||
DEFAULT_AD9102_AMPLITUDE,
|
||||
DEFAULT_AD9102_HOLD_CYCLES,
|
||||
DEFAULT_AD9102_PAT_BASE,
|
||||
DEFAULT_AD9102_PAT_PERIOD,
|
||||
DEFAULT_AD9102_SAMPLE_COUNT,
|
||||
DEFAULT_AD9102_SAW_FREQUENCY_HZ,
|
||||
DEFAULT_AD9102_SRAM_FREQUENCY_HZ,
|
||||
DEFAULT_PI_I,
|
||||
DEFAULT_PI_P,
|
||||
GUI_POLL_INTERVAL_MS,
|
||||
PLOT_POINTS,
|
||||
)
|
||||
from laser_control.conversions import current_ma_to_n, temp_c_to_n
|
||||
from laser_control.controller import (
|
||||
ad9102_saw_frequency_from_step_hz,
|
||||
ad9102_saw_frequency_limits_hz,
|
||||
ad9102_saw_step_from_frequency_hz,
|
||||
ad9102_sram_frequency_from_playback_hz,
|
||||
ad9102_sram_frequency_limits_hz,
|
||||
ad9102_sram_sample_count_from_frequency_hz,
|
||||
)
|
||||
from laser_control.models import DeviceStatus, Measurements, ProfileSaveRequest
|
||||
|
||||
from .dialogs import ProfileSaveDialog
|
||||
from .sections import (
|
||||
build_device_group,
|
||||
build_log_group,
|
||||
build_manual_group,
|
||||
build_status_group,
|
||||
)
|
||||
from .worker import ControllerWorker
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Compact GUI composed around live plots and explicit control cards."""
|
||||
|
||||
request_connect = pyqtSignal()
|
||||
request_apply_manual = pyqtSignal(float, float, float, float)
|
||||
request_reset = pyqtSignal()
|
||||
request_apply_ad9102 = pyqtSignal(dict)
|
||||
request_apply_ad9833 = pyqtSignal(bool, bool, int)
|
||||
request_pulse_ds1809 = pyqtSignal(bool, int, int)
|
||||
request_set_stm32_dac = pyqtSignal(bool, int)
|
||||
request_upload_wave = pyqtSignal(object)
|
||||
request_cancel_wave = pyqtSignal()
|
||||
request_save_profile = pyqtSignal(object)
|
||||
request_poll = pyqtSignal()
|
||||
request_shutdown = pyqtSignal()
|
||||
|
||||
def __init__(self, *, auto_connect: bool = True) -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle("Управление лазерной схемой")
|
||||
|
||||
self._connected = False
|
||||
self._port_name = ""
|
||||
self._poll_in_flight = False
|
||||
self._command_in_flight = False
|
||||
|
||||
self._temp1_history = deque(maxlen=PLOT_POINTS)
|
||||
self._temp2_history = deque(maxlen=PLOT_POINTS)
|
||||
self._current1_history = deque(maxlen=PLOT_POINTS)
|
||||
self._current2_history = deque(maxlen=PLOT_POINTS)
|
||||
|
||||
self._build_ui()
|
||||
self._update_ad9102_form()
|
||||
self._update_ad9833_preview()
|
||||
self._on_wave_text_changed()
|
||||
self._update_control_state()
|
||||
self._build_worker()
|
||||
|
||||
self._poll_timer = QTimer(self)
|
||||
self._poll_timer.setInterval(GUI_POLL_INTERVAL_MS)
|
||||
self._poll_timer.timeout.connect(self._request_poll_if_idle)
|
||||
self._poll_timer.start()
|
||||
|
||||
self._append_log("INFO", "GUI started")
|
||||
if auto_connect:
|
||||
self._emit_connect_request()
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
root = QWidget(self)
|
||||
self.setCentralWidget(root)
|
||||
|
||||
layout = QHBoxLayout(root)
|
||||
layout.setContentsMargins(14, 14, 14, 14)
|
||||
layout.setSpacing(14)
|
||||
|
||||
layout.addWidget(self._build_plot_panel(), stretch=11)
|
||||
layout.addWidget(self._build_side_panel(), stretch=5)
|
||||
|
||||
def _build_plot_panel(self) -> QWidget:
|
||||
panel = QWidget(self)
|
||||
grid = QGridLayout(panel)
|
||||
grid.setContentsMargins(0, 0, 0, 0)
|
||||
grid.setHorizontalSpacing(12)
|
||||
grid.setVerticalSpacing(12)
|
||||
|
||||
self._plot_temp1, self._curve_temp1 = self._build_plot_card(
|
||||
"Температура лазера 1",
|
||||
"#ffb703",
|
||||
0,
|
||||
50,
|
||||
)
|
||||
self._plot_temp2, self._curve_temp2 = self._build_plot_card(
|
||||
"Температура лазера 2",
|
||||
"#fb8500",
|
||||
0,
|
||||
50,
|
||||
)
|
||||
self._plot_current1, self._curve_current1 = self._build_plot_card(
|
||||
"Фотодиод 1",
|
||||
"#219ebc",
|
||||
0,
|
||||
1.2,
|
||||
)
|
||||
self._plot_current2, self._curve_current2 = self._build_plot_card(
|
||||
"Фотодиод 2",
|
||||
"#2a9d8f",
|
||||
0,
|
||||
1.2,
|
||||
)
|
||||
|
||||
grid.addWidget(self._plot_temp1, 0, 0)
|
||||
grid.addWidget(self._plot_temp2, 0, 1)
|
||||
grid.addWidget(self._plot_current1, 1, 0)
|
||||
grid.addWidget(self._plot_current2, 1, 1)
|
||||
grid.setColumnStretch(0, 1)
|
||||
grid.setColumnStretch(1, 1)
|
||||
grid.setRowStretch(0, 1)
|
||||
grid.setRowStretch(1, 1)
|
||||
return panel
|
||||
|
||||
def _build_plot_card(
|
||||
self,
|
||||
title: str,
|
||||
color: str,
|
||||
y_min: float,
|
||||
y_max: float,
|
||||
) -> tuple[QWidget, pg.PlotDataItem]:
|
||||
container = QWidget(self)
|
||||
container_layout = QVBoxLayout(container)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.setSpacing(8)
|
||||
|
||||
label = QLabel(title, container)
|
||||
label.setObjectName("valueLabel")
|
||||
container_layout.addWidget(label)
|
||||
|
||||
plot = pg.PlotWidget(background="#0f1720", enableMenu=False)
|
||||
plot.showGrid(x=True, y=True, alpha=0.16)
|
||||
plot.setYRange(y_min, y_max)
|
||||
plot.setXRange(0, max(1, PLOT_POINTS - 1))
|
||||
plot.setMouseEnabled(x=False, y=False)
|
||||
plot.getPlotItem().hideButtons()
|
||||
plot.getPlotItem().setClipToView(True)
|
||||
plot.getPlotItem().setDownsampling(mode="peak")
|
||||
plot.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
curve = plot.plot(pen=pg.mkPen(color=color, width=2))
|
||||
container_layout.addWidget(plot, stretch=1)
|
||||
return container, curve
|
||||
|
||||
def _build_side_panel(self) -> QWidget:
|
||||
panel = QWidget(self)
|
||||
panel.setMinimumWidth(420)
|
||||
layout = QVBoxLayout(panel)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(12)
|
||||
|
||||
self._subtitle = QLabel("Автоподключение при запуске без автоприменения параметров")
|
||||
self._subtitle.setObjectName("captionLabel")
|
||||
layout.addWidget(self._subtitle)
|
||||
layout.addWidget(build_manual_group(self))
|
||||
layout.addWidget(build_device_group(self))
|
||||
layout.addWidget(build_status_group(self))
|
||||
layout.addWidget(build_log_group(self), stretch=1)
|
||||
layout.addStretch(1)
|
||||
|
||||
scroll = QScrollArea(self)
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
scroll.setWidget(panel)
|
||||
return scroll
|
||||
|
||||
def _build_worker(self) -> None:
|
||||
self._worker_thread = QThread(self)
|
||||
self._worker = ControllerWorker()
|
||||
self._worker.moveToThread(self._worker_thread)
|
||||
|
||||
self.request_connect.connect(self._worker.connect_device)
|
||||
self.request_apply_manual.connect(self._worker.apply_manual)
|
||||
self.request_reset.connect(self._worker.reset_device)
|
||||
self.request_apply_ad9102.connect(self._worker.apply_ad9102)
|
||||
self.request_apply_ad9833.connect(self._worker.apply_ad9833)
|
||||
self.request_pulse_ds1809.connect(self._worker.pulse_ds1809)
|
||||
self.request_set_stm32_dac.connect(self._worker.set_stm32_dac)
|
||||
self.request_upload_wave.connect(self._worker.upload_ad9102_waveform)
|
||||
self.request_cancel_wave.connect(self._worker.cancel_ad9102_waveform_upload)
|
||||
self.request_save_profile.connect(self._worker.save_profile)
|
||||
self.request_poll.connect(self._worker.poll)
|
||||
self.request_shutdown.connect(self._worker.shutdown)
|
||||
|
||||
self._worker.connected_changed.connect(self._on_connected_changed)
|
||||
self._worker.measurements_ready.connect(self._on_measurements_ready)
|
||||
self._worker.status_ready.connect(self._on_status_ready)
|
||||
self._worker.log_message.connect(self._append_log)
|
||||
self._worker.command_finished.connect(self._on_command_finished)
|
||||
self._worker.poll_finished.connect(self._on_poll_finished)
|
||||
|
||||
self._worker_thread.start()
|
||||
|
||||
def _emit_connect_request(self) -> None:
|
||||
self._dispatch_command(self.request_connect.emit)
|
||||
|
||||
def _dispatch_command(self, emit_request) -> None:
|
||||
if self._command_in_flight:
|
||||
return
|
||||
|
||||
self._command_in_flight = True
|
||||
self._poll_timer.stop()
|
||||
self._update_control_state()
|
||||
emit_request()
|
||||
|
||||
def _request_poll_if_idle(self) -> None:
|
||||
if not self._connected or self._command_in_flight or self._poll_in_flight:
|
||||
return
|
||||
|
||||
self._poll_in_flight = True
|
||||
self.request_poll.emit()
|
||||
|
||||
def _on_command_finished(self) -> None:
|
||||
self._command_in_flight = False
|
||||
self._update_control_state()
|
||||
if self._connected and not self._poll_timer.isActive():
|
||||
self._poll_timer.start()
|
||||
|
||||
def _on_poll_finished(self) -> None:
|
||||
self._poll_in_flight = False
|
||||
|
||||
def _on_apply_manual(self) -> None:
|
||||
self._dispatch_command(
|
||||
lambda: self.request_apply_manual.emit(
|
||||
self._manual_temp1.value(),
|
||||
self._manual_temp2.value(),
|
||||
self._manual_current1.value(),
|
||||
self._manual_current2.value(),
|
||||
)
|
||||
)
|
||||
|
||||
def _on_reset_device(self) -> None:
|
||||
self._dispatch_command(self.request_reset.emit)
|
||||
|
||||
def _on_apply_ad9102(self) -> None:
|
||||
use_sram = self._ad9102_mode.currentData() == "sram"
|
||||
advanced = self._ad9102_advanced_toggle.isChecked()
|
||||
config = {
|
||||
"use_basic": not advanced,
|
||||
"enabled": self._ad9102_enable.isChecked(),
|
||||
"use_sram": use_sram,
|
||||
"triangle": self._ad9102_shape.currentData() == "triangle",
|
||||
"frequency_hz": self._ad9102_frequency_hz.value(),
|
||||
"saw_step": self._ad9102_saw_step.value(),
|
||||
"pat_period_base": self._ad9102_pat_base.value(),
|
||||
"pat_period": self._ad9102_pat_period.value(),
|
||||
"sample_count": self._ad9102_sample_count.value(),
|
||||
"hold_cycles": self._ad9102_hold_cycles.value(),
|
||||
"amplitude": self._ad9102_amplitude.value(),
|
||||
"use_amplitude_format": use_sram and (not advanced or self._ad9102_use_amplitude.isChecked()),
|
||||
}
|
||||
self._dispatch_command(lambda: self.request_apply_ad9102.emit(config))
|
||||
|
||||
def _on_apply_ad9833(self) -> None:
|
||||
self._dispatch_command(
|
||||
lambda: self.request_apply_ad9833.emit(
|
||||
self._ad9833_enable.isChecked(),
|
||||
self._ad9833_shape.currentData() == "triangle",
|
||||
self._ad9833_frequency_hz.value(),
|
||||
)
|
||||
)
|
||||
|
||||
def _on_pulse_ds1809(self) -> None:
|
||||
self._dispatch_command(
|
||||
lambda: self.request_pulse_ds1809.emit(
|
||||
self._ds1809_direction.currentData() == "inc",
|
||||
self._ds1809_count.value(),
|
||||
self._ds1809_pulse_ms.value(),
|
||||
)
|
||||
)
|
||||
|
||||
def _on_apply_stm32_dac(self) -> None:
|
||||
self._dispatch_command(
|
||||
lambda: self.request_set_stm32_dac.emit(
|
||||
self._stm32_dac_enable.isChecked(),
|
||||
self._stm32_dac_code.value(),
|
||||
)
|
||||
)
|
||||
|
||||
def _on_save_profile(self) -> None:
|
||||
dialog = ProfileSaveDialog(
|
||||
custom_waveform_available=self._custom_waveform_is_available(),
|
||||
parent=self,
|
||||
)
|
||||
if dialog.exec() != ProfileSaveDialog.DialogCode.Accepted:
|
||||
return
|
||||
|
||||
try:
|
||||
request = self._build_profile_save_request(
|
||||
profile_name=dialog.profile_name(),
|
||||
include_custom_waveform=dialog.include_custom_waveform(),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._append_log("ERROR", str(exc))
|
||||
return
|
||||
|
||||
self._dispatch_command(lambda: self.request_save_profile.emit(request))
|
||||
|
||||
def _on_upload_waveform(self) -> None:
|
||||
try:
|
||||
samples = self._parse_wave_samples(self._wave_samples_box.toPlainText())
|
||||
if len(samples) < 2:
|
||||
raise ValueError("Для загрузки waveform нужно минимум 2 отсчёта")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._append_log("ERROR", str(exc))
|
||||
return
|
||||
self._dispatch_command(lambda: self.request_upload_wave.emit(samples))
|
||||
|
||||
def _on_cancel_waveform(self) -> None:
|
||||
self._dispatch_command(self.request_cancel_wave.emit)
|
||||
|
||||
def _on_load_wave_file(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Открыть файл waveform",
|
||||
"",
|
||||
"Text files (*.txt *.csv *.dat);;All files (*)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
self._wave_samples_box.setPlainText(handle.read())
|
||||
self._append_log("INFO", f"Waveform file loaded: {path}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._append_log("ERROR", f"Не удалось открыть файл: {exc}")
|
||||
|
||||
def _on_wave_text_changed(self) -> None:
|
||||
text = self._wave_samples_box.toPlainText().strip()
|
||||
if not text:
|
||||
self._wave_info_label.setText("Отсчётов: 0")
|
||||
return
|
||||
try:
|
||||
count = len(self._parse_wave_samples(text))
|
||||
self._wave_info_label.setText(f"Отсчётов: {count}")
|
||||
except Exception:
|
||||
self._wave_info_label.setText("Отсчётов: ошибка формата")
|
||||
|
||||
def _on_reconnect(self) -> None:
|
||||
self._append_log("INFO", "Reconnect requested from UI")
|
||||
self._emit_connect_request()
|
||||
|
||||
def _on_connected_changed(self, connected: bool, port_name: str) -> None:
|
||||
self._connected = connected
|
||||
self._port_name = port_name
|
||||
if not connected:
|
||||
self._poll_in_flight = False
|
||||
self._command_in_flight = False
|
||||
if connected:
|
||||
self._status_header.setText("Подключено")
|
||||
self._status_header.setObjectName("statusOk")
|
||||
self._subtitle.setText(f"Подключено к {port_name}")
|
||||
else:
|
||||
self._status_header.setText("Отключено")
|
||||
self._status_header.setObjectName("statusError")
|
||||
self._subtitle.setText("Автоподключение при запуске без автоприменения параметров")
|
||||
self._status_header.style().unpolish(self._status_header)
|
||||
self._status_header.style().polish(self._status_header)
|
||||
self._update_control_state()
|
||||
|
||||
def _update_control_state(self) -> None:
|
||||
connected = self._connected and not self._command_in_flight
|
||||
|
||||
self._apply_manual_button.setEnabled(connected)
|
||||
self._apply_ad9102_button.setEnabled(connected)
|
||||
self._apply_ad9833_button.setEnabled(connected)
|
||||
self._apply_stm32_dac_button.setEnabled(connected)
|
||||
self._pulse_ds1809_button.setEnabled(connected)
|
||||
self._upload_wave_button.setEnabled(connected)
|
||||
self._cancel_wave_button.setEnabled(connected)
|
||||
self._save_profile_button.setEnabled(connected)
|
||||
self._reset_button.setEnabled(connected)
|
||||
self._reconnect_button.setEnabled(not self._command_in_flight)
|
||||
self._load_wave_file_button.setEnabled(True)
|
||||
|
||||
def _on_ad9102_mode_changed(self) -> None:
|
||||
if not hasattr(self, "_ad9102_frequency_hz"):
|
||||
return
|
||||
|
||||
use_sram = self._ad9102_mode.currentData() == "sram"
|
||||
if use_sram:
|
||||
min_hz, max_hz = ad9102_sram_frequency_limits_hz()
|
||||
frequency_hz = DEFAULT_AD9102_SRAM_FREQUENCY_HZ
|
||||
else:
|
||||
min_hz, max_hz = ad9102_saw_frequency_limits_hz(
|
||||
triangle=self._ad9102_shape.currentData() == "triangle",
|
||||
)
|
||||
frequency_hz = DEFAULT_AD9102_SAW_FREQUENCY_HZ
|
||||
|
||||
frequency_hz = max(min_hz, min(max_hz, frequency_hz))
|
||||
self._ad9102_frequency_hz.blockSignals(True)
|
||||
self._ad9102_frequency_hz.setRange(min_hz, max_hz)
|
||||
self._ad9102_frequency_hz.setValue(frequency_hz)
|
||||
self._ad9102_frequency_hz.blockSignals(False)
|
||||
|
||||
if use_sram:
|
||||
self._ad9102_sample_count.blockSignals(True)
|
||||
self._ad9102_hold_cycles.blockSignals(True)
|
||||
self._ad9102_amplitude.blockSignals(True)
|
||||
self._ad9102_use_amplitude.blockSignals(True)
|
||||
self._ad9102_sample_count.setValue(DEFAULT_AD9102_SAMPLE_COUNT)
|
||||
self._ad9102_hold_cycles.setValue(DEFAULT_AD9102_HOLD_CYCLES)
|
||||
self._ad9102_amplitude.setValue(DEFAULT_AD9102_AMPLITUDE)
|
||||
self._ad9102_use_amplitude.setChecked(True)
|
||||
self._ad9102_sample_count.blockSignals(False)
|
||||
self._ad9102_hold_cycles.blockSignals(False)
|
||||
self._ad9102_amplitude.blockSignals(False)
|
||||
self._ad9102_use_amplitude.blockSignals(False)
|
||||
|
||||
self._update_ad9102_form()
|
||||
|
||||
def _update_ad9102_form(self) -> None:
|
||||
if not hasattr(self, "_ad9102_advanced_group"):
|
||||
return
|
||||
|
||||
use_sram = self._ad9102_mode.currentData() == "sram"
|
||||
advanced = self._ad9102_advanced_toggle.isChecked()
|
||||
triangle = self._ad9102_shape.currentData() == "triangle"
|
||||
use_amplitude = use_sram and (not advanced or self._ad9102_use_amplitude.isChecked())
|
||||
|
||||
self._ad9102_advanced_group.setVisible(advanced)
|
||||
self._ad9102_frequency_hz.setEnabled(not advanced)
|
||||
|
||||
self._ad9102_saw_step.setEnabled(advanced and not use_sram)
|
||||
self._ad9102_pat_base.setEnabled(advanced and not use_sram)
|
||||
self._ad9102_pat_period.setEnabled(advanced and not use_sram)
|
||||
|
||||
self._ad9102_sample_count.setEnabled(advanced and use_sram)
|
||||
self._ad9102_use_amplitude.setEnabled(advanced and use_sram)
|
||||
self._ad9102_hold_cycles.setEnabled(advanced and use_sram and not self._ad9102_use_amplitude.isChecked())
|
||||
self._ad9102_amplitude.setEnabled(use_sram and use_amplitude)
|
||||
|
||||
if advanced:
|
||||
if use_sram:
|
||||
sample_count = self._ad9102_sample_count.value()
|
||||
hold_cycles = (
|
||||
1
|
||||
if self._ad9102_use_amplitude.isChecked()
|
||||
else (self._ad9102_hold_cycles.value() or 1)
|
||||
)
|
||||
actual_frequency = ad9102_sram_frequency_from_playback_hz(
|
||||
sample_count=sample_count,
|
||||
hold_cycles=hold_cycles,
|
||||
)
|
||||
self._ad9102_preview.setText(
|
||||
"Реальная частота: "
|
||||
f"{self._format_hz(actual_frequency)} "
|
||||
f"(точек: {sample_count}, удержание: {hold_cycles})"
|
||||
)
|
||||
if self._ad9102_use_amplitude.isChecked():
|
||||
hint = (
|
||||
"Расширенный режим памяти. Сейчас задаются размах и число точек. "
|
||||
"Удержание фиксировано значением из прошивки: 1 такт на точку."
|
||||
)
|
||||
else:
|
||||
hint = (
|
||||
"Расширенный режим памяти. Сейчас задаются число точек и удержание. "
|
||||
"Размах при этом возьмётся из прошивочного значения по умолчанию."
|
||||
)
|
||||
else:
|
||||
actual_frequency = ad9102_saw_frequency_from_step_hz(
|
||||
triangle=triangle,
|
||||
saw_step=self._ad9102_saw_step.value(),
|
||||
)
|
||||
self._ad9102_preview.setText(
|
||||
"Реальная частота: "
|
||||
f"{self._format_hz(actual_frequency)} "
|
||||
f"(код шага: {self._ad9102_saw_step.value()})"
|
||||
)
|
||||
hint = (
|
||||
"Расширенный встроенный режим AD9102. Частота определяется в основном кодом шага, "
|
||||
)
|
||||
else:
|
||||
if use_sram:
|
||||
min_hz, max_hz = ad9102_sram_frequency_limits_hz()
|
||||
else:
|
||||
min_hz, max_hz = ad9102_saw_frequency_limits_hz(triangle=triangle)
|
||||
self._ad9102_frequency_hz.blockSignals(True)
|
||||
self._ad9102_frequency_hz.setRange(min_hz, max_hz)
|
||||
if self._ad9102_frequency_hz.value() < min_hz:
|
||||
self._ad9102_frequency_hz.setValue(min_hz)
|
||||
elif self._ad9102_frequency_hz.value() > max_hz:
|
||||
self._ad9102_frequency_hz.setValue(max_hz)
|
||||
self._ad9102_frequency_hz.blockSignals(False)
|
||||
|
||||
desired_frequency = self._ad9102_frequency_hz.value()
|
||||
if use_sram:
|
||||
sample_count, actual_frequency = ad9102_sram_sample_count_from_frequency_hz(
|
||||
frequency_hz=desired_frequency,
|
||||
)
|
||||
self._ad9102_sample_count.blockSignals(True)
|
||||
self._ad9102_hold_cycles.blockSignals(True)
|
||||
self._ad9102_use_amplitude.blockSignals(True)
|
||||
self._ad9102_sample_count.setValue(sample_count)
|
||||
self._ad9102_hold_cycles.setValue(1)
|
||||
self._ad9102_use_amplitude.setChecked(True)
|
||||
self._ad9102_sample_count.blockSignals(False)
|
||||
self._ad9102_hold_cycles.blockSignals(False)
|
||||
self._ad9102_use_amplitude.blockSignals(False)
|
||||
self._ad9102_preview.setText(
|
||||
"Реальная частота: "
|
||||
f"{self._format_hz(actual_frequency)} "
|
||||
f"(точек: {sample_count}, удержание: 1)"
|
||||
)
|
||||
hint = (
|
||||
f"Доступный диапазон: {min_hz:,}..{max_hz:,} Гц. "
|
||||
).replace(",", " ")
|
||||
else:
|
||||
saw_step, actual_frequency = ad9102_saw_step_from_frequency_hz(
|
||||
triangle=triangle,
|
||||
frequency_hz=desired_frequency,
|
||||
)
|
||||
self._ad9102_saw_step.blockSignals(True)
|
||||
self._ad9102_pat_base.blockSignals(True)
|
||||
self._ad9102_pat_period.blockSignals(True)
|
||||
self._ad9102_saw_step.setValue(saw_step)
|
||||
self._ad9102_pat_base.setValue(DEFAULT_AD9102_PAT_BASE)
|
||||
self._ad9102_pat_period.setValue(DEFAULT_AD9102_PAT_PERIOD)
|
||||
self._ad9102_saw_step.blockSignals(False)
|
||||
self._ad9102_pat_base.blockSignals(False)
|
||||
self._ad9102_pat_period.blockSignals(False)
|
||||
self._ad9102_preview.setText(
|
||||
"Реальная частота: "
|
||||
f"{self._format_hz(actual_frequency)} "
|
||||
f"(код шага: {saw_step})"
|
||||
)
|
||||
hint = (
|
||||
f"Доступный диапазон: {min_hz:,}..{max_hz:,} Гц. "
|
||||
"Амплитуда этой STM-командой не управляется."
|
||||
).replace(",", " ")
|
||||
|
||||
self._ad9102_basic_hint.setText(hint)
|
||||
|
||||
@staticmethod
|
||||
def _format_hz(value: float) -> str:
|
||||
return f"{value:,.1f} Гц".replace(",", " ")
|
||||
|
||||
def _update_ad9833_preview(self) -> None:
|
||||
frequency_hz = self._ad9833_frequency_hz.value()
|
||||
frequency_word = int(round(frequency_hz * (1 << 28) / AD9833_MCLK_HZ))
|
||||
self._ad9833_word_preview.setText(
|
||||
f"Внутренний код: {frequency_word:,}".replace(",", " ")
|
||||
)
|
||||
|
||||
def _on_measurements_ready(self, measurements: Measurements) -> None:
|
||||
self._telemetry_temp1.setText(f"{measurements.temp1:.2f} °C")
|
||||
self._telemetry_temp2.setText(f"{measurements.temp2:.2f} °C")
|
||||
self._telemetry_current1.setText(f"{measurements.current1:.3f} мА")
|
||||
self._telemetry_current2.setText(f"{measurements.current2:.3f} мА")
|
||||
self._telemetry_temp_ext1.setText(f"{measurements.temp_ext1:.2f} °C")
|
||||
self._telemetry_temp_ext2.setText(f"{measurements.temp_ext2:.2f} °C")
|
||||
self._telemetry_3v3.setText(f"{measurements.voltage_3v3:.3f} В")
|
||||
self._telemetry_5v1.setText(f"{measurements.voltage_5v1:.3f} В")
|
||||
self._telemetry_5v2.setText(f"{measurements.voltage_5v2:.3f} В")
|
||||
self._telemetry_7v0.setText(f"{measurements.voltage_7v0:.3f} В")
|
||||
self._message_id_value.setText(str(measurements.message_id))
|
||||
|
||||
self._temp1_history.append(measurements.temp1)
|
||||
self._temp2_history.append(measurements.temp2)
|
||||
self._current1_history.append(measurements.current1)
|
||||
self._current2_history.append(measurements.current2)
|
||||
self._refresh_plot_curves()
|
||||
|
||||
def _on_status_ready(self, status: DeviceStatus) -> None:
|
||||
self._port_value.setText(self._port_name or "auto")
|
||||
self._state_value.setText(status.error_message or "All ok.")
|
||||
self._detail_value.setText(f"0x{status.detail:02X}")
|
||||
self._message_id_value.setText(
|
||||
str(status.last_command_id) if status.last_command_id is not None else "—"
|
||||
)
|
||||
|
||||
if status.has_error:
|
||||
self._status_header.setText("Есть ошибки")
|
||||
self._status_header.setObjectName("statusError")
|
||||
elif self._connected:
|
||||
self._status_header.setText("Подключено")
|
||||
self._status_header.setObjectName("statusOk")
|
||||
|
||||
self._status_header.style().unpolish(self._status_header)
|
||||
self._status_header.style().polish(self._status_header)
|
||||
|
||||
def _refresh_plot_curves(self) -> None:
|
||||
x1 = list(range(len(self._temp1_history)))
|
||||
x2 = list(range(len(self._temp2_history)))
|
||||
x3 = list(range(len(self._current1_history)))
|
||||
x4 = list(range(len(self._current2_history)))
|
||||
self._curve_temp1.setData(x1, list(self._temp1_history))
|
||||
self._curve_temp2.setData(x2, list(self._temp2_history))
|
||||
self._curve_current1.setData(x3, list(self._current1_history))
|
||||
self._curve_current2.setData(x4, list(self._current2_history))
|
||||
|
||||
def _append_log(self, level: str, message: str) -> None:
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
self._log_box.append(f"[{timestamp}] {level:<5} {message}")
|
||||
self._log_box.moveCursor(QTextCursor.MoveOperation.End)
|
||||
|
||||
def _custom_waveform_is_available(self) -> bool:
|
||||
try:
|
||||
return len(self._parse_wave_samples(self._wave_samples_box.toPlainText())) >= 2
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _build_profile_save_request(
|
||||
self,
|
||||
*,
|
||||
profile_name: str,
|
||||
include_custom_waveform: bool,
|
||||
) -> ProfileSaveRequest:
|
||||
custom_wave_samples: list[int] = []
|
||||
if include_custom_waveform:
|
||||
custom_wave_samples = self._parse_wave_samples(self._wave_samples_box.toPlainText())
|
||||
if len(custom_wave_samples) < 2:
|
||||
raise ValueError("Для сохранения пользовательской формы нужно минимум 2 отсчёта")
|
||||
|
||||
return ProfileSaveRequest(
|
||||
profile_name=profile_name.strip(),
|
||||
profile_text=self._build_profile_text(
|
||||
profile_name=profile_name.strip(),
|
||||
custom_wave_samples=custom_wave_samples,
|
||||
),
|
||||
waveform_text=self._build_waveform_text(custom_wave_samples) if custom_wave_samples else "",
|
||||
)
|
||||
|
||||
def _build_profile_text(self, *, profile_name: str, custom_wave_samples: list[int]) -> str:
|
||||
waveform_mode = "custom_sram" if custom_wave_samples else (
|
||||
"generated_sram" if self._ad9102_mode.currentData() == "sram" else "saw"
|
||||
)
|
||||
waveform_sample_count = len(custom_wave_samples) if custom_wave_samples else self._ad9102_sample_count.value()
|
||||
waveform_hold_cycles = 1 if custom_wave_samples else self._ad9102_hold_cycles.value()
|
||||
waveform_triangle = 1 if self._ad9102_shape.currentData() == "triangle" else 0
|
||||
ad9833_frequency_word = int(round(self._ad9833_frequency_hz.value() * (1 << 28) / AD9833_MCLK_HZ))
|
||||
pid_p = DEFAULT_PI_P / 256.0
|
||||
pid_i = DEFAULT_PI_I / 256.0
|
||||
|
||||
lines = [
|
||||
"# Saved from the desktop GUI.",
|
||||
f"profile_name={profile_name}",
|
||||
"boot_enabled=true",
|
||||
"auto_run=true",
|
||||
"",
|
||||
"work_enable=1",
|
||||
"u5v1_enable=1",
|
||||
"u5v2_enable=1",
|
||||
"ld1_enable=1",
|
||||
"ld2_enable=1",
|
||||
"ref1_enable=1",
|
||||
"ref2_enable=1",
|
||||
"tec1_enable=1",
|
||||
"tec2_enable=1",
|
||||
"ts1_enable=1",
|
||||
"ts2_enable=1",
|
||||
"",
|
||||
"pid1_from_host=1",
|
||||
"pid2_from_host=1",
|
||||
"averages=0",
|
||||
"message_id=0",
|
||||
"",
|
||||
f"laser1_target_temp={temp_c_to_n(self._manual_temp1.value())}",
|
||||
f"laser2_target_temp={temp_c_to_n(self._manual_temp2.value())}",
|
||||
f"laser1_current={current_ma_to_n(self._manual_current1.value())}",
|
||||
f"laser2_current={current_ma_to_n(self._manual_current2.value())}",
|
||||
f"laser1_pid_p={pid_p:.6g}",
|
||||
f"laser1_pid_i={pid_i:.6g}",
|
||||
f"laser2_pid_p={pid_p:.6g}",
|
||||
f"laser2_pid_i={pid_i:.6g}",
|
||||
"",
|
||||
f"waveform_mode={waveform_mode}",
|
||||
f"waveform_enable={1 if self._ad9102_enable.isChecked() else 0}",
|
||||
f"waveform_triangle={waveform_triangle}",
|
||||
f"waveform_saw_step={self._ad9102_saw_step.value()}",
|
||||
f"waveform_pat_base={self._ad9102_pat_base.value()}",
|
||||
f"waveform_pat_period={self._ad9102_pat_period.value()}",
|
||||
f"waveform_sample_count={waveform_sample_count}",
|
||||
f"waveform_hold_cycles={waveform_hold_cycles}",
|
||||
f"waveform_amplitude={self._ad9102_amplitude.value()}",
|
||||
"",
|
||||
f"ad9833_enable={1 if self._ad9833_enable.isChecked() else 0}",
|
||||
f"ad9833_triangle={1 if self._ad9833_shape.currentData() == 'triangle' else 0}",
|
||||
f"ad9833_frequency_word={ad9833_frequency_word}",
|
||||
"",
|
||||
f"stm32_dac_enable={1 if self._stm32_dac_enable.isChecked() else 0}",
|
||||
f"stm32_dac_code={self._stm32_dac_code.value()}",
|
||||
"",
|
||||
f"ds1809_apply={'true' if self._ds1809_profile_apply.isChecked() else 'false'}",
|
||||
f"ds1809_position_from_min={self._ds1809_profile_position.value()}",
|
||||
]
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _build_waveform_text(samples: list[int]) -> str:
|
||||
return "\n".join(str(sample) for sample in samples) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _parse_wave_samples(text: str) -> list[int]:
|
||||
cleaned = (
|
||||
text.replace("[", " ")
|
||||
.replace("]", " ")
|
||||
.replace("(", " ")
|
||||
.replace(")", " ")
|
||||
)
|
||||
tokens = [token for token in re.split(r"[\s,;]+", cleaned.strip()) if token]
|
||||
return [int(token, 0) for token in tokens]
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
self._poll_timer.stop()
|
||||
self.request_shutdown.emit()
|
||||
self._worker_thread.quit()
|
||||
self._worker_thread.wait(3000)
|
||||
super().closeEvent(event)
|
||||
Reference in New Issue
Block a user