597 lines
22 KiB
Python
597 lines
22 KiB
Python
"""High-level controller orchestrating protocol encoding and serial transport."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import math
|
|
import time
|
|
from typing import Callable, Sequence
|
|
|
|
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 (
|
|
CommunicationError,
|
|
DeviceNotRespondingError,
|
|
DeviceStateError,
|
|
InvalidParameterError,
|
|
)
|
|
from .models import DeviceState, DeviceStatus, Measurements, ProfileSaveRequest
|
|
from .protocol import Protocol
|
|
from .transport import SerialTransport
|
|
from .validators import ParameterValidator
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_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:
|
|
"""Public API for manual control, polling, and status queries."""
|
|
|
|
def __init__(
|
|
self,
|
|
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: 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: 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:
|
|
"""Return True when the serial port is connected."""
|
|
return self._transport.is_connected
|
|
|
|
@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,
|
|
temp1: float,
|
|
temp2: float,
|
|
current1: float,
|
|
current2: float,
|
|
) -> None:
|
|
"""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
|
|
|
|
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_expect_ok(command)
|
|
self._last_temp1 = values["temp1"]
|
|
self._last_temp2 = values["temp2"]
|
|
self._last_current1 = values["current1"]
|
|
self._last_current2 = values["current2"]
|
|
|
|
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,
|
|
*,
|
|
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,
|
|
)
|
|
|
|
detail = self._send_and_expect_ok(command)
|
|
logger.info("AD9102 configured: sram=%s triangle=%s enabled=%s", use_sram, triangle, enabled)
|
|
return detail
|
|
|
|
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,
|
|
}
|
|
|
|
saw_step, actual_frequency_hz = ad9102_saw_step_from_frequency_hz(
|
|
triangle=triangle,
|
|
frequency_hz=frequency_hz,
|
|
)
|
|
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,
|
|
)
|
|
return {
|
|
"detail": detail,
|
|
"actual_frequency_hz": actual_frequency_hz,
|
|
"saw_step": saw_step,
|
|
"amplitude_applied": False,
|
|
}
|
|
|
|
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_expect_ok(
|
|
Protocol.encode_ad9833_control(
|
|
enabled=enabled,
|
|
triangle=triangle,
|
|
frequency_word=frequency_word,
|
|
)
|
|
)
|
|
logger.info("AD9833 configured: enabled=%s triangle=%s word=%d", enabled, triangle, frequency_word)
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
measurements = Protocol.decode_response(raw)
|
|
self._last_measurements = measurements
|
|
if self._on_data is not None:
|
|
self._on_data(measurements)
|
|
return measurements
|
|
|
|
def get_status(self) -> DeviceStatus:
|
|
"""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, detail = Protocol.decode_status(raw)
|
|
return DeviceStatus(
|
|
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(state),
|
|
)
|
|
|
|
def _send(self, data: bytes) -> None:
|
|
if not self.is_connected:
|
|
raise CommunicationError("Not connected to device. Call connect() first.")
|
|
self._transport.send(data)
|
|
time.sleep(WAIT_AFTER_SEND_SEC)
|
|
|
|
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()
|
|
|
|
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
|
|
|
|
@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
|
|
|
|
@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) -> "LaserController":
|
|
self.connect()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
|
|
if self.is_connected:
|
|
self.disconnect()
|
|
return False
|