Files
scanengine-3/hardware/helios_laser.py
T
Thomas Ales 709dc529df Phase 6: strip signature-restating docstrings; correct README/SETUP
- Collapsed Args:/Returns:/Raises: blocks that only restated the
  signature (364 lines): tektronix_base 48% -> ~20% doc density,
  helios_laser and uc480_camera likewise. Only docstrings whose entire
  body was those sections were touched.
- Preserved verbatim the comments that carry hardware knowledge the code
  can't express: uc480's USB split-transaction contention note (with its
  measured fps), the IS_ALLOW_STARTER_FW_UPLOAD segfault explanation, the
  QImage-copy rationale, and tektronix's NUMFRAMESACQuired warning.
- README: project structure, quick start, and every usage example now
  describe code that exists (they referenced hardware/bbd202.py,
  CoherentHOPSLaser, get_curve_binary, and 'python -m scanengine.app',
  none of which do). Added a headless-scan example and a read-a-scan-file
  example, since reuse without the GUI is the point of the refactor.
- SETUP: structure section defers to README instead of keeping a second
  stale copy; documents the vendored uEye SDK and the Genesis quarantine.
- ruff is now clean repo-wide: fixed the remaining raise-from, unused
  loop variables, placeholder f-strings, and a non-strict zip; the
  widget-layout semicolon idiom is an explicit config ignore rather than
  22 standing warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 11:27:36 -05:00

