291 lines
10 KiB
Python
291 lines
10 KiB
Python
"""Worker object hosting the controller in a dedicated QThread."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
import time
|
|
|
|
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
|
|
|
|
from laser_control import (
|
|
CommunicationError,
|
|
DeviceNotRespondingError,
|
|
LaserController,
|
|
)
|
|
|
|
|
|
class ControllerWorker(QObject):
|
|
"""Run blocking serial I/O away from the GUI thread."""
|
|
|
|
connected_changed = pyqtSignal(bool, str)
|
|
measurements_ready = pyqtSignal(object)
|
|
status_ready = pyqtSignal(object)
|
|
log_message = pyqtSignal(str, str)
|
|
command_finished = pyqtSignal()
|
|
poll_finished = pyqtSignal()
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self._controller = LaserController()
|
|
self._poll_in_progress = False
|
|
self._last_status_time = 0.0
|
|
|
|
@pyqtSlot()
|
|
def connect_device(self) -> None:
|
|
"""Connect to the board and query current status without changing setpoints."""
|
|
self._run_command(self._connect_device_impl)
|
|
|
|
@pyqtSlot(float, float, float, float)
|
|
def apply_manual(
|
|
self,
|
|
temp1: float,
|
|
temp2: float,
|
|
current1: float,
|
|
current2: float,
|
|
) -> None:
|
|
"""Apply manual setpoints on the device."""
|
|
self._run_command(
|
|
lambda: (
|
|
self._ensure_connected(),
|
|
self._apply_manual_impl(temp1, temp2, current1, current2),
|
|
)
|
|
)
|
|
|
|
@pyqtSlot()
|
|
def reset_device(self) -> None:
|
|
"""Send the firmware default command."""
|
|
self._run_command(
|
|
lambda: (
|
|
self._ensure_connected(),
|
|
self._reset_device_impl(),
|
|
)
|
|
)
|
|
|
|
@pyqtSlot(dict)
|
|
def apply_ad9102(self, config: dict) -> None:
|
|
"""Configure AD9102 generator state."""
|
|
self._run_command(
|
|
lambda: (
|
|
self._ensure_connected(),
|
|
self._apply_ad9102_impl(config),
|
|
)
|
|
)
|
|
|
|
@pyqtSlot(bool, bool, int)
|
|
def apply_ad9833(self, enabled: bool, triangle: bool, frequency_hz: int) -> None:
|
|
"""Configure AD9833 generator state."""
|
|
self._run_command(
|
|
lambda: (
|
|
self._ensure_connected(),
|
|
self._apply_ad9833_impl(enabled, triangle, frequency_hz),
|
|
)
|
|
)
|
|
|
|
@pyqtSlot(bool, int, int)
|
|
def pulse_ds1809(self, increment: bool, count: int, pulse_ms: int) -> None:
|
|
"""Pulse the DS1809 potentiometer."""
|
|
self._run_command(
|
|
lambda: (
|
|
self._ensure_connected(),
|
|
self._pulse_ds1809_impl(increment, count, pulse_ms),
|
|
)
|
|
)
|
|
|
|
@pyqtSlot(bool, int)
|
|
def set_stm32_dac(self, enabled: bool, dac_code: int) -> None:
|
|
"""Configure the STM32 DAC."""
|
|
self._run_command(
|
|
lambda: (
|
|
self._ensure_connected(),
|
|
self._set_stm32_dac_impl(enabled, dac_code),
|
|
)
|
|
)
|
|
|
|
@pyqtSlot(object)
|
|
def save_profile(self, request: object) -> None:
|
|
"""Save the current GUI configuration to the device SD card."""
|
|
self._run_command(
|
|
lambda: (
|
|
self._ensure_connected(),
|
|
self._save_profile_impl(request),
|
|
)
|
|
)
|
|
|
|
@pyqtSlot(object)
|
|
def upload_ad9102_waveform(self, samples: object) -> None:
|
|
"""Upload a custom waveform to AD9102 SRAM."""
|
|
self._run_command(
|
|
lambda: (
|
|
self._ensure_connected(),
|
|
self._upload_ad9102_waveform_impl(samples),
|
|
)
|
|
)
|
|
|
|
@pyqtSlot()
|
|
def cancel_ad9102_waveform_upload(self) -> None:
|
|
"""Cancel an in-progress waveform upload."""
|
|
self._run_command(
|
|
lambda: (
|
|
self._ensure_connected(),
|
|
self._cancel_ad9102_waveform_upload_impl(),
|
|
)
|
|
)
|
|
|
|
@pyqtSlot()
|
|
def poll(self) -> None:
|
|
"""Fetch measurements regularly and refresh status once per second."""
|
|
if self._poll_in_progress or not self._controller.is_connected:
|
|
return
|
|
|
|
self._poll_in_progress = True
|
|
try:
|
|
measurements = self._controller.get_measurements()
|
|
if measurements is not None:
|
|
self.measurements_ready.emit(measurements)
|
|
|
|
now = time.monotonic()
|
|
if now - self._last_status_time >= 1.0:
|
|
self._emit_status()
|
|
except (CommunicationError, DeviceNotRespondingError) as exc:
|
|
self.log_message.emit("ERROR", str(exc))
|
|
self._disconnect_silently()
|
|
except Exception as exc: # noqa: BLE001
|
|
self.log_message.emit("ERROR", str(exc))
|
|
finally:
|
|
self._poll_in_progress = False
|
|
self.poll_finished.emit()
|
|
|
|
@pyqtSlot()
|
|
def shutdown(self) -> None:
|
|
"""Disconnect gracefully when the GUI closes."""
|
|
self._disconnect_silently()
|
|
|
|
def _run_command(self, action: Callable[[], None]) -> None:
|
|
try:
|
|
action()
|
|
except Exception as exc: # noqa: BLE001
|
|
self.log_message.emit("ERROR", str(exc))
|
|
finally:
|
|
self.command_finished.emit()
|
|
|
|
def _connect_device_impl(self) -> None:
|
|
self._disconnect_silently()
|
|
try:
|
|
self._controller.connect()
|
|
self.connected_changed.emit(True, self._controller.port_name or "")
|
|
self.log_message.emit(
|
|
"INFO",
|
|
f"Connected to {self._controller.port_name or 'auto-detected port'}",
|
|
)
|
|
self._emit_status()
|
|
measurements = self._controller.get_measurements()
|
|
if measurements is not None:
|
|
self.measurements_ready.emit(measurements)
|
|
except Exception:
|
|
self._disconnect_silently()
|
|
raise
|
|
|
|
def _apply_manual_impl(self, temp1: float, temp2: float, current1: float, current2: float) -> None:
|
|
self._controller.set_manual_mode(temp1, temp2, current1, current2)
|
|
self.log_message.emit(
|
|
"INFO",
|
|
f"Manual mode applied: T1={temp1:.2f} T2={temp2:.2f} I1={current1:.3f} I2={current2:.3f}",
|
|
)
|
|
self._emit_status()
|
|
|
|
def _reset_device_impl(self) -> None:
|
|
self._controller.reset()
|
|
self.log_message.emit("INFO", "DEFAULT_ENABLE sent")
|
|
self._emit_status()
|
|
|
|
def _apply_ad9102_impl(self, config: dict) -> None:
|
|
if config.pop("use_basic", False):
|
|
simple_config = {
|
|
"enabled": config["enabled"],
|
|
"use_sram": config["use_sram"],
|
|
"triangle": config["triangle"],
|
|
"frequency_hz": config["frequency_hz"],
|
|
"amplitude": config["amplitude"],
|
|
}
|
|
result = self._controller.configure_ad9102_simple(**simple_config)
|
|
actual_frequency_hz = float(result["actual_frequency_hz"])
|
|
if simple_config["use_sram"]:
|
|
self.log_message.emit(
|
|
"INFO",
|
|
"AD9102 memory waveform applied: "
|
|
f"{actual_frequency_hz:.1f} Hz, "
|
|
f"samples={result['sample_count']}, "
|
|
f"amplitude={simple_config['amplitude']}",
|
|
)
|
|
else:
|
|
self.log_message.emit(
|
|
"INFO",
|
|
"AD9102 built-in waveform applied: "
|
|
f"{actual_frequency_hz:.1f} Hz, "
|
|
f"saw_step={result['saw_step']}",
|
|
)
|
|
else:
|
|
self._controller.configure_ad9102(**config)
|
|
self.log_message.emit("INFO", "AD9102 advanced settings applied")
|
|
self._emit_status()
|
|
|
|
def _apply_ad9833_impl(self, enabled: bool, triangle: bool, frequency_hz: int) -> None:
|
|
frequency_word = self._controller.configure_ad9833_frequency(
|
|
enabled=enabled,
|
|
triangle=triangle,
|
|
frequency_hz=frequency_hz,
|
|
)
|
|
self.log_message.emit(
|
|
"INFO",
|
|
f"AD9833 settings applied: {frequency_hz} Hz, code={frequency_word}",
|
|
)
|
|
self._emit_status()
|
|
|
|
def _pulse_ds1809_impl(self, increment: bool, count: int, pulse_ms: int) -> None:
|
|
self._controller.pulse_ds1809(
|
|
increment=increment,
|
|
count=count,
|
|
pulse_ms=pulse_ms,
|
|
)
|
|
direction = "increment" if increment else "decrement"
|
|
self.log_message.emit("INFO", f"DS1809 pulse: {direction}, count={count}, pulse={pulse_ms} ms")
|
|
self._emit_status()
|
|
|
|
def _set_stm32_dac_impl(self, enabled: bool, dac_code: int) -> None:
|
|
self._controller.set_stm32_dac(enabled=enabled, dac_code=dac_code)
|
|
self.log_message.emit("INFO", f"STM32 DAC set to code {dac_code}")
|
|
self._emit_status()
|
|
|
|
def _save_profile_impl(self, request: object) -> None:
|
|
self._controller.save_profile_to_sd(request)
|
|
profile_name = getattr(request, "profile_name", "<unnamed>")
|
|
self.log_message.emit("INFO", f"Profile saved to SD: {profile_name}")
|
|
self._emit_status()
|
|
|
|
def _upload_ad9102_waveform_impl(self, samples: object) -> None:
|
|
sample_list = list(samples)
|
|
self._controller.upload_ad9102_waveform(sample_list)
|
|
self.log_message.emit("INFO", f"AD9102 waveform uploaded ({len(sample_list)} samples)")
|
|
self._emit_status()
|
|
|
|
def _cancel_ad9102_waveform_upload_impl(self) -> None:
|
|
self._controller.cancel_ad9102_waveform_upload()
|
|
self.log_message.emit("INFO", "AD9102 waveform upload cancelled")
|
|
self._emit_status()
|
|
|
|
def _emit_status(self) -> None:
|
|
status = self._controller.get_status()
|
|
self._last_status_time = time.monotonic()
|
|
self.status_ready.emit(status)
|
|
|
|
def _ensure_connected(self) -> None:
|
|
if not self._controller.is_connected:
|
|
raise CommunicationError("Device is not connected")
|
|
|
|
def _disconnect_silently(self) -> None:
|
|
try:
|
|
if self._controller.is_connected:
|
|
self._controller.disconnect()
|
|
finally:
|
|
self.connected_changed.emit(False, "")
|