""" 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. Args: port: Serial port (e.g., '/dev/ttyUSB0' or 'COM5') timeout: Serial timeout in seconds """ 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. Args: port: Serial port (uses stored port if None) Returns: True if connection successful """ 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. Args: command: ASCII command string (without CR) Returns: True if sent successfully """ 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. Args: frequency: Frequency in Hz (16700 - 125000) Returns: True if successful """ 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. Args: current: Current in mA (0 - 2000 for this model) Returns: True if successful """ 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. Args: mode: PulseMode enumeration value Returns: True if successful """ command = f"LDG {mode.value}" return self._send_command(command) def set_laser_enable(self, enable: bool) -> bool: """ Enable or disable laser emission. Args: enable: True to enable, False to disable Returns: True if successful """ 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). Args: enable: True to activate remote enable, False to deactivate Returns: True if successful """ 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.