Add new PyQt UI
This commit is contained in:
@ -1,119 +1,181 @@
|
||||
"""
|
||||
Main laser controller for the laser control module.
|
||||
"""High-level controller orchestrating protocol encoding and serial transport."""
|
||||
|
||||
Provides a high-level API for controlling dual laser systems.
|
||||
All input parameters are validated before being sent to the device.
|
||||
Can be embedded in any Python application without GUI dependencies.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Callable
|
||||
import math
|
||||
import time
|
||||
from typing import Callable, Sequence
|
||||
|
||||
from .protocol import Protocol, TaskType as ProtoTaskType
|
||||
from .validators import ParameterValidator
|
||||
from .models import (
|
||||
ManualModeParams,
|
||||
VariationParams,
|
||||
VariationType,
|
||||
Measurements,
|
||||
DeviceStatus,
|
||||
DeviceState,
|
||||
from .constants import (
|
||||
AD9102_CLOCK_HZ,
|
||||
AD9102_PAT_BASE_MAX,
|
||||
AD9102_PAT_BASE_MIN,
|
||||
AD9102_PAT_PERIOD_MAX,
|
||||
AD9102_PAT_PERIOD_MIN,
|
||||
AD9102_SAW_STEP_MAX,
|
||||
AD9102_SAW_STEP_MIN,
|
||||
AD9102_SRAM_AMPLITUDE_MAX,
|
||||
AD9102_SRAM_AMPLITUDE_MIN,
|
||||
AD9102_SRAM_HOLD_MAX,
|
||||
AD9102_SRAM_HOLD_MIN,
|
||||
AD9102_SRAM_SAMPLE_MAX,
|
||||
AD9102_SRAM_SAMPLE_MIN,
|
||||
AD9102_WAVE_MAX_CHUNK_SAMPLES,
|
||||
AD9102_WAVE_SAMPLE_MAX,
|
||||
AD9102_WAVE_SAMPLE_MIN,
|
||||
AD9833_FREQ_WORD_MAX,
|
||||
AD9833_FREQ_WORD_MIN,
|
||||
AD9833_MCLK_HZ,
|
||||
AD9833_OUTPUT_FREQ_MAX_HZ,
|
||||
AD9833_OUTPUT_FREQ_MIN_HZ,
|
||||
DEFAULT_CURRENT1_MA,
|
||||
DEFAULT_AD9102_HOLD_CYCLES,
|
||||
DEFAULT_AD9102_PAT_BASE,
|
||||
DEFAULT_AD9102_PAT_PERIOD,
|
||||
DEFAULT_CURRENT2_MA,
|
||||
DEFAULT_PI_I,
|
||||
DEFAULT_PI_P,
|
||||
DEFAULT_TEMP1_C,
|
||||
DEFAULT_TEMP2_C,
|
||||
DS1809_COUNT_MAX,
|
||||
DS1809_COUNT_MIN,
|
||||
DS1809_PULSE_MS_MAX,
|
||||
DS1809_PULSE_MS_MIN,
|
||||
GET_DATA_TOTAL_LENGTH,
|
||||
PROFILE_SAVE_DATA_CHUNK_BYTES,
|
||||
PROFILE_SAVE_SECTION_PROFILE_TEXT,
|
||||
PROFILE_SAVE_SECTION_WAVEFORM_TEXT,
|
||||
STM32_DAC_CODE_MAX,
|
||||
STM32_DAC_CODE_MIN,
|
||||
STATUS_RESPONSE_LENGTH,
|
||||
WAIT_AFTER_SEND_SEC,
|
||||
)
|
||||
from .exceptions import (
|
||||
ValidationError,
|
||||
CommunicationError,
|
||||
DeviceNotRespondingError,
|
||||
DeviceStateError,
|
||||
InvalidParameterError,
|
||||
)
|
||||
from .constants import WAIT_AFTER_SEND_SEC
|
||||
from .models import DeviceState, DeviceStatus, Measurements, ProfileSaveRequest
|
||||
from .protocol import Protocol
|
||||
from .transport import SerialTransport
|
||||
from .validators import ParameterValidator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default PI regulator coefficients (match firmware defaults)
|
||||
DEFAULT_PI_P = 2560 # 10 * 256
|
||||
DEFAULT_PI_I = 128 # 0.5 * 256
|
||||
_AD9102_SAW_RAMP_STEPS = 1 << 14
|
||||
|
||||
|
||||
def ad9102_saw_frequency_limits_hz(*, triangle: bool) -> tuple[int, int]:
|
||||
"""Return the reachable frequency range for the built-in saw generator."""
|
||||
factor = 2 if triangle else 1
|
||||
minimum = math.ceil(AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * AD9102_SAW_STEP_MAX))
|
||||
maximum = math.floor(AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * AD9102_SAW_STEP_MIN))
|
||||
return minimum, maximum
|
||||
|
||||
|
||||
def ad9102_saw_frequency_from_step_hz(*, triangle: bool, saw_step: int) -> float:
|
||||
"""Calculate the actual built-in saw/triangle frequency for a given SAW_STEP."""
|
||||
factor = 2 if triangle else 1
|
||||
saw_step = max(AD9102_SAW_STEP_MIN, min(AD9102_SAW_STEP_MAX, int(saw_step)))
|
||||
return AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * saw_step)
|
||||
|
||||
|
||||
def ad9102_saw_step_from_frequency_hz(*, triangle: bool, frequency_hz: int) -> tuple[int, float]:
|
||||
"""Map a desired built-in saw frequency to the closest supported SAW_STEP."""
|
||||
min_hz, max_hz = ad9102_saw_frequency_limits_hz(triangle=triangle)
|
||||
if frequency_hz < min_hz or frequency_hz > max_hz:
|
||||
raise InvalidParameterError(
|
||||
f"frequency_hz must be in range [{min_hz}, {max_hz}] for this AD9102 mode"
|
||||
)
|
||||
factor = 2 if triangle else 1
|
||||
saw_step = round(AD9102_CLOCK_HZ / (_AD9102_SAW_RAMP_STEPS * factor * frequency_hz))
|
||||
saw_step = max(AD9102_SAW_STEP_MIN, min(AD9102_SAW_STEP_MAX, saw_step))
|
||||
return saw_step, ad9102_saw_frequency_from_step_hz(triangle=triangle, saw_step=saw_step)
|
||||
|
||||
|
||||
def ad9102_sram_frequency_limits_hz(*, hold_cycles: int = DEFAULT_AD9102_HOLD_CYCLES) -> tuple[int, int]:
|
||||
"""Return the reachable frequency range for SRAM playback for a fixed hold setting."""
|
||||
hold = hold_cycles or DEFAULT_AD9102_HOLD_CYCLES
|
||||
minimum = math.ceil(AD9102_CLOCK_HZ / (AD9102_SRAM_SAMPLE_MAX * hold))
|
||||
maximum = math.floor(AD9102_CLOCK_HZ / (AD9102_SRAM_SAMPLE_MIN * hold))
|
||||
return minimum, maximum
|
||||
|
||||
|
||||
def ad9102_sram_frequency_from_playback_hz(*, sample_count: int, hold_cycles: int) -> float:
|
||||
"""Calculate the actual SRAM playback frequency."""
|
||||
sample_count = max(AD9102_SRAM_SAMPLE_MIN, min(AD9102_SRAM_SAMPLE_MAX, int(sample_count)))
|
||||
hold = hold_cycles or DEFAULT_AD9102_HOLD_CYCLES
|
||||
hold = max(DEFAULT_AD9102_HOLD_CYCLES, min(AD9102_SRAM_HOLD_MAX, int(hold)))
|
||||
return AD9102_CLOCK_HZ / (sample_count * hold)
|
||||
|
||||
|
||||
def ad9102_sram_sample_count_from_frequency_hz(
|
||||
*,
|
||||
frequency_hz: int,
|
||||
hold_cycles: int = DEFAULT_AD9102_HOLD_CYCLES,
|
||||
) -> tuple[int, float]:
|
||||
"""Map a desired SRAM playback frequency to the closest supported sample count."""
|
||||
min_hz, max_hz = ad9102_sram_frequency_limits_hz(hold_cycles=hold_cycles)
|
||||
if frequency_hz < min_hz or frequency_hz > max_hz:
|
||||
raise InvalidParameterError(
|
||||
f"frequency_hz must be in range [{min_hz}, {max_hz}] for this AD9102 mode"
|
||||
)
|
||||
hold = hold_cycles or DEFAULT_AD9102_HOLD_CYCLES
|
||||
sample_count = round(AD9102_CLOCK_HZ / (frequency_hz * hold))
|
||||
sample_count = max(AD9102_SRAM_SAMPLE_MIN, min(AD9102_SRAM_SAMPLE_MAX, sample_count))
|
||||
return sample_count, ad9102_sram_frequency_from_playback_hz(
|
||||
sample_count=sample_count,
|
||||
hold_cycles=hold,
|
||||
)
|
||||
|
||||
|
||||
class LaserController:
|
||||
"""
|
||||
High-level controller for the dual laser board.
|
||||
|
||||
Usage example::
|
||||
|
||||
ctrl = LaserController(port='/dev/ttyUSB0')
|
||||
ctrl.connect()
|
||||
ctrl.set_manual_mode(temp1=25.0, temp2=30.0,
|
||||
current1=40.0, current2=35.0)
|
||||
data = ctrl.get_measurements()
|
||||
print(data.voltage_3v3)
|
||||
ctrl.disconnect()
|
||||
|
||||
All public methods raise :class:`ValidationError` for bad parameters
|
||||
and :class:`CommunicationError` for transport-level problems.
|
||||
"""
|
||||
"""Public API for manual control, polling, and status queries."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: Optional[str] = None,
|
||||
port: str | None = None,
|
||||
pi_coeff1_p: int = DEFAULT_PI_P,
|
||||
pi_coeff1_i: int = DEFAULT_PI_I,
|
||||
pi_coeff2_p: int = DEFAULT_PI_P,
|
||||
pi_coeff2_i: int = DEFAULT_PI_I,
|
||||
on_data: Optional[Callable[[Measurements], None]] = None,
|
||||
):
|
||||
"""
|
||||
Args:
|
||||
port: Serial port (e.g. '/dev/ttyUSB0'). None = auto-detect.
|
||||
pi_coeff1_p: Proportional coefficient for laser 1 PI regulator.
|
||||
pi_coeff1_i: Integral coefficient for laser 1 PI regulator.
|
||||
pi_coeff2_p: Proportional coefficient for laser 2 PI regulator.
|
||||
pi_coeff2_i: Integral coefficient for laser 2 PI regulator.
|
||||
on_data: Optional callback called whenever new measurements
|
||||
are received. Signature: ``callback(Measurements)``.
|
||||
"""
|
||||
self._protocol = Protocol(port)
|
||||
on_data: Callable[[Measurements], None] | None = None,
|
||||
) -> None:
|
||||
self._transport = SerialTransport(port=port)
|
||||
self._pi1_p = pi_coeff1_p
|
||||
self._pi1_i = pi_coeff1_i
|
||||
self._pi2_p = pi_coeff2_p
|
||||
self._pi2_i = pi_coeff2_i
|
||||
self._on_data = on_data
|
||||
self._message_id = 0
|
||||
self._last_measurements: Optional[Measurements] = None
|
||||
# Last manual-mode params, used to restore state after stop_task()
|
||||
self._last_temp1: float = 25.0
|
||||
self._last_temp2: float = 25.0
|
||||
self._last_current1: float = 30.0
|
||||
self._last_current2: float = 30.0
|
||||
|
||||
# ---- Connection -------------------------------------------------------
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
Open connection to the device.
|
||||
|
||||
Returns:
|
||||
True if connection succeeded.
|
||||
|
||||
Raises:
|
||||
CommunicationError: If the port cannot be opened.
|
||||
"""
|
||||
self._protocol.connect()
|
||||
logger.info("Connected to laser controller on port %s",
|
||||
self._protocol._port_name or "auto")
|
||||
return True
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Close the serial port gracefully."""
|
||||
self._protocol.disconnect()
|
||||
logger.info("Disconnected from laser controller")
|
||||
self._last_measurements: Measurements | None = None
|
||||
self._last_temp1 = DEFAULT_TEMP1_C
|
||||
self._last_temp2 = DEFAULT_TEMP2_C
|
||||
self._last_current1 = DEFAULT_CURRENT1_MA
|
||||
self._last_current2 = DEFAULT_CURRENT2_MA
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""True if the serial port is open."""
|
||||
return self._protocol.is_connected
|
||||
"""Return True when the serial port is connected."""
|
||||
return self._transport.is_connected
|
||||
|
||||
# ---- Public API -------------------------------------------------------
|
||||
@property
|
||||
def port_name(self) -> str | None:
|
||||
"""Return the active serial port name when available."""
|
||||
return self._transport.port_name
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""Open the configured serial connection."""
|
||||
self._transport.connect()
|
||||
logger.info("Connected to laser controller on port %s", self.port_name)
|
||||
return True
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Close the serial connection."""
|
||||
self._transport.disconnect()
|
||||
logger.info("Disconnected from laser controller")
|
||||
|
||||
def set_manual_mode(
|
||||
self,
|
||||
@ -122,262 +184,413 @@ class LaserController:
|
||||
current1: float,
|
||||
current2: float,
|
||||
) -> None:
|
||||
"""
|
||||
Set manual control parameters for both lasers.
|
||||
|
||||
Args:
|
||||
temp1: Setpoint temperature for laser 1, °C.
|
||||
Valid range: [15.0 … 40.0] °C.
|
||||
temp2: Setpoint temperature for laser 2, °C.
|
||||
Valid range: [15.0 … 40.0] °C.
|
||||
current1: Drive current for laser 1, mA.
|
||||
Valid range: [15.0 … 60.0] mA.
|
||||
current2: Drive current for laser 2, mA.
|
||||
Valid range: [15.0 … 60.0] mA.
|
||||
|
||||
Raises:
|
||||
ValidationError: If any parameter is out of range.
|
||||
CommunicationError: If the command cannot be sent.
|
||||
"""
|
||||
validated = ParameterValidator.validate_manual_mode_params(
|
||||
temp1, temp2, current1, current2
|
||||
"""Send manual setpoints and remember them for post-reset restore."""
|
||||
values = ParameterValidator.validate_manual_mode_params(
|
||||
temp1=temp1,
|
||||
temp2=temp2,
|
||||
current1=current1,
|
||||
current2=current2,
|
||||
)
|
||||
self._message_id = (self._message_id + 1) & 0xFFFF
|
||||
|
||||
cmd = Protocol.encode_decode_enable(
|
||||
temp1=validated['temp1'],
|
||||
temp2=validated['temp2'],
|
||||
current1=validated['current1'],
|
||||
current2=validated['current2'],
|
||||
command = Protocol.encode_decode_enable(
|
||||
temp1=values["temp1"],
|
||||
temp2=values["temp2"],
|
||||
current1=values["current1"],
|
||||
current2=values["current2"],
|
||||
pi_coeff1_p=self._pi1_p,
|
||||
pi_coeff1_i=self._pi1_i,
|
||||
pi_coeff2_p=self._pi2_p,
|
||||
pi_coeff2_i=self._pi2_i,
|
||||
message_id=self._message_id,
|
||||
)
|
||||
self._send_and_read_state(cmd)
|
||||
self._last_temp1 = validated['temp1']
|
||||
self._last_temp2 = validated['temp2']
|
||||
self._last_current1 = validated['current1']
|
||||
self._last_current2 = validated['current2']
|
||||
logger.debug("Manual mode set: T1=%.2f T2=%.2f I1=%.2f I2=%.2f",
|
||||
validated['temp1'], validated['temp2'],
|
||||
validated['current1'], validated['current2'])
|
||||
self._send_and_expect_ok(command)
|
||||
self._last_temp1 = values["temp1"]
|
||||
self._last_temp2 = values["temp2"]
|
||||
self._last_current1 = values["current1"]
|
||||
self._last_current2 = values["current2"]
|
||||
|
||||
def start_variation(
|
||||
def reset(self) -> None:
|
||||
"""Send DEFAULT_ENABLE and require an error-free acknowledgement."""
|
||||
self._send_and_expect_ok(Protocol.encode_default_enable())
|
||||
logger.info("Device reset command sent")
|
||||
|
||||
def configure_ad9102(
|
||||
self,
|
||||
variation_type: VariationType,
|
||||
params: dict,
|
||||
) -> None:
|
||||
"""
|
||||
Start a parameter variation task.
|
||||
*,
|
||||
enabled: bool,
|
||||
use_sram: bool,
|
||||
triangle: bool = False,
|
||||
saw_step: int = 1,
|
||||
pat_period_base: int = 2,
|
||||
pat_period: int = 0xFFFF,
|
||||
sample_count: int = 16,
|
||||
hold_cycles: int = 1,
|
||||
amplitude: int = 8191,
|
||||
use_amplitude_format: bool = False,
|
||||
) -> int:
|
||||
"""Configure the AD9102 signal generator in saw or generated-SRAM mode."""
|
||||
if use_sram:
|
||||
sample_count = self._validate_int_range(
|
||||
sample_count,
|
||||
"sample_count",
|
||||
AD9102_SRAM_SAMPLE_MIN,
|
||||
AD9102_SRAM_SAMPLE_MAX,
|
||||
)
|
||||
if use_amplitude_format:
|
||||
amplitude = self._validate_int_range(
|
||||
amplitude,
|
||||
"amplitude",
|
||||
AD9102_SRAM_AMPLITUDE_MIN,
|
||||
AD9102_SRAM_AMPLITUDE_MAX,
|
||||
)
|
||||
command = Protocol.encode_ad9102_control(
|
||||
enabled=enabled,
|
||||
triangle=triangle,
|
||||
sram_mode=True,
|
||||
alt_format=True,
|
||||
param0=amplitude,
|
||||
param1=sample_count,
|
||||
)
|
||||
else:
|
||||
hold_cycles = self._validate_int_range(
|
||||
hold_cycles,
|
||||
"hold_cycles",
|
||||
AD9102_SRAM_HOLD_MIN,
|
||||
AD9102_SRAM_HOLD_MAX,
|
||||
)
|
||||
command = Protocol.encode_ad9102_control(
|
||||
enabled=enabled,
|
||||
triangle=triangle,
|
||||
sram_mode=True,
|
||||
alt_format=False,
|
||||
param0=sample_count,
|
||||
param1=hold_cycles,
|
||||
)
|
||||
else:
|
||||
saw_step = self._validate_int_range(
|
||||
saw_step,
|
||||
"saw_step",
|
||||
AD9102_SAW_STEP_MIN,
|
||||
AD9102_SAW_STEP_MAX,
|
||||
)
|
||||
pat_period_base = self._validate_int_range(
|
||||
pat_period_base,
|
||||
"pat_period_base",
|
||||
AD9102_PAT_BASE_MIN,
|
||||
AD9102_PAT_BASE_MAX,
|
||||
)
|
||||
pat_period = self._validate_int_range(
|
||||
pat_period,
|
||||
"pat_period",
|
||||
AD9102_PAT_PERIOD_MIN,
|
||||
AD9102_PAT_PERIOD_MAX,
|
||||
)
|
||||
param0 = ((pat_period_base & 0x0F) << 8) | (saw_step & 0xFF)
|
||||
command = Protocol.encode_ad9102_control(
|
||||
enabled=enabled,
|
||||
triangle=triangle,
|
||||
sram_mode=False,
|
||||
alt_format=False,
|
||||
param0=param0,
|
||||
param1=pat_period,
|
||||
)
|
||||
|
||||
Args:
|
||||
variation_type: Which parameter to vary
|
||||
(:class:`VariationType.CHANGE_CURRENT_LD1` or
|
||||
:class:`VariationType.CHANGE_CURRENT_LD2`).
|
||||
params: Dictionary with the following keys:
|
||||
detail = self._send_and_expect_ok(command)
|
||||
logger.info("AD9102 configured: sram=%s triangle=%s enabled=%s", use_sram, triangle, enabled)
|
||||
return detail
|
||||
|
||||
- ``min_value`` – minimum value of the varied parameter.
|
||||
- ``max_value`` – maximum value of the varied parameter.
|
||||
- ``step`` – step size.
|
||||
- ``time_step`` – discretisation time step, µs [20 … 100].
|
||||
- ``delay_time``– delay between pulses, ms [3 … 10].
|
||||
- ``static_temp1`` – fixed temperature for laser 1, °C.
|
||||
- ``static_temp2`` – fixed temperature for laser 2, °C.
|
||||
- ``static_current1`` – fixed current for laser 1, mA.
|
||||
- ``static_current2`` – fixed current for laser 2, mA.
|
||||
def configure_ad9102_simple(
|
||||
self,
|
||||
*,
|
||||
enabled: bool,
|
||||
use_sram: bool,
|
||||
triangle: bool,
|
||||
frequency_hz: int,
|
||||
amplitude: int = 8191,
|
||||
) -> dict[str, float | int | bool]:
|
||||
"""Configure AD9102 using simplified frequency/shape controls."""
|
||||
if use_sram:
|
||||
amplitude = self._validate_int_range(
|
||||
amplitude,
|
||||
"amplitude",
|
||||
AD9102_SRAM_AMPLITUDE_MIN,
|
||||
AD9102_SRAM_AMPLITUDE_MAX,
|
||||
)
|
||||
sample_count, actual_frequency_hz = ad9102_sram_sample_count_from_frequency_hz(
|
||||
frequency_hz=frequency_hz,
|
||||
hold_cycles=DEFAULT_AD9102_HOLD_CYCLES,
|
||||
)
|
||||
detail = self.configure_ad9102(
|
||||
enabled=enabled,
|
||||
use_sram=True,
|
||||
triangle=triangle,
|
||||
sample_count=sample_count,
|
||||
amplitude=amplitude,
|
||||
use_amplitude_format=True,
|
||||
)
|
||||
return {
|
||||
"detail": detail,
|
||||
"actual_frequency_hz": actual_frequency_hz,
|
||||
"sample_count": sample_count,
|
||||
"hold_cycles": DEFAULT_AD9102_HOLD_CYCLES,
|
||||
"amplitude_applied": True,
|
||||
}
|
||||
|
||||
Raises:
|
||||
ValidationError: If any parameter fails validation.
|
||||
CommunicationError: If the command cannot be sent.
|
||||
"""
|
||||
# Validate variation-specific params
|
||||
validated = ParameterValidator.validate_variation_params(
|
||||
params, variation_type
|
||||
saw_step, actual_frequency_hz = ad9102_saw_step_from_frequency_hz(
|
||||
triangle=triangle,
|
||||
frequency_hz=frequency_hz,
|
||||
)
|
||||
|
||||
# Validate static parameters
|
||||
static_temp1 = ParameterValidator.validate_temperature(
|
||||
params.get('static_temp1', 25.0), 'static_temp1'
|
||||
detail = self.configure_ad9102(
|
||||
enabled=enabled,
|
||||
use_sram=False,
|
||||
triangle=triangle,
|
||||
saw_step=saw_step,
|
||||
pat_period_base=DEFAULT_AD9102_PAT_BASE,
|
||||
pat_period=DEFAULT_AD9102_PAT_PERIOD,
|
||||
)
|
||||
static_temp2 = ParameterValidator.validate_temperature(
|
||||
params.get('static_temp2', 25.0), 'static_temp2'
|
||||
)
|
||||
static_current1 = ParameterValidator.validate_current(
|
||||
params.get('static_current1', 30.0), 'static_current1'
|
||||
)
|
||||
static_current2 = ParameterValidator.validate_current(
|
||||
params.get('static_current2', 30.0), 'static_current2'
|
||||
)
|
||||
|
||||
# Map VariationType → protocol TaskType
|
||||
task_type_map = {
|
||||
VariationType.CHANGE_CURRENT_LD1: ProtoTaskType.CHANGE_CURRENT_LD1,
|
||||
VariationType.CHANGE_CURRENT_LD2: ProtoTaskType.CHANGE_CURRENT_LD2,
|
||||
VariationType.CHANGE_TEMPERATURE_LD1: ProtoTaskType.CHANGE_TEMPERATURE_LD1,
|
||||
VariationType.CHANGE_TEMPERATURE_LD2: ProtoTaskType.CHANGE_TEMPERATURE_LD2,
|
||||
return {
|
||||
"detail": detail,
|
||||
"actual_frequency_hz": actual_frequency_hz,
|
||||
"saw_step": saw_step,
|
||||
"amplitude_applied": False,
|
||||
}
|
||||
proto_task = task_type_map[validated['variation_type']]
|
||||
|
||||
cmd = Protocol.encode_task_enable(
|
||||
task_type=proto_task,
|
||||
static_temp1=static_temp1,
|
||||
static_temp2=static_temp2,
|
||||
static_current1=static_current1,
|
||||
static_current2=static_current2,
|
||||
min_value=validated['min_value'],
|
||||
max_value=validated['max_value'],
|
||||
step=validated['step'],
|
||||
time_step=validated['time_step'],
|
||||
delay_time=validated['delay_time'],
|
||||
message_id=self._message_id,
|
||||
pi_coeff1_p=self._pi1_p,
|
||||
pi_coeff1_i=self._pi1_i,
|
||||
pi_coeff2_p=self._pi2_p,
|
||||
pi_coeff2_i=self._pi2_i,
|
||||
def configure_ad9833(self, *, enabled: bool, triangle: bool, frequency_word: int) -> None:
|
||||
"""Configure the AD9833 generator using its raw 28-bit frequency word."""
|
||||
frequency_word = self._validate_int_range(
|
||||
frequency_word,
|
||||
"frequency_word",
|
||||
AD9833_FREQ_WORD_MIN,
|
||||
AD9833_FREQ_WORD_MAX,
|
||||
)
|
||||
self._send_and_read_state(cmd)
|
||||
logger.info("Variation task started: type=%s min=%.3f max=%.3f step=%.3f",
|
||||
validated['variation_type'].name,
|
||||
validated['min_value'],
|
||||
validated['max_value'],
|
||||
validated['step'])
|
||||
|
||||
def stop_task(self) -> None:
|
||||
"""Stop the current task and restore manual mode.
|
||||
|
||||
Sends DEFAULT_ENABLE (reset) followed by DECODE_ENABLE with the last
|
||||
known manual-mode parameters. This two-step sequence matches the
|
||||
original firmware protocol: after DEFAULT_ENABLE the board is in a
|
||||
reset state and must receive DECODE_ENABLE before it can respond to
|
||||
TRANS_ENABLE data requests again.
|
||||
"""
|
||||
cmd_reset = Protocol.encode_default_enable()
|
||||
self._send_and_read_state(cmd_reset)
|
||||
logger.info("Task stopped (DEFAULT_ENABLE sent)")
|
||||
|
||||
# Restore manual mode so the board is ready for TRANS_ENABLE requests
|
||||
self._message_id = (self._message_id + 1) & 0xFFFF
|
||||
cmd_restore = Protocol.encode_decode_enable(
|
||||
temp1=self._last_temp1,
|
||||
temp2=self._last_temp2,
|
||||
current1=self._last_current1,
|
||||
current2=self._last_current2,
|
||||
pi_coeff1_p=self._pi1_p,
|
||||
pi_coeff1_i=self._pi1_i,
|
||||
pi_coeff2_p=self._pi2_p,
|
||||
pi_coeff2_i=self._pi2_i,
|
||||
message_id=self._message_id,
|
||||
self._send_and_expect_ok(
|
||||
Protocol.encode_ad9833_control(
|
||||
enabled=enabled,
|
||||
triangle=triangle,
|
||||
frequency_word=frequency_word,
|
||||
)
|
||||
)
|
||||
self._send_and_read_state(cmd_restore)
|
||||
logger.info("Manual mode restored after task stop")
|
||||
logger.info("AD9833 configured: enabled=%s triangle=%s word=%d", enabled, triangle, frequency_word)
|
||||
|
||||
def get_measurements(self) -> Optional[Measurements]:
|
||||
"""
|
||||
Request and return the latest measurements from the device.
|
||||
def configure_ad9833_frequency(self, *, enabled: bool, triangle: bool, frequency_hz: int) -> int:
|
||||
"""Configure AD9833 using output frequency in hertz for a 20 MHz master clock."""
|
||||
frequency_hz = self._validate_int_range(
|
||||
frequency_hz,
|
||||
"frequency_hz",
|
||||
AD9833_OUTPUT_FREQ_MIN_HZ,
|
||||
AD9833_OUTPUT_FREQ_MAX_HZ,
|
||||
)
|
||||
frequency_word = int(round(frequency_hz * (1 << 28) / AD9833_MCLK_HZ))
|
||||
if frequency_word < AD9833_FREQ_WORD_MIN:
|
||||
frequency_word = AD9833_FREQ_WORD_MIN
|
||||
if frequency_word > AD9833_FREQ_WORD_MAX:
|
||||
frequency_word = AD9833_FREQ_WORD_MAX
|
||||
self.configure_ad9833(
|
||||
enabled=enabled,
|
||||
triangle=triangle,
|
||||
frequency_word=frequency_word,
|
||||
)
|
||||
return frequency_word
|
||||
|
||||
Returns:
|
||||
:class:`Measurements` dataclass, or None if no data available.
|
||||
def pulse_ds1809(self, *, increment: bool, count: int, pulse_ms: int) -> None:
|
||||
"""Pulse the DS1809 digital potentiometer in one direction."""
|
||||
if not increment and count:
|
||||
decrement = True
|
||||
else:
|
||||
decrement = False
|
||||
count = self._validate_int_range(count, "count", DS1809_COUNT_MIN, DS1809_COUNT_MAX)
|
||||
pulse_ms = self._validate_int_range(
|
||||
pulse_ms,
|
||||
"pulse_ms",
|
||||
DS1809_PULSE_MS_MIN,
|
||||
DS1809_PULSE_MS_MAX,
|
||||
)
|
||||
self._send_and_expect_ok(
|
||||
Protocol.encode_ds1809_control(
|
||||
increment=increment,
|
||||
decrement=decrement,
|
||||
count=count,
|
||||
pulse_ms=pulse_ms,
|
||||
)
|
||||
)
|
||||
logger.info("DS1809 pulsed: increment=%s count=%d pulse_ms=%d", increment, count, pulse_ms)
|
||||
|
||||
Raises:
|
||||
CommunicationError: On transport errors.
|
||||
"""
|
||||
cmd = Protocol.encode_trans_enable()
|
||||
self._send(cmd)
|
||||
def set_stm32_dac(self, *, enabled: bool, dac_code: int) -> None:
|
||||
"""Set the STM32 on-chip DAC code and output-enable state."""
|
||||
dac_code = self._validate_int_range(
|
||||
dac_code,
|
||||
"dac_code",
|
||||
STM32_DAC_CODE_MIN,
|
||||
STM32_DAC_CODE_MAX,
|
||||
)
|
||||
self._send_and_expect_ok(
|
||||
Protocol.encode_stm32_dac_control(enabled=enabled, dac_code=dac_code)
|
||||
)
|
||||
logger.info("STM32 DAC configured: enabled=%s code=%d", enabled, dac_code)
|
||||
|
||||
raw = self._protocol.receive_raw(30)
|
||||
if not raw or len(raw) != 30:
|
||||
logger.warning("No data received from device")
|
||||
def save_profile_to_sd(self, request: ProfileSaveRequest) -> None:
|
||||
"""Stream a rendered profile INI and optional waveform CSV to the device SD card."""
|
||||
if not isinstance(request, ProfileSaveRequest):
|
||||
raise InvalidParameterError("request", "Value must be a ProfileSaveRequest instance")
|
||||
|
||||
profile_name = ParameterValidator.validate_profile_name(request.profile_name)
|
||||
if not isinstance(request.profile_text, str) or not request.profile_text.strip():
|
||||
raise InvalidParameterError("profile_text", "Value must not be empty")
|
||||
if not isinstance(request.waveform_text, str):
|
||||
raise InvalidParameterError("waveform_text", "Value must be a string")
|
||||
|
||||
try:
|
||||
profile_bytes = request.profile_text.encode("ascii")
|
||||
waveform_bytes = request.waveform_text.encode("ascii")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise InvalidParameterError(
|
||||
"profile_text",
|
||||
"Profile payload must contain ASCII text only",
|
||||
) from exc
|
||||
|
||||
begin_sent = False
|
||||
try:
|
||||
self._send_and_expect_ok(
|
||||
Protocol.encode_profile_save_begin(
|
||||
profile_name=profile_name,
|
||||
profile_text_bytes=len(profile_bytes),
|
||||
waveform_text_bytes=len(waveform_bytes),
|
||||
)
|
||||
)
|
||||
begin_sent = True
|
||||
|
||||
for start in range(0, len(profile_bytes), PROFILE_SAVE_DATA_CHUNK_BYTES):
|
||||
self._send_and_expect_ok(
|
||||
Protocol.encode_profile_save_data(
|
||||
section_id=PROFILE_SAVE_SECTION_PROFILE_TEXT,
|
||||
chunk=profile_bytes[start:start + PROFILE_SAVE_DATA_CHUNK_BYTES],
|
||||
)
|
||||
)
|
||||
|
||||
for start in range(0, len(waveform_bytes), PROFILE_SAVE_DATA_CHUNK_BYTES):
|
||||
self._send_and_expect_ok(
|
||||
Protocol.encode_profile_save_data(
|
||||
section_id=PROFILE_SAVE_SECTION_WAVEFORM_TEXT,
|
||||
chunk=waveform_bytes[start:start + PROFILE_SAVE_DATA_CHUNK_BYTES],
|
||||
)
|
||||
)
|
||||
|
||||
self._send_and_expect_ok(Protocol.encode_profile_save_commit())
|
||||
except Exception:
|
||||
if begin_sent:
|
||||
try:
|
||||
self._send_and_expect_ok(Protocol.encode_profile_save_cancel())
|
||||
except Exception as cancel_exc: # noqa: BLE001
|
||||
logger.warning("Profile save cancel failed: %s", cancel_exc)
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"Profile saved to SD: name=%s waveform_bytes=%d",
|
||||
profile_name,
|
||||
len(waveform_bytes),
|
||||
)
|
||||
|
||||
def upload_ad9102_waveform(self, samples: Sequence[int]) -> None:
|
||||
"""Upload and commit a custom AD9102 waveform from signed 14-bit samples."""
|
||||
if not samples:
|
||||
raise InvalidParameterError("samples", "At least two samples are required")
|
||||
sample_list = [self._validate_wave_sample(sample, index) for index, sample in enumerate(samples)]
|
||||
sample_count = len(sample_list)
|
||||
if not AD9102_SRAM_SAMPLE_MIN <= sample_count <= AD9102_SRAM_SAMPLE_MAX:
|
||||
raise InvalidParameterError(
|
||||
"samples",
|
||||
f"Sample count must be in range [{AD9102_SRAM_SAMPLE_MIN}, {AD9102_SRAM_SAMPLE_MAX}]",
|
||||
)
|
||||
|
||||
self._send_and_expect_ok(Protocol.encode_ad9102_wave_begin(sample_count))
|
||||
for start in range(0, sample_count, AD9102_WAVE_MAX_CHUNK_SAMPLES):
|
||||
chunk = sample_list[start:start + AD9102_WAVE_MAX_CHUNK_SAMPLES]
|
||||
self._send_and_expect_ok(Protocol.encode_ad9102_wave_data(chunk))
|
||||
self._send_and_expect_ok(Protocol.encode_ad9102_wave_commit())
|
||||
logger.info("Uploaded AD9102 waveform with %d samples", sample_count)
|
||||
|
||||
def cancel_ad9102_waveform_upload(self) -> None:
|
||||
"""Cancel an in-progress AD9102 custom waveform upload."""
|
||||
self._send_and_expect_ok(Protocol.encode_ad9102_wave_cancel())
|
||||
logger.info("Cancelled AD9102 waveform upload")
|
||||
|
||||
def get_measurements(self) -> Measurements | None:
|
||||
"""Request one telemetry frame from the device."""
|
||||
self._send(Protocol.encode_trans_enable())
|
||||
raw = self._transport.read(GET_DATA_TOTAL_LENGTH)
|
||||
if len(raw) != GET_DATA_TOTAL_LENGTH:
|
||||
logger.warning("Expected %d telemetry bytes, got %d", GET_DATA_TOTAL_LENGTH, len(raw))
|
||||
return None
|
||||
|
||||
response = Protocol.decode_response(raw)
|
||||
measurements = response.to_measurements()
|
||||
measurements = Protocol.decode_response(raw)
|
||||
self._last_measurements = measurements
|
||||
|
||||
if self._on_data:
|
||||
if self._on_data is not None:
|
||||
self._on_data(measurements)
|
||||
|
||||
return measurements
|
||||
|
||||
def get_status(self) -> DeviceStatus:
|
||||
"""
|
||||
Request and return the current device status.
|
||||
|
||||
Returns:
|
||||
:class:`DeviceStatus` with state and latest measurements.
|
||||
|
||||
Raises:
|
||||
CommunicationError: On transport errors.
|
||||
"""
|
||||
cmd = Protocol.encode_state()
|
||||
self._send(cmd)
|
||||
|
||||
raw = self._protocol.receive_raw(2)
|
||||
if not raw or len(raw) < 2:
|
||||
"""Query the current two-byte firmware status word."""
|
||||
self._send(Protocol.encode_state())
|
||||
raw = self._transport.read(STATUS_RESPONSE_LENGTH)
|
||||
if len(raw) != STATUS_RESPONSE_LENGTH:
|
||||
raise DeviceNotRespondingError()
|
||||
|
||||
state_code = Protocol.decode_state(raw)
|
||||
|
||||
# Try to get measurements as well
|
||||
measurements = self._last_measurements
|
||||
|
||||
state, detail = Protocol.decode_status(raw)
|
||||
return DeviceStatus(
|
||||
state=DeviceState(state_code) if state_code in DeviceState._value2member_map_
|
||||
else DeviceState.ERROR,
|
||||
measurements=measurements,
|
||||
state=state,
|
||||
detail=detail,
|
||||
measurements=self._last_measurements,
|
||||
is_connected=self.is_connected,
|
||||
last_command_id=self._message_id,
|
||||
error_message=Protocol.state_to_description(f"{state_code:04x}")
|
||||
if state_code != 0 else None,
|
||||
error_message=Protocol.state_to_description(state),
|
||||
)
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Send a hardware reset command to the device."""
|
||||
cmd = Protocol.encode_default_enable()
|
||||
self._send_and_read_state(cmd)
|
||||
logger.info("Device reset command sent")
|
||||
|
||||
# ---- Internal helpers -------------------------------------------------
|
||||
|
||||
def _send(self, cmd: bytes) -> None:
|
||||
"""Send command bytes and wait for the device to process."""
|
||||
def _send(self, data: bytes) -> None:
|
||||
if not self.is_connected:
|
||||
raise CommunicationError("Not connected to device. Call connect() first.")
|
||||
self._protocol.send_raw(cmd)
|
||||
self._transport.send(data)
|
||||
time.sleep(WAIT_AFTER_SEND_SEC)
|
||||
|
||||
def _send_and_read_state(self, cmd: bytes) -> int:
|
||||
"""Send command and read the 2-byte STATE response the device always returns.
|
||||
def _send_and_expect_ok(self, data: bytes) -> int:
|
||||
self._send(data)
|
||||
raw = self._transport.read(STATUS_RESPONSE_LENGTH)
|
||||
if len(raw) != STATUS_RESPONSE_LENGTH:
|
||||
raise DeviceNotRespondingError()
|
||||
|
||||
Commands DECODE_ENABLE, TASK_ENABLE and DEFAULT_ENABLE each trigger a
|
||||
STATE reply from the firmware. If we don't consume those bytes here,
|
||||
they accumulate in the serial buffer and corrupt the next DATA read.
|
||||
state, detail = Protocol.decode_status(raw)
|
||||
if state != DeviceState.OK:
|
||||
combined_code = int(state) | (detail << 8)
|
||||
raise DeviceStateError(
|
||||
combined_code,
|
||||
Protocol.state_to_description(state),
|
||||
)
|
||||
return detail
|
||||
|
||||
Returns the decoded state code (0x0000 = OK).
|
||||
"""
|
||||
self._send(cmd)
|
||||
raw = self._protocol.receive_raw(2)
|
||||
if raw and len(raw) == 2:
|
||||
state = Protocol.decode_state(raw)
|
||||
logger.debug("STATE response after command: 0x%04x", state)
|
||||
return state
|
||||
return 0
|
||||
@staticmethod
|
||||
def _validate_int_range(value: int, name: str, minimum: int, maximum: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise InvalidParameterError(name, "Value must be an integer")
|
||||
if not minimum <= value <= maximum:
|
||||
raise InvalidParameterError(name, f"Value must be in range [{minimum}, {maximum}]")
|
||||
return value
|
||||
|
||||
# ---- Context manager support -----------------------------------------
|
||||
@staticmethod
|
||||
def _validate_wave_sample(value: int, index: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise InvalidParameterError(f"samples[{index}]", "Value must be an integer")
|
||||
if not AD9102_WAVE_SAMPLE_MIN <= value <= AD9102_WAVE_SAMPLE_MAX:
|
||||
raise InvalidParameterError(
|
||||
f"samples[{index}]",
|
||||
f"Value must be in range [{AD9102_WAVE_SAMPLE_MIN}, {AD9102_WAVE_SAMPLE_MAX}]",
|
||||
)
|
||||
return value
|
||||
|
||||
def __enter__(self):
|
||||
def __enter__(self) -> "LaserController":
|
||||
self.connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
# Always try to stop any running task before closing the port.
|
||||
# If we don't, the board stays in TASK state and ignores all future
|
||||
# commands until its power is cycled.
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
||||
if self.is_connected:
|
||||
try:
|
||||
self.stop_task()
|
||||
except Exception:
|
||||
pass
|
||||
self.disconnect()
|
||||
return False
|
||||
self.disconnect()
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user