286 lines
10 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Helios Laser System Driver
Basic implementation for controlling the Helios pulsed laser.
"""
import time
import logging
from typing import Optional, List
from enum import Enum
from hardware.serial_util import open_8n1
logger = logging.getLogger(__name__)
class PulseMode(Enum):
"""Helios pulse mode enumeration"""
SINGLE_PULSE = 0
CONTINUOUS_GATING = 1
CONTINUOUS_PULSING = 2
class HeliosLaser:
"""
Driver for Helios pulsed laser system.
Communication: RS-232, 9600 baud, 8N1
Commands are ASCII strings terminated with CR
"""
def __init__(self, port: str = None, timeout: float = 1.0):
"""Initialize Helios laser driver."""
self.port = port
self.timeout = timeout
self.serial = None
self.is_connected = False
@staticmethod
def list_available_ports() -> List[str]:
"""List available serial ports, likeliest devices first."""
from hardware.serial_util import list_port_devices
return list_port_devices()
def connect(self, port: str = None) -> bool:
"""Connect to the Helios laser."""
if port:
self.port = port
if not self.port:
logger.error("No port specified")
return False
try:
self.serial = open_8n1(self.port, baudrate=9600, timeout=self.timeout)
time.sleep(0.1) # Allow time for connection to stabilize
self.is_connected = True
logger.info(f"Connected to Helios laser on {self.port}")
return True
except Exception as e:
logger.error(f"Failed to connect to Helios laser: {e}")
self.is_connected = False
return False
def disconnect(self):
"""Disconnect from the laser"""
if self.serial and self.serial.is_open:
try:
# Disable laser before disconnecting
self.set_laser_enable(False)
self.serial.close()
logger.info("Disconnected from Helios laser")
except Exception as e:
logger.error(f"Error during disconnect: {e}")
self.is_connected = False
self.serial = None
def _send_command(self, command: str) -> bool:
"""Send a command to the laser."""
if not self.is_connected or not self.serial:
logger.error("Not connected to laser")
return False
try:
cmd_bytes = (command + '\r').encode('ascii')
self.serial.write(cmd_bytes)
logger.debug(f"Sent command: {command}")
return True
except Exception as e:
logger.error(f"Failed to send command '{command}': {e}")
return False
def _query(self, command: str) -> Optional[str]:
"""Send a query and return the value from its response.
Reads until the CR terminator rather than sleeping a fixed interval:
the device usually answers in a few ms, so the old unconditional
0.05 + 0.2 s cost ~250 ms per query and made an 8-query status poll
take ~2 s — longer than the 1 s interval that scheduled it.
"""
try:
# Clear any stale bytes so a previous timed-out reply can't be
# mistaken for this command's response.
self.serial.reset_input_buffer()
if not self._send_command(command):
return None
response = self.serial.read_until(b'\r').decode('ascii', errors='replace').strip()
if not response:
logger.warning(f"Query '{command}' timed out after {self.timeout}s")
return None
logger.debug(f"Query '{command}' response: {response}")
# Helios format: "COMMAND = VALUE UNIT" — take just the value
if '=' in response:
parts = response.split('=')
if len(parts) >= 2:
value_part = parts[1].strip()
# Strip the unit suffix if present (e.g. "ns", "mA", "mW")
fields = value_part.split()
if fields:
return fields[0]
return response
except Exception as e:
logger.error(f"Failed to read response for '{command}': {e}")
return None
def _query_int(self, command: str) -> Optional[int]:
"""Query a value that should parse as an int; None if absent/unparseable."""
raw = self._query(command)
if raw is None:
return None
try:
return int(raw)
except ValueError:
logger.error(f"Query '{command}' returned non-integer {raw!r}")
return None
def set_frequency_hz(self, frequency: int) -> bool:
"""Set laser pulse frequency in Hz."""
if not (16700 <= frequency <= 125000):
logger.error(f"Frequency {frequency} Hz out of range (16700-125000)")
return False
# Convert frequency to period in nanoseconds
period_ns = int(1e9 / frequency)
# Clamp to valid range (8000-60000 ns)
if not (8000 <= period_ns <= 60000):
logger.error(f"Period {period_ns} ns out of range (8000-60000)")
return False
command = f"LDF {period_ns}"
return self._send_command(command)
def set_current_ma(self, current: int) -> bool:
"""Set pump diode current in mA."""
if not (0 <= current <= 2000):
logger.error(f"Current {current} mA out of range (0-2000)")
return False
command = f"LDS {current}"
return self._send_command(command)
def set_pulse_mode(self, mode: PulseMode) -> bool:
"""Set pulse mode."""
command = f"LDG {mode.value}"
return self._send_command(command)
def set_laser_enable(self, enable: bool) -> bool:
"""Enable or disable laser emission."""
command = f"LDO {1 if enable else 0}"
success = self._send_command(command)
if success:
state = "enabled" if enable else "disabled"
logger.info(f"Laser {state}")
return success
def is_laser_enabled(self) -> bool:
"""True if laser emission is currently enabled."""
return self._query_int("LDO") == 1
def get_frequency_hz(self) -> Optional[int]:
"""Current laser frequency in Hz, or None on error."""
period_ns = self._query_int("LDF")
if not period_ns:
return None
return int(1e9 / period_ns)
def get_current_ma(self) -> Optional[int]:
"""Current pump diode current in mA, or None on error."""
return self._query_int("LDS")
def _query_millicelsius(self, command: str) -> Optional[float]:
"""Query a temperature register (milli-°C) and convert to °C."""
value = self._query_int(command)
return None if value is None else value / 1000.0
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]:
"""Controller serial number, or None on error."""
return self._query("CSR")
def get_head_serial(self) -> Optional[str]:
"""Laser head serial number, or None on error."""
return self._query("HSR")
def get_status_registers(self) -> tuple:
"""Query the LER, LCE and CCE status registers.
Each is a bitmask (sum of flags); non-zero means active faults,
cleared with reset_faults(). Returns (ler, lce, cce), any of which
is None if that register could not be read.
"""
return (self._query_int("LER"), self._query_int("LCE"),
self._query_int("CCE"))
def reset_faults(self) -> bool:
"""
Execute the controller reset sequence to clear status registers.
Protocol-specified sequence: CCE 0 -> LCE 0 -> LER 0
Returns:
True if all three commands sent successfully
"""
ok = True
ok = self._send_command("CCE 0") and ok
time.sleep(0.1)
ok = self._send_command("LCE 0") and ok
time.sleep(0.1)
ok = self._send_command("LER 0") and ok
if ok:
logger.info("Fault reset sequence sent")
return ok
def get_remote_enable(self) -> Optional[bool]:
"""Remote enable state (LRE — utility connector pin 8); None on error."""
value = self._query_int("LRE")
return None if value is None else value == 1
def send_raw_command(self, command: str) -> Optional[str]:
"""Send a raw command and return the unparsed response (diagnostics)."""
if not self.is_connected or not self.serial:
logger.error("Not connected to laser")
return None
try:
self.serial.reset_input_buffer()
if not self._send_command(command):
return None
raw = self.serial.read_until(b'\r')
if not raw:
raw = self.serial.read(self.serial.in_waiting)
return raw.decode('ascii', errors='replace').strip()
except Exception as e:
logger.error(f"send_raw_command error: {e}")
return None
def set_remote_enable(self, enable: bool) -> bool:
"""Set the remote enable state (LRE - utility connector pin 8)."""
command = f"LRE {1 if enable else 0}"
return self._send_command(command)
# No __del__: it used to call disconnect(), which disables the laser and
# writes to the serial port from the garbage collector at an
# unpredictable time (including interpreter shutdown, when the port may
# already be torn down). Callers close the driver explicitly.