Deterministic Parsing of Mixed Binary/ASCII Instrument Outputs in Python

Mixed-format instrument streams routinely multiplex human-readable configuration blocks with high-throughput binary measurement payloads over a single transport layer such as UART, TCP, or GPIB. In laboratory automation, ad-hoc string splitting or naive socket.recv() loops collapse under partial-read conditions, delimiter collisions, and transport jitter, silently reinterpreting one dropped byte as a shifted struct field. This page works the exact single-frame case end to end: a bounded, three-phase finite-state machine that enforces strict framing invariants, validates payloads at the byte level, and hands each payload to downstream consumers as a read-only memoryview slice with a single bounded copy.

Framing Contract & Synchronization Scope

This is the focused, one-frame companion to the broader treatment in Binary & ASCII Format Parsing, the parent within the Data Capture, Validation & Metadata Sync pipeline. Where the parent surveys frame anatomy, byte-order tables, and hex/ASCII hybrids across an instrument fleet, this page pins down a single wire contract — an ASCII header, a length-prefixed little- or big-endian binary payload, and a CRC-32 trailer — and proves a parser correct against it. The assumptions are narrow on purpose: exactly one start marker per frame, an explicit LEN field, a declared endianness flag, and a fixed 4-byte trailer. It applies to any byte-stream instrument that fits that shape: digital storage oscilloscopes returning definite-length blocks, spectrometers emitting ASCII diagnostic headers before raw scan buffers, and packed-array DAQ cards.

Every frame must carry an unambiguous ASCII start marker, an explicit payload length, a deterministic endianness declaration, and a verifiable trailer. Timestamps and sequence counters are injected at the ingestion boundary, not derived post-parse, so clock drift cannot leak in during a multi-chunk partial read. Any violation of these invariants triggers immediate frame discard and a hard state reset; speculative recovery introduces silent data corruption downstream. The wire contract this page assumes:

*HDR\r\n
DEV:OSC-4000
MODE:RAW_ACQ
ENDIAN:L
LEN:4096
;END\r\n
<4096 bytes binary payload>
<CRC32 trailer (4 bytes)>
One frame: byte regions mapped to the three parser states A single frame is laid out left to right from low to high byte offset in three regions. The ASCII header runs from the *HDR start marker through key/value lines such as ENDIAN:L and LEN:4096 to the ;END terminator; the LEN field sets the width of the next region. The middle region is the N-byte little- or big-endian binary payload. The trailing region is a fixed 4-byte CRC32 trailer. Beneath the regions, three state boxes show which region each parser state consumes: AWAITING_HEADER finds the marker and reads LEN and ENDIAN, READING_PAYLOAD slices exactly LEN bytes in one bounded copy, and VALIDATING_TRAILER compares the computed CRC32 against the trailer. On a CRC match the parser yields a read-only memoryview; on a CRC mismatch it calls reset and discards the frame. Both exits reset the machine back to AWAITING_HEADER. ONE FRAME ON THE WIRE — LOW ADDRESS ▸ HIGH ADDRESS, PARSED IN THREE STATES ASCII HEADER *HDR ↵ ENDIAN:L · LEN:4096 ;END ↵ N-BYTE BINARY PAYLOAD LEN bytes · struct '<' little / '>' big 9A 2F E1 00 7C … B4 CRC32 4 bytes >I LEN sets payload width AWAITING_HEADER find marker · read LEN / ENDIAN READING_PAYLOAD slice LEN bytes · one bounded copy VALIDATING_TRAILER crc32(payload) == trailer ? LEN valid bytes read CRC ok ▸ yield read-only memoryview CRC fail ▸ reset() + discard frame both exits reset the machine → AWAITING_HEADER
The frame's three byte regions map one-to-one onto the parser's three states: the header's LEN field fixes the payload width before any allocation, the payload is sliced in a single bounded copy, and the 4-byte trailer gates emission — a CRC match yields a read-only memoryview, a mismatch resets and discards.

Buffer Residency Bounds & Deterministic Consumption

The correctness argument rests on two guarantees: the ingestion buffer stays bounded regardless of transport noise, and every feed() call makes forward progress or stops. When no start marker is present, the parser must not accumulate unbounded noise, so it trims all but a trailing window N_trim (large enough to hold a start marker split across two reads). Once a header is located, at most one in-flight frame is resident. That gives a hard ceiling on buffered bytes:

