Files
2026-10-06 19:36:00 +03:00

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")