From 57f50a733dcdf070a52c3ca7f34caa20a3520073 Mon Sep 17 00:00:00 2001 From: "Thomas Ales [M S E]" Date: Thu, 28 May 2026 18:29:24 -0500 Subject: [PATCH] pre local llm test --- aui_defaults.json | 3 +- hardware/helios_laser.py | 27 ++- sc3-aui-camera.ui | 18 ++ sc3-aui-main.ui | 10 + sc3_aui_app.py | 408 ++++++++++++++++++++++++++++++++++++--- 5 files changed, 433 insertions(+), 33 deletions(-) diff --git a/aui_defaults.json b/aui_defaults.json index 3f31259..00dec6a 100644 --- a/aui_defaults.json +++ b/aui_defaults.json @@ -3,5 +3,6 @@ "bbd_port": "/dev/ttyAPT", "oscope_ip": "192.168.100.105", "laser_freq_hz": 20000.0, - "save_dir": "/opt/scanengine-3/scans" + "save_dir": "/opt/scanengine-3/scans", + "helios_port": "/dev/ttyUSB0" } \ No newline at end of file diff --git a/hardware/helios_laser.py b/hardware/helios_laser.py index 99b5625..f7ac118 100644 --- a/hardware/helios_laser.py +++ b/hardware/helios_laser.py @@ -280,21 +280,28 @@ class HeliosLaser: logger.error(f"Invalid response for LDS: {response}") return None - def get_power_mw(self) -> Optional[float]: - """ - Get laser output power in mW. - - Returns: - Power in mW or None if error - """ - response = self._query("HMP") + def _query_millicelsius(self, command: str) -> Optional[float]: + """Query a temperature register (returns milli-°C) and convert to °C.""" + response = self._query(command) if response: try: - return float(response) + return int(response) / 1000.0 except ValueError: - logger.error(f"Invalid response for HMP: {response}") + logger.error(f"Invalid response for {command}: {response}") return None + def get_diode_temp_c(self) -> Optional[float]: + """Diode temperature in °C (LTA, 5000–50000 milli-°C).""" + return self._query_millicelsius("LTA") + + def get_power_stage_temp_c(self) -> Optional[float]: + """Power stage temperature in °C (LTT, 5000–65355 milli-°C).""" + return self._query_millicelsius("LTT") + + def get_qswitch_temp_c(self) -> Optional[float]: + """Q-switch temperature in °C (EOA, 5000–50000 milli-°C).""" + return self._query_millicelsius("EOA") + def get_controller_serial(self) -> Optional[str]: """ Get controller serial number. diff --git a/sc3-aui-camera.ui b/sc3-aui-camera.ui index fc89452..f7404d4 100644 --- a/sc3-aui-camera.ui +++ b/sc3-aui-camera.ui @@ -60,6 +60,24 @@ + + + + + + Start Stream + + + + + + + Stop Stream + + + + + diff --git a/sc3-aui-main.ui b/sc3-aui-main.ui index ff26821..21abbac 100644 --- a/sc3-aui-main.ui +++ b/sc3-aui-main.ui @@ -897,6 +897,16 @@ + + + + Show Helios Panel + + + true + + + diff --git a/sc3_aui_app.py b/sc3_aui_app.py index 6d18b3f..aaa299e 100755 --- a/sc3_aui_app.py +++ b/sc3_aui_app.py @@ -26,6 +26,7 @@ from PyQt6.QtWidgets import ( ROOT = Path(__file__).parent sys.path.insert(0, str(ROOT)) +from hardware.helios_laser import HeliosLaser from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver from hardware.tektronix_base import TektronixOscilloscopeBase from hardware.uc480_camera import CameraStreamThread, UC480Camera @@ -34,11 +35,12 @@ from hardware.uc480_camera import CameraStreamThread, UC480Camera _AUI_DEFAULTS_PATH = ROOT / "aui_defaults.json" _AUI_DEFAULTS_FALLBACK = { - "t3r_port": "/dev/ttyUSB0", - "bbd_port": "/dev/ttyUSB1", - "oscope_ip": "192.168.0.1", + "t3r_port": "/dev/ttyUSB0", + "bbd_port": "/dev/ttyUSB1", + "oscope_ip": "192.168.0.1", "laser_freq_hz": 2000, - "save_dir": str(ROOT / "scans"), + "save_dir": str(ROOT / "scans"), + "helios_port": "/dev/ttyUSB2", } def _load_aui_defaults() -> dict: @@ -66,6 +68,7 @@ DEFAULT_BBD_PORT = _aui["bbd_port"] DEFAULT_OSCOPE_IP = _aui["oscope_ip"] DEFAULT_LASER_FREQ_HZ = float(_aui["laser_freq_hz"]) DEFAULT_SAVE_DIR = _aui["save_dir"] +DEFAULT_HELIOS_PORT = _aui.get("helios_port", "/dev/ttyUSB2") # ── Scan constants ─────────────────────────────────────────────────────────── @@ -512,7 +515,7 @@ class OscopeWorker(QObject): # ── CH1 — RF Acoustic Packet ────────────────────────────────────────── s.set_channel_label_name(1, "RF Acoustic Packet") - s.set_channel_scale(1, 0.05) # 100 mV/div + s.set_channel_scale(1, 0.07) # 70 mV/div s.set_channel_position(1, 0.0) # 0 divs s.set_channel_termination(1, 50) # 50 Ohm s.set_channel_coupling(1, "DC") @@ -536,7 +539,7 @@ class OscopeWorker(QObject): # ── CH4 — Bias B ────────────────────────────────────────────────────── s.set_channel_label_name(4, "Bias - B") - s.set_channel_scale(4, 0.1) # 50 mV/div + s.set_channel_scale(4, 0.1) # 100 mV/div s.set_channel_position(4, -2.72) # -2.72 divs s.set_channel_termination(4, 1000000) # 1 Mohm s.set_channel_coupling(4, "DC") @@ -562,11 +565,167 @@ class OscopeWorker(QObject): self._cmd_q.put({"type": "stop"}) +# ── Helios laser worker ─────────────────────────────────────────────────────── + +class HeliosWorker(QObject): + """Manages HeliosLaser in a worker thread using a command queue.""" + connected = pyqtSignal() + disconnected = pyqtSignal() + connection_failed = pyqtSignal(str) + enabled_updated = pyqtSignal(bool) + current_updated = pyqtSignal(int) + diode_temp_updated = pyqtSignal(float) + pstage_temp_updated = pyqtSignal(float) + qswitch_temp_updated = pyqtSignal(float) + status_registers_updated = pyqtSignal(object, object, object) # LER, LCE, CCE (int|None) + error_occurred = pyqtSignal(str) + + def __init__(self): + super().__init__() + self._laser: HeliosLaser | None = None + self._cmd_q: queue.Queue = queue.Queue() + self._running = False + self.is_connected = False + + @pyqtSlot() + def run(self): + self._running = True + while self._running: + try: + cmd = self._cmd_q.get(timeout=0.1) + self._dispatch(cmd) + except queue.Empty: + pass + if self._laser: + try: + self._laser.disconnect() + except Exception: + pass + + def _dispatch(self, cmd: dict): + t = cmd["type"] + if t == "connect": + self._do_connect(cmd["port"]) + elif t == "disconnect": + self._do_disconnect() + elif t == "set_enable": + self._do_set_enable(cmd["enable"]) + elif t == "set_current": + self._do_set_current(cmd["current_ma"]) + elif t == "poll_status": + self._do_poll_status() + elif t == "stop": + self._running = False + + def _do_connect(self, port: str): + try: + self._laser = HeliosLaser(timeout=1.0) + if not self._laser.connect(port): + self._laser = None + self.connection_failed.emit("Failed to open serial port") + return + self.is_connected = True + self.connected.emit() + self._do_poll_status() + except Exception as e: + self._laser = None + self.connection_failed.emit(str(e)) + + def _do_disconnect(self): + if self._laser: + try: + self._laser.set_laser_enable(False) + except Exception: + pass + try: + self._laser.disconnect() + except Exception: + pass + self._laser = None + self.is_connected = False + self.disconnected.emit() + + def _do_set_enable(self, enable: bool): + if not self._laser: + return + try: + self._laser.set_laser_enable(enable) + self.enabled_updated.emit(self._laser.is_laser_enabled()) + except Exception as e: + self.error_occurred.emit(str(e)) + + def _do_set_current(self, current_ma: int): + if not self._laser: + return + try: + self._laser.set_current_ma(current_ma) + self.current_updated.emit(current_ma) + except Exception as e: + self.error_occurred.emit(str(e)) + + def _do_poll_status(self): + if not self._laser or not self._laser.is_connected: + return + try: + ler, lce, cce = self._laser.get_status_registers() + self.status_registers_updated.emit(ler, lce, cce) + except Exception: + pass + try: + self.enabled_updated.emit(self._laser.is_laser_enabled()) + except Exception: + pass + try: + current = self._laser.get_current_ma() + if current is not None: + self.current_updated.emit(current) + except Exception: + pass + try: + t = self._laser.get_diode_temp_c() + if t is not None: + self.diode_temp_updated.emit(t) + except Exception: + pass + try: + t = self._laser.get_power_stage_temp_c() + if t is not None: + self.pstage_temp_updated.emit(t) + except Exception: + pass + try: + t = self._laser.get_qswitch_temp_c() + if t is not None: + self.qswitch_temp_updated.emit(t) + except Exception: + pass + + def queue_connect(self, port: str): + self._cmd_q.put({"type": "connect", "port": port}) + + def queue_disconnect(self): + self._cmd_q.put({"type": "disconnect"}) + + def queue_set_enable(self, enable: bool): + self._cmd_q.put({"type": "set_enable", "enable": enable}) + + def queue_set_current(self, current_ma: int): + self._cmd_q.put({"type": "set_current", "current_ma": current_ma}) + + def queue_poll_status(self): + self._cmd_q.put({"type": "poll_status"}) + + def stop_worker(self): + self._cmd_q.put({"type": "stop"}) + + # ── Camera window popup ─────────────────────────────────────────────────────── class CameraWindow(QWidget): """Camera display popup — auto-connects on show, auto-disconnects on close.""" + closed = pyqtSignal() + def __init__(self, parent: QWidget | None = None): super().__init__(parent, Qt.WindowType.Window) uic.loadUi(ROOT / "sc3-aui-camera.ui", self) @@ -590,18 +749,25 @@ class CameraWindow(QWidget): self.uc480_exposure_slider.valueChanged.connect(self._set_exposure) self.uc480_gain_slider.valueChanged.connect(self._set_gain) + self.uc480_start_btn.clicked.connect(self._start_stream) + self.uc480_stop_btn.clicked.connect(self._stop_stream) self.uc480_close_window_btn.clicked.connect(self.close) + self._update_controls() + def showEvent(self, event): super().showEvent(event) - self._start_camera() + self._connect_camera() + self._start_stream() def closeEvent(self, event): - self._stop_camera() + self._stop_stream() + self._disconnect_camera() super().closeEvent(event) + self.closed.emit() - def _start_camera(self): - if self._stream is not None: + def _connect_camera(self): + if self._camera is not None: return try: self._camera = UC480Camera() @@ -610,22 +776,33 @@ class CameraWindow(QWidget): self.uc480_exposure_slider.blockSignals(True) self.uc480_exposure_slider.setValue(max(1, round(exp_ms * 10))) self.uc480_exposure_slider.blockSignals(False) - self._stream = CameraStreamThread(self._camera) - self._stream.frame_ready.connect(self._on_frame) - self._stream.start() except Exception as e: - QMessageBox.warning(self, "Camera Error", f"Could not open camera:\n{e}") + self._camera = None + self._frame_label.setText(f"Camera error:\n{e}") + self._update_controls() - def _stop_camera(self): - if self._stream: - self._stream.stop() - self._stream = None + def _disconnect_camera(self): if self._camera: try: self._camera.cleanup() except Exception: pass self._camera = None + self._update_controls() + + def _start_stream(self): + if self._stream is not None or self._camera is None: + return + self._stream = CameraStreamThread(self._camera) + self._stream.frame_ready.connect(self._on_frame) + self._stream.start() + self._update_controls() + + def _stop_stream(self): + if self._stream: + self._stream.stop() + self._stream = None + self._update_controls() def _on_frame(self, image: QImage): px = QPixmap.fromImage(image).scaled( @@ -650,6 +827,162 @@ class CameraWindow(QWidget): except Exception: pass + def _update_controls(self): + camera_ok = self._camera is not None + streaming = self._stream is not None + self.uc480_start_btn.setEnabled(camera_ok and not streaming) + self.uc480_stop_btn.setEnabled(streaming) + self.uc480_exposure_slider.setEnabled(camera_ok) + self.uc480_gain_slider.setEnabled(camera_ok) + + +# ── Helios laser panel ──────────────────────────────────────────────────────── + +class HeliosWindow(QWidget): + """Helios laser control panel — shown/hidden with a toggle in the main window.""" + + closed = pyqtSignal() + + def __init__(self, worker: HeliosWorker, parent: QWidget | None = None): + super().__init__(parent, Qt.WindowType.Window) + uic.loadUi(ROOT / "sc3-aui-helios.ui", self) + self.setWindowTitle("Helios Laser") + + self._worker = worker + + self._poll_timer = QTimer(self) + self._poll_timer.setInterval(1000) + self._poll_timer.timeout.connect(self._worker.queue_poll_status) + + self._wire_signals() + self._update_controls(False) + self._set_emission_indicator(False) + + def closeEvent(self, event): + self._poll_timer.stop() + if self._worker.is_connected: + self._worker.queue_disconnect() + super().closeEvent(event) + self.closed.emit() + + def _wire_signals(self): + self.helios_connect_toggle.toggled.connect(self._on_connect_toggle) + self._worker.connected.connect(self._on_connected) + self._worker.disconnected.connect(self._on_disconnected) + self._worker.connection_failed.connect(self._on_connection_failed) + + self.helios_enable_btn.toggled.connect(self._on_enable_toggle) + self._worker.enabled_updated.connect(self._on_enabled_updated) + + self.helios_set_current_btn.clicked.connect(self._on_set_current) + self._worker.current_updated.connect(self._on_current_updated) + self._worker.diode_temp_updated.connect( + lambda t: self.helios_temp_diode_label.setText(f"{t:.1f} °C") + ) + self._worker.pstage_temp_updated.connect( + lambda t: self.helios_temp_pstage_label.setText(f"{t:.1f} °C") + ) + self._worker.qswitch_temp_updated.connect( + lambda t: self.helios_temp_qswitch_label.setText(f"{t:.1f} °C") + ) + self._worker.status_registers_updated.connect(self._on_status_registers) + self._worker.error_occurred.connect( + lambda m: self.helios_status_label.setText(m) + ) + + def _on_connect_toggle(self, checked: bool): + if checked: + self.helios_connect_toggle.setText("Connecting…") + self.helios_connect_toggle.setEnabled(False) + port = self.helios_port_edit.text().strip() + d = _load_aui_defaults() + d["helios_port"] = port + _save_aui_defaults(d) + self._worker.queue_connect(port) + else: + self._poll_timer.stop() + self._worker.queue_disconnect() + + def _on_connected(self): + self.helios_connect_toggle.setEnabled(True) + self.helios_connect_toggle.setText("Disconnect") + self.helios_status_label.setText("Connected") + self._update_controls(True) + self._poll_timer.start() + + def _on_disconnected(self): + self._poll_timer.stop() + self.helios_connect_toggle.blockSignals(True) + self.helios_connect_toggle.setChecked(False) + self.helios_connect_toggle.setText("Connect") + self.helios_connect_toggle.setEnabled(True) + self.helios_connect_toggle.blockSignals(False) + self.helios_status_label.setText("Disconnected") + self._update_controls(False) + + def _on_connection_failed(self, msg: str): + self.helios_connect_toggle.blockSignals(True) + self.helios_connect_toggle.setChecked(False) + self.helios_connect_toggle.setText("Connect") + self.helios_connect_toggle.setEnabled(True) + self.helios_connect_toggle.blockSignals(False) + self.helios_status_label.setText(f"Failed: {msg}") + self._update_controls(False) + + def _set_emission_indicator(self, enabled: bool): + if enabled: + self.helios_emission_indicator.setStyleSheet( + "background-color: #ff2222; border-radius: 10px;" + ) + else: + self.helios_emission_indicator.setStyleSheet( + "background-color: #444444; border-radius: 10px;" + ) + + def _on_enable_toggle(self, checked: bool): + self.helios_enable_btn.setText("Disable Laser" if checked else "Enable Laser") + self._worker.queue_set_enable(checked) + + def _on_enabled_updated(self, enabled: bool): + self.helios_enable_btn.blockSignals(True) + self.helios_enable_btn.setChecked(enabled) + self.helios_enable_btn.setText("Disable Laser" if enabled else "Enable Laser") + self.helios_enable_btn.blockSignals(False) + self._set_emission_indicator(enabled) + + def _on_set_current(self): + self._worker.queue_set_current(self.helios_current_spin.value()) + + def _on_current_updated(self, current_ma: int): + self.helios_current_spin.blockSignals(True) + self.helios_current_spin.setValue(current_ma) + self.helios_current_spin.blockSignals(False) + + def _reset_temperatures(self): + self.helios_temp_diode_label.setText("--- °C") + self.helios_temp_pstage_label.setText("--- °C") + self.helios_temp_qswitch_label.setText("--- °C") + + def _on_status_registers(self, ler, lce, cce): + self.helios_ler_label.setText(f"0x{ler:04X}" if ler is not None else "---") + self.helios_lce_label.setText(f"0x{lce:04X}" if lce is not None else "---") + self.helios_cce_label.setText(f"0x{cce:04X}" if cce is not None else "---") + + def _update_controls(self, connected: bool): + self.helios_enable_btn.setEnabled(connected) + self.helios_current_spin.setEnabled(connected) + self.helios_set_current_btn.setEnabled(connected) + if not connected: + self.helios_enable_btn.blockSignals(True) + self.helios_enable_btn.setChecked(False) + self.helios_enable_btn.setText("Enable Laser") + self.helios_enable_btn.blockSignals(False) + self._set_emission_indicator(False) + self._reset_temperatures() + self.helios_ler_label.setText("---") + self.helios_lce_label.setText("---") + self.helios_cce_label.setText("---") + # ── Scan progress popup ─────────────────────────────────────────────────────── @@ -1013,8 +1346,14 @@ class MainWindow(QMainWindow): self._oscope_worker.moveToThread(self._oscope_thread) self._oscope_thread.started.connect(self._oscope_worker.run) + self._helios_thread = QThread(self) + self._helios_worker = HeliosWorker() + self._helios_worker.moveToThread(self._helios_thread) + self._helios_thread.started.connect(self._helios_worker.run) + # ── Popup windows ───────────────────────────────────────────────────── self._camera_win = CameraWindow() + self._helios_win = HeliosWindow(self._helios_worker) self._scan_progress = ScanProgressWindow() self._scan_worker: ScanWorker | None = None @@ -1038,6 +1377,7 @@ class MainWindow(QMainWindow): self._t3r_thread.start() self._bbd_thread.start() self._oscope_thread.start() + self._helios_thread.start() # ── UI initialisation ───────────────────────────────────────────────────── @@ -1045,6 +1385,7 @@ class MainWindow(QMainWindow): self.t3r_comport_edit.setText(DEFAULT_T3R_PORT) self.bbd202_comport_edit.setText(DEFAULT_BBD_PORT) self.oscope_ip_edit.setText(DEFAULT_OSCOPE_IP) + self._helios_win.helios_port_edit.setText(DEFAULT_HELIOS_PORT) self.t3r_jog_speed_edit.setText(str(GR_MOVE_SPEED_HZ)) self.lineEdit_10.setText(str(BBD_DEFAULT_JOG_MM)) @@ -1131,8 +1472,17 @@ class MainWindow(QMainWindow): # Camera self.show_camera_toggle.toggled.connect(self._on_camera_toggle) - self._camera_win.uc480_close_window_btn.clicked.connect( - lambda: self.show_camera_toggle.setChecked(False) + self._camera_win.closed.connect( + lambda: self._set_toggle(self.show_camera_toggle, False, "Show Camera Window") + ) + + # Helios + self.show_helios_toggle.toggled.connect(self._on_helios_toggle) + self._helios_win.closed.connect( + lambda: self._set_toggle(self.show_helios_toggle, False, "Show Helios Panel") + ) + self._helios_win.helios_close_btn.clicked.connect( + lambda: self.show_helios_toggle.setChecked(False) ) # Scan @@ -1345,6 +1695,16 @@ class MainWindow(QMainWindow): self.show_camera_toggle.setText("Show Camera Window") self._camera_win.close() + # ── Helios toggle ───────────────────────────────────────────────────────── + + def _on_helios_toggle(self, checked: bool): + if checked: + self.show_helios_toggle.setText("Hide Helios Panel") + self._helios_win.show() + else: + self.show_helios_toggle.setText("Show Helios Panel") + self._helios_win.close() + # ── Scan ────────────────────────────────────────────────────────────────── def _on_browse_save_dir(self): @@ -1459,7 +1819,10 @@ class MainWindow(QMainWindow): self._bbd_worker.queue_disconnect() if self._oscope_worker.is_connected: self._oscope_worker.queue_disconnect() + if self._helios_worker.is_connected: + self._helios_worker.queue_disconnect() self._camera_win.close() + self._helios_win.close() QTimer.singleShot(1500, QApplication.instance().quit) # ── Helpers ─────────────────────────────────────────────────────────────── @@ -1478,9 +1841,10 @@ class MainWindow(QMainWindow): def closeEvent(self, event): self._camera_win.close() self._scan_progress.close() - for w in (self._t3r_worker, self._bbd_worker, self._oscope_worker): + self._helios_win.close() + for w in (self._t3r_worker, self._bbd_worker, self._oscope_worker, self._helios_worker): w.stop_worker() - for t in (self._t3r_thread, self._bbd_thread, self._oscope_thread): + for t in (self._t3r_thread, self._bbd_thread, self._oscope_thread, self._helios_thread): t.quit() t.wait(2000) super().closeEvent(event)