where N_trim is the noise-retention window (2048 B here), |H| the header length, L_max the configured max_payload, and C the 4-byte CRC trailer. Because L_max is validated before the parser transitions into payload reading, a malformed LEN field can never provoke an allocation larger than this bound — the struct overflow and out-of-memory failure modes are closed off at the header stage rather than caught after the fact.

Termination is enforced structurally. Each feed() snapshots (state, len(buffer)) before every state step and breaks the loop if a step produces no change, so a partial header, payload, or trailer parks the residual bytes for the next call instead of spinning. Amortized cost is linear: each byte is appended once and deleted once, giving O(n) total consumption across the stream. The one non-linear risk is repeated bytearray.find() over retained noise; capping the retained window at N_trim bounds each scan and keeps sustained-noise latency flat.

State-Machine Architecture & Buffer Discipline

The parser consumes bytes deterministically and transitions only when framing invariants are satisfied, in three discrete phases:

  1. Header Acquisition (AWAITING_HEADER): Scan for the fixed ASCII start marker. Discard all preceding bytes as transport noise. Parse key/value metadata until the deterministic terminator (;END\r\n).
  2. Length & Format Resolution: Extract payload length and endianness from the header. Validate against the hard max_payload boundary before allocating, using an explicit struct byte-order prefix (< for little-endian, > for big-endian).
  3. Binary Extraction & Trailer Validation (READING_PAYLOADVALIDATING_TRAILER): Slice a fixed-width payload, copy it once into an immutable buffer, verify the CRC-32 trailer, and yield a zero-copy memoryview to consumers. Reset state immediately upon successful emission.

This deterministic model eliminates regex backtracking and speculative buffering. The CRC step is deliberately the same integrity gate documented under Checksum & CRC Validation — a bit-flipped block is rejected here, at the transport boundary, rather than decoded into plausible-looking noise. Because a read timeout can leave a half-received frame, the partial-frame discipline mirrors Timeout Handling & Retry Logic: the machine must be able to complete an interrupted frame on the next poll, never discard it.

Production Parser Implementation

The following implementation enforces explicit error boundaries, deterministic state transitions, and single-copy payload delivery. It is designed for synchronous or asyncio-compatible ingestion loops.

import struct
import time
import zlib
import logging
from enum import Enum, auto
from dataclasses import dataclass
from typing import Iterator, Optional

logger = logging.getLogger(__name__)

class ParseError(Exception):
    """Raised on deterministic framing violations or struct mismatches."""
    pass

class FrameState(Enum):
    AWAITING_HEADER = auto()
    READING_PAYLOAD = auto()
    VALIDATING_TRAILER = auto()

@dataclass(frozen=True)
class InstrumentFrame:
    metadata: dict[str, str]
    payload: memoryview
    ingestion_ts: float
    seq_id: int
    transport_id: str

