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