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