"""Serial transport and little-endian commands for the dual-laser board.""" import errno import logging import struct from functools import reduce from operator import xor import serial from .constants import BAUDRATE, SERIAL_TIMEOUT_SEC from .conversions import current_ma_to_n, temp_c_to_n from .validators import validate_manual, validate_variation from .exceptions import CommunicationError, PortBusyError, ProtocolError logger = logging.getLogger(__name__) def _command(header: int, *words: int) -> bytes: """Append XOR checksum; header is excluded, every word is uint16 LE.""" if any(not isinstance(word, int) or not 0 <= word <= 65535 for word in words): raise ValueError("Laser command words must be uint16 integers") return struct.pack(f"<{len(words) + 2}H", header, *words, reduce(xor, words, 0)) class Protocol: """Exclusive serial connection and the commands used by the application.""" def __init__(self, port: str): self.port = port self._serial: serial.Serial | None = None # ---- Connection management def connect(self) -> None: """Open the serial port. The port is opened with ``exclusive=True`` so only one program can own the laser controller at a time. This is required because the board is a single half-duplex request/response bus: if two processes opened it, their commands and responses would interleave and corrupt each other. A second opener fails fast with :class:`PortBusyError` instead of silently sharing the bus. Note the lock is advisory (``flock``): every program that opens this port must also use ``exclusive=True`` for the guarantee to hold. """ if self.is_connected: return port = self.port try: self._serial = serial.Serial( port=port, baudrate=BAUDRATE, timeout=SERIAL_TIMEOUT_SEC, exclusive=True, ) except serial.SerialException as exc: # pyserial raises SerialException with errno EWOULDBLOCK when another # process already holds the exclusive flock. if getattr(exc, "errno", None) == errno.EWOULDBLOCK or "exclusively lock" in str(exc).lower(): logger.error("Laser serial port '%s' is busy (held by another program)", port) raise PortBusyError(port) from exc logger.error("Cannot open laser serial port '%s': %s", port, exc) raise CommunicationError(f"Cannot connect to port '{port}': {exc}") from exc except Exception as exc: logger.error("Cannot open laser serial port '%s': %s", port, exc) raise CommunicationError(f"Cannot connect to port '{port}': {exc}") from exc logger.debug("Laser serial port opened (exclusive): %s @ %d baud", port, BAUDRATE) def disconnect(self) -> None: """Close the serial port if open.""" connection, self._serial = self._serial, None if connection is not None and connection.is_open: logger.debug("Closing laser serial port") connection.close() @property def is_connected(self) -> bool: return self._serial is not None and self._serial.is_open # ---- Raw I/O def send_raw(self, data: bytes) -> None: if self._serial is None or not self._serial.is_open: raise CommunicationError("Serial port is not connected") self._serial.write(data) def receive_raw(self, length: int) -> bytes: if self._serial is None or not self._serial.is_open: raise CommunicationError("Serial port is not connected") return self._serial.read(length) @staticmethod def encode_decode_enable( temp1: float, temp2: float, current1: float, current2: float, pi_coeff1_p: int, pi_coeff1_i: int, pi_coeff2_p: int, pi_coeff2_i: int, message_id: int, ) -> bytes: """DECODE_ENABLE: constant currents, temperatures and PI coefficients.""" validate_manual(temp1, temp2, current1, current2) # 0x37ff enables both channels and their regulators; SD recording is off. return _command( 0x1111, 0x37ff, temp_c_to_n(temp1), temp_c_to_n(temp2), 0, 0, 0, pi_coeff1_p, pi_coeff1_i, pi_coeff2_p, pi_coeff2_i, message_id & 0xffff, current_ma_to_n(current1), current_ma_to_n(current2), ) @staticmethod def encode_task_enable( static_temp1: float, static_temp2: float, static_current1: float, static_current2: float, min_value: float, max_value: float, step: float, time_step: int, delay_time: int, pi_coeff1_p: int = 1, pi_coeff1_i: int = 1, pi_coeff2_p: int = 1, pi_coeff2_i: int = 1, variation_type: str = "CHANGE_CURRENT_LD1", ) -> bytes: """TASK_ENABLE: sweep LD1 current while LD2 and both temperatures stay fixed.""" if variation_type != "CHANGE_CURRENT_LD1": raise ValueError("radar.laser_control.variation.variation_type must be CHANGE_CURRENT_LD1") validate_manual(static_temp1, static_temp2, static_current1, static_current2) validate_variation(min_value, max_value, step, time_step, delay_time) low, high, delta = (current_ma_to_n(value) for value in (min_value, max_value, step)) if delta == 0 or low >= high: raise ValueError("Current variation range and step must be representable by the DAC") return _command( 0x7777, 0x37ff, 1, low, high, delta, int(time_step * 100), temp_c_to_n(static_temp1), current_ma_to_n(static_current2), temp_c_to_n(static_temp2), int(delay_time), pi_coeff1_p, pi_coeff1_i, pi_coeff2_p, pi_coeff2_i, ) @staticmethod def decode_state(data: bytes) -> int: """STATE is a two-byte error bitmask; zero acknowledges the command.""" if len(data) != 2: raise ProtocolError(f"Expected 2 STATE bytes, got {len(data)}") return int.from_bytes(data, "little")