"""Wire protocol for the Kamil ADC collector's TTY stream. The collector publishes a stream of 8-byte little-endian frames, four 16-bit words each: ``[marker, step, ch1, ch2]`` (``ch1``/``ch2`` signed). Three frame kinds appear in ``do8_freq_ref`` mode: * **Sweep boundary** — ``marker, 0xFFFF, 0xFFFF, 0xFFFF``. Delimits sweeps. * **Main point** — ``0x000A, step, I, Q``. One complex main sample at ``step``. * **Reference point** — ``0x00A8, step, I, Q``. The reference sample paired with the main sample of the same ``step`` (emitted only where the DI8 loopback settled, so reference points are sparser than main points). :class:`KamilAdcStreamParser` consumes raw bytes incrementally and yields one :class:`RawSweep` per completed sweep. Main and reference points are aligned by step index; only steps carrying *both* survive (a step needs its reference for the frequency axis and its main for the signal). Anything other than the three known frame kinds is a protocol violation and raises — the reader fails fast and lets the supervisor relaunch a clean collector rather than silently resyncing. """ from __future__ import annotations from dataclasses import dataclass import struct import numpy as np # Wire-format constants. FRAME_BYTES = 8 MAIN_MARKER = 0x000A REFERENCE_MARKER = 0x00A8 _BOUNDARY_STEP = 0xFFFF # marker (u16), step (u16), ch1 (i16), ch2 (i16) — point frames carry signed I/Q. _FRAME = struct.Struct(" int: return int(self.steps.size) class KamilAdcStreamParser: """Incremental, frame-aligning parser turning the TTY byte stream into sweeps. Usage: call :meth:`feed` with each chunk of bytes; it returns the list of sweeps completed by that chunk (usually zero or one). The parser is stateful but holds no I/O and is cheap to unit-test. """ __slots__ = ("_buffer", "_aligned", "_main", "_reference") def __init__(self) -> None: self._buffer = bytearray() self._aligned = False self._main: dict[int, complex] = {} self._reference: dict[int, complex] = {} def feed(self, data: bytes) -> list[RawSweep]: """Append ``data`` and return any sweeps completed by it.""" self._buffer.extend(data) if not self._aligned and not self._align(): return [] sweeps: list[RawSweep] = [] buffer = self._buffer while len(buffer) >= FRAME_BYTES: frame = bytes(buffer[:FRAME_BYTES]) del buffer[:FRAME_BYTES] if frame[2:] == _BOUNDARY_TAIL: sweep = self._take_sweep() if sweep is not None: sweeps.append(sweep) continue marker, step, real, imag = _FRAME.unpack(frame) if marker == MAIN_MARKER: self._main[step] = complex(real, imag) elif marker == REFERENCE_MARKER: self._reference[step] = complex(real, imag) else: raise ValueError( f"Kamil ADC protocol violation: unexpected frame marker 0x{marker:04x}" ) return sweeps def reset(self) -> None: """Drop all buffered state (e.g. after the collector is relaunched).""" self._buffer.clear() self._aligned = False self._main.clear() self._reference.clear() def _align(self) -> bool: """Discard pre-roll up to and including the first sweep boundary. Returns ``True`` once frame-aligned. Keeps a short tail so a boundary split across two feeds can still be found on the next chunk. """ index = self._buffer.find(_START_FRAME) if index < 0: if len(self._buffer) >= FRAME_BYTES: del self._buffer[: -(FRAME_BYTES - 1)] return False del self._buffer[: index + FRAME_BYTES] self._main.clear() self._reference.clear() self._aligned = True return True def _take_sweep(self) -> RawSweep | None: """Assemble the buffered points into a sweep and reset for the next one.""" shared = sorted(self._main.keys() & self._reference.keys()) main = self._main reference = self._reference self._main = {} self._reference = {} if not shared: return None return RawSweep( steps=np.asarray(shared, dtype=np.int32), main=np.asarray([main[step] for step in shared], dtype=np.complex64), reference=np.asarray([reference[step] for step in shared], dtype=np.complex64), )