132 lines
5.8 KiB
Python
132 lines
5.8 KiB
Python
"""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, variation_codes
|
|
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 = variation_codes(min_value, max_value, step)
|
|
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")
|