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)