class MixedStreamParser:
    """Bounded three-phase parser for ASCII-header + binary-payload frames."""

    def __init__(
        self,
        start_marker: bytes = b"*HDR\r\n",
        header_term: bytes = b";END\r\n",
        max_payload: int = 10_000_000,
        transport_id: str = "primary_bus",
    ):
        self.start_marker = start_marker
        self.header_term = header_term
        self.max_payload = max_payload
        self.transport_id = transport_id

        self._state = FrameState.AWAITING_HEADER
        self._buffer = bytearray()
        self._payload_len = 0
        self._payload_fmt = "<"
        self._current_meta: dict[str, str] = {}
        self._current_payload: Optional[memoryview] = None
        self._ingestion_ts = 0.0
        self._seq_counter = 0

    def feed(self, chunk: bytes) -> Iterator[InstrumentFrame]:
        """Ingest raw bytes and yield fully validated frames."""
        if not chunk:
            return

        # Inject ingestion boundary metadata before parsing.
        self._ingestion_ts = time.perf_counter()
        self._seq_counter += 1
        self._buffer.extend(chunk)

        while self._buffer:
            # Snapshot state+length to detect stalls (partial frame in flight)
            # and break instead of spinning on incomplete data.
            progress_marker = (self._state, len(self._buffer))

            if self._state == FrameState.AWAITING_HEADER:
                self._acquire_header()
            elif self._state == FrameState.READING_PAYLOAD:
                self._extract_payload()
            elif self._state == FrameState.VALIDATING_TRAILER:
                frame = self._validate_and_emit()
                if frame is not None:
                    yield frame
            else:
                break

            if (self._state, len(self._buffer)) == progress_marker:
                break  # No forward progress; await more bytes.

    def _acquire_header(self) -> None:
        idx = self._buffer.find(self.start_marker)
        if idx == -1:
            # Discard transport noise aggressively to bound memory.
            if len(self._buffer) > 4096:
                self._buffer = bytearray(self._buffer[-2048:])
            return

        del self._buffer[:idx]  # Strip preceding noise.

        term_idx = self._buffer.find(self.header_term)
        if term_idx == -1:
            return  # Partial header; await more data.

        raw_header = bytes(self._buffer[:term_idx + len(self.header_term)])
        del self._buffer[:term_idx + len(self.header_term)]

        self._current_meta = self._parse_metadata(raw_header)
        self._payload_len = int(self._current_meta.get("LEN", 0))
        endian_flag = self._current_meta.get("ENDIAN", "L")
        self._payload_fmt = "<" if endian_flag.upper() == "L" else ">"

        if self._payload_len <= 0 or self._payload_len > self.max_payload:
            logger.error("Frame discarded: invalid LEN=%s", self._payload_len)
            self._reset()
            return

        self._state = FrameState.READING_PAYLOAD

    def _parse_metadata(self, raw: bytes) -> dict[str, str]:
        content = raw[len(self.start_marker):-len(self.header_term)]
        meta: dict[str, str] = {}
        for line in content.split(b"\r\n"):
            if b":" in line:
                k, v = line.split(b":", 1)
                meta[k.decode("ascii").strip()] = v.decode("ascii").strip()
        return meta

    def _extract_payload(self) -> None:
        if len(self._buffer) < self._payload_len:
            return  # Partial payload.

        # Detach the payload into an immutable buffer. A memoryview over the
        # mutable bytearray would block the subsequent del (BufferError), so we
        # copy once here and expose a read-only memoryview to consumers.
        self._current_payload = memoryview(bytes(self._buffer[:self._payload_len]))
        del self._buffer[:self._payload_len]
        self._state = FrameState.VALIDATING_TRAILER

    def _validate_and_emit(self) -> Optional[InstrumentFrame]:
        if len(self._buffer) < 4:
            return None  # Awaiting CRC32 trailer.

        trailer = bytes(self._buffer[:4])
        del self._buffer[:4]

        computed_crc = zlib.crc32(self._current_payload) & 0xFFFFFFFF
        received_crc = struct.unpack(">I", trailer)[0]

        if computed_crc != received_crc:
            logger.critical(
                "CRC mismatch: computed=%#010x, received=%#010x. Discarding frame.",
                computed_crc, received_crc,
            )
            self._reset()
            return None

        frame = InstrumentFrame(
            metadata=self._current_meta,
            payload=self._current_payload,
            ingestion_ts=self._ingestion_ts,
            seq_id=self._seq_counter,
            transport_id=self.transport_id,
        )

        # Reset for next frame.
        self._state = FrameState.AWAITING_HEADER
        self._current_payload = None
        return frame

    def _reset(self) -> None:
        """Hard state reset on framing violation."""
        self._state = FrameState.AWAITING_HEADER
        self._buffer.clear()
        self._payload_len = 0
        self._current_payload = None

Live-Lab Validation & Verification

Before trusting this parser against a live instrument, confirm correct behavior with a deterministic loopback so that a failure isolates to your framing assumptions rather than the hardware. Build one known-good frame from bytes you control, then feed it in adversarial chunk boundaries — one byte at a time — to prove the machine reassembles across partial reads:

def build_frame(payload: bytes, endian: str = "L") -> bytes:
    """Construct a spec-compliant frame for loopback verification."""
    header = (
        b"*HDR\r\n"
        b"DEV:OSC-4000\r\n"
        b"MODE:RAW_ACQ\r\n"
        + f"ENDIAN:{endian}\r\n".encode("ascii")
        + f"LEN:{len(payload)}\r\n".encode("ascii")
        + b";END\r\n"
    )
    crc = zlib.crc32(payload) & 0xFFFFFFFF
    return header + payload + struct.pack(">I", crc)


def test_byte_at_a_time() -> None:
    payload = struct.pack("<1024f", *[float(i) for i in range(1024)])
    frame = build_frame(payload)
    parser = MixedStreamParser(max_payload=1 << 20)

    emitted = []
    for i in range(len(frame)):
        emitted.extend(parser.feed(frame[i:i + 1]))  # Worst-case fragmentation.

    assert len(emitted) == 1, "exactly one frame must survive reassembly"
    assert emitted[0].metadata["LEN"] == str(len(payload))
    assert bytes(emitted[0].payload) == payload
    print("loopback OK:", emitted[0].seq_id, emitted[0].metadata)

In a live control loop, watch three signals. First, the CRC mismatch line logs at CRITICAL — a healthy link produces zero; a nonzero-but-flat rate points to a specific cable or channel, while a climbing rate points to progressive EMI or connector degradation. Second, monitor seq_id continuity per transport_id: gaps mean frames were discarded upstream, not corrupted. Third, on the instrument side, cross-check the emitted metadata["LEN"] against the device’s own block-length preamble (for USB-TMC / IEEE 488.2 sources, the #<n><length> header) — a mismatch there means the wire contract drifted with a firmware revision. Timestamp each emission with ingestion_ts and confirm inter-frame deltas track the instrument’s configured acquisition rate rather than the transport’s arrival jitter.

Failure Modes & Diagnosis

Four failure modes are specific to this narrow header/payload/trailer machine. Map the observable symptom to a root cause and a concrete action:

Symptom Root cause Immediate action
Silent discards on every frame; parser never yields Delimiter collision or shifted byte alignment — start_marker does not match firmware output exactly Confirm the exact ASCII marker against a firmware hex dump; inject a build_frame() loopback to validate marker positioning before blaming the link
struct.error: unpack requires a buffer of X bytes, or payloads decode to 3.4e38 / NaN ENDIAN flag disagrees with the instrument architecture, or a one-byte payload shift Cross-reference the header ENDIAN against the device; force an explicit < or > in every downstream struct.unpack; loopback a known value and compare
CRC mismatch logs flooding CRITICAL Transport bit-flips, or slicing consumed a trailer byte into the payload region Verify baud/parity and MTU; confirm _extract_payload slices exactly LEN bytes so the 4-byte trailer stays intact; escalate persistent per-channel rates to Threshold Tuning & Alerting
Latency climbs and buffer grows under sustained input Start marker never found (wrong marker or baud drift), so noise accumulates Verify the N_trim noise-trim ceiling fires; confirm header_term matches; profile bytearray.extend() versus a pre-allocated ring buffer for >100 kHz streams

For faults that recur across an instrument array rather than a single link, route the typed ParseError through Error Code Categorization so recovery policy is driven by fault class, and encode each device’s byte order behind a Protocol Abstraction Layer instead of a global flag.

This parser is the ingestion boundary for the wider pipeline. Upstream, the serial or TCP transport configured through PySerial Configuration & Tuning must guarantee byte-order consistency and a read granularity finer than one frame. Downstream, each emitted InstrumentFrame feeds Checksum & CRC Validation audit trails — the concrete streaming variant is worked in Implementing CRC32 Validation for Sensor Data Streams — where per-channel CRC rates drive hardware health monitoring.

The metadata dictionary routes into Metadata Injection Workflows, attaching calibration coefficients and instrument state before data reaches storage. The ingestion_ts field drives Threshold Tuning & Alerting for real-time deviation detection against baseline acquisition rates. In concurrent deployments, wrap feed() behind an Async Command Queuing System so one slow instrument cannot stall the shared loop — one parser instance per session, since the bytearray buffer and FSM state are not reentrant. The read-only memoryview payload casts directly into a NumPy array with no further copy for analytics.

Conclusion

Mixed ASCII/binary instrument streams demand a deterministic framing contract, strict state transitions, and a single bounded copy from the resizable ingestion buffer into an immutable, read-only payload. By enforcing ingestion-boundary timestamping, explicit length and endianness resolution before allocation, and hard CRC validation, this machine bounds its own memory, guarantees forward progress on partial reads, and prevents silent corruption from reaching the analysis layer. Deploy it with bounded buffers, explicit struct prefixes, and immediate state resets on violation to hold pipeline integrity across high-throughput laboratory environments.