""" protocol.py — High-level MiniMate Plus request/response protocol. Implements the request/response patterns documented in docs/instantel_protocol_reference.md on top of: - minimateplus.framing — DLE codec, frame builder, S3 streaming parser - minimateplus.transport — byte I/O (SerialTransport / future TcpTransport) This module knows nothing about pyserial or TCP — it only calls transport.write() and transport.read_until_idle(). Key patterns implemented: - POLL startup handshake (two-step, special payload[5] format) - Generic two-step paged read (probe → get length → fetch data) - Response timeout + checksum validation - Boot-string drain (device sends "Operating System" ASCII before framing) All public methods raise ProtocolError on timeout, bad checksum, or unexpected response SUB. """ from __future__ import annotations import logging import time from typing import Optional from .framing import ( S3Frame, S3FrameParser, build_bw_frame, waveform_key_params, token_params, POLL_PROBE, POLL_DATA, ) from .transport import BaseTransport log = logging.getLogger(__name__) # ── Constants ───────────────────────────────────────────────────────────────── # Response SUB = 0xFF - Request SUB (confirmed pattern, no known exceptions # among read commands; one write-path exception documented for SUB 1C→6E). def _expected_rsp_sub(req_sub: int) -> int: return (0xFF - req_sub) & 0xFF # SUB byte constants (request side) — see protocol reference §5.1 SUB_POLL = 0x5B SUB_SERIAL_NUMBER = 0x15 SUB_FULL_CONFIG = 0x01 SUB_EVENT_INDEX = 0x08 SUB_CHANNEL_CONFIG = 0x06 SUB_TRIGGER_CONFIG = 0x1C SUB_EVENT_HEADER = 0x1E SUB_EVENT_ADVANCE = 0x1F SUB_WAVEFORM_HEADER = 0x0A SUB_WAVEFORM_RECORD = 0x0C SUB_BULK_WAVEFORM = 0x5A SUB_COMPLIANCE = 0x1A SUB_UNKNOWN_2E = 0x2E # Hardcoded data lengths for the two-step read protocol. # # The S3 probe response page_key is always 0x0000 — it does NOT carry the # data length back to us. Instead, each SUB has a fixed known payload size # confirmed from BW capture analysis (offset at payload[5] of the data-request # frame). # # Key: request SUB byte. Value: offset/length byte sent in the data-request. # Entries marked 🔶 are inferred from captured frames and may need adjustment. DATA_LENGTHS: dict[int, int] = { SUB_POLL: 0x30, # POLL startup data block ✅ SUB_SERIAL_NUMBER: 0x0A, # 10-byte serial number block ✅ SUB_FULL_CONFIG: 0x98, # 152-byte full config block ✅ SUB_EVENT_INDEX: 0x58, # 88-byte event index ✅ SUB_TRIGGER_CONFIG: 0x2C, # 44-byte trigger config 🔶 SUB_EVENT_HEADER: 0x08, # 8-byte event header (waveform key + event data) ✅ SUB_EVENT_ADVANCE: 0x08, # 8-byte next-key response ✅ # SUB_WAVEFORM_HEADER (0x0A) is VARIABLE — length read from probe response # data[4]. Do NOT add it here; use read_waveform_header() instead. ✅ SUB_WAVEFORM_RECORD: 0xD2, # 210-byte waveform/histogram record ✅ SUB_UNKNOWN_2E: 0x1A, # 26 bytes, purpose TBD 🔶 0x09: 0xCA, # 202 bytes, purpose TBD 🔶 # SUB_COMPLIANCE (0x1A) uses a multi-step sequence with a 2090-byte total; # NOT handled here — requires specialised read logic. } # Default timeout values (seconds). # MiniMate Plus is a slow device — keep these generous. DEFAULT_RECV_TIMEOUT = 10.0 POLL_RECV_TIMEOUT = 10.0 # ── Exception ───────────────────────────────────────────────────────────────── class ProtocolError(Exception): """Raised when the device violates the expected protocol.""" class TimeoutError(ProtocolError): """Raised when no response is received within the allowed time.""" class ChecksumError(ProtocolError): """Raised when a received frame has a bad checksum.""" class UnexpectedResponse(ProtocolError): """Raised when the response SUB doesn't match what we requested.""" # ── MiniMateProtocol ────────────────────────────────────────────────────────── class MiniMateProtocol: """ Protocol state machine for one open connection to a MiniMate Plus device. Does not own the transport — transport lifetime is managed by MiniMateClient. Typical usage (via MiniMateClient — not directly): proto = MiniMateProtocol(transport) proto.startup() # POLL handshake, drain boot string data = proto.read(SUB_FULL_CONFIG) sn_data = proto.read(SUB_SERIAL_NUMBER) """ def __init__( self, transport: BaseTransport, recv_timeout: float = DEFAULT_RECV_TIMEOUT, ) -> None: self._transport = transport self._recv_timeout = recv_timeout self._parser = S3FrameParser() # ── Public API ──────────────────────────────────────────────────────────── def startup(self) -> S3Frame: """ Perform the POLL startup handshake and return the POLL data frame. Steps (matching §6 Session Startup Sequence): 1. Drain any boot-string bytes ("Operating System" ASCII) 2. Send POLL_PROBE (SUB 5B, offset=0x00) 3. Receive probe ack (page_key is 0x0000; data length 0x30 is hardcoded) 4. Send POLL_DATA (SUB 5B, offset=0x30) 5. Receive data frame with "Instantel" + "MiniMate Plus" strings Returns: The data-phase POLL response S3Frame. Raises: ProtocolError: if either POLL step fails. """ log.debug("startup: draining boot string") self._drain_boot_string() log.debug("startup: POLL probe") self._send(POLL_PROBE) probe_rsp = self._recv_one( expected_sub=_expected_rsp_sub(SUB_POLL), timeout=self._recv_timeout, ) log.debug( "startup: POLL probe response page_key=0x%04X", probe_rsp.page_key ) log.debug("startup: POLL data request") self._send(POLL_DATA) data_rsp = self._recv_one( expected_sub=_expected_rsp_sub(SUB_POLL), timeout=self._recv_timeout, ) log.debug("startup: POLL data received, %d bytes", len(data_rsp.data)) return data_rsp def read(self, sub: int) -> bytes: """ Execute a two-step paged read and return the data payload bytes. Step 1: send probe frame (offset=0x00) → device sends a short ack Step 2: send data-request (offset=DATA_LEN) → device sends the data block The S3 probe response does NOT carry the data length — page_key is always 0x0000 in observed frames. DATA_LENGTHS holds the known fixed lengths derived from BW capture analysis. Args: sub: Request SUB byte (e.g. SUB_FULL_CONFIG = 0x01). Returns: De-stuffed data payload bytes (payload[5:] of the response frame, with the checksum already stripped by the parser). Raises: ProtocolError: on timeout, bad checksum, or wrong response SUB. KeyError: if sub is not in DATA_LENGTHS (caller should add it). """ rsp_sub = _expected_rsp_sub(sub) # Step 1 — probe (offset = 0) log.debug("read SUB=0x%02X: probe", sub) self._send(build_bw_frame(sub, 0)) _probe = self._recv_one(expected_sub=rsp_sub) # ack; page_key always 0 # Look up the hardcoded data length for this SUB if sub not in DATA_LENGTHS: raise ProtocolError( f"No known data length for SUB=0x{sub:02X}. " "Add it to DATA_LENGTHS in protocol.py." ) length = DATA_LENGTHS[sub] log.debug("read SUB=0x%02X: data request offset=0x%02X", sub, length) if length == 0: log.warning("read SUB=0x%02X: DATA_LENGTHS entry is zero", sub) return b"" # Step 2 — data-request (offset = length) self._send(build_bw_frame(sub, length)) data_rsp = self._recv_one(expected_sub=rsp_sub) log.debug("read SUB=0x%02X: received %d data bytes", sub, len(data_rsp.data)) return data_rsp.data def send_keepalive(self) -> None: """ Send a single POLL_PROBE keepalive without waiting for a response. Blastware sends these every ~80ms during idle. Useful if you need to hold the session open between real requests. """ self._send(POLL_PROBE) # ── Event download API ──────────────────────────────────────────────────── def read_event_first(self) -> tuple[bytes, bytes]: """ Send the SUB 1E (EVENT_HEADER) two-step read and return the first waveform key and accompanying 8-byte event data block. This always uses all-zero params — the device returns the first stored event's waveform key unconditionally. Returns: (key4, event_data8) where: key4 — 4-byte opaque waveform record address (data[11:15]) event_data8 — full 8-byte data section (data[11:19]) Raises: ProtocolError: on timeout, bad checksum, or wrong response SUB. Confirmed from 3-31-26 capture: 1E request uses all-zero params; response data section layout is: [LENGTH_ECHO:1][00×4][KEY_ECHO:4][00×2][KEY4:4][EXTRA:4] … Actual data starts at data[11]; first 4 bytes are the waveform key. """ rsp_sub = _expected_rsp_sub(SUB_EVENT_HEADER) length = DATA_LENGTHS[SUB_EVENT_HEADER] # 0x08 log.debug("read_event_first: 1E probe") self._send(build_bw_frame(SUB_EVENT_HEADER, 0)) self._recv_one(expected_sub=rsp_sub) log.debug("read_event_first: 1E data request offset=0x%02X", length) self._send(build_bw_frame(SUB_EVENT_HEADER, length)) data_rsp = self._recv_one(expected_sub=rsp_sub) event_data8 = data_rsp.data[11:19] key4 = data_rsp.data[11:15] log.debug("read_event_first: key=%s", key4.hex()) return key4, event_data8 def read_waveform_header(self, key4: bytes) -> tuple[bytes, int]: """ Send the SUB 0A (WAVEFORM_HEADER) two-step read for *key4*. The data length for 0A is VARIABLE and must be read from the probe response at data[4]. Two known values: 0x30 — full histogram bin (has a waveform record to follow) 0x26 — partial histogram bin (no waveform record) Args: key4: 4-byte waveform record address from 1E or 1F. Returns: (header_bytes, record_length) where: header_bytes — raw data section starting at data[11] record_length — DATA_LENGTH read from probe (0x30 or 0x26) Raises: ProtocolError: on timeout, bad checksum, or wrong response SUB. Confirmed from 3-31-26 capture: 0A probe response data[4] carries the variable length; data-request uses that length as the offset byte. """ rsp_sub = _expected_rsp_sub(SUB_WAVEFORM_HEADER) params = waveform_key_params(key4) log.debug("read_waveform_header: 0A probe key=%s", key4.hex()) self._send(build_bw_frame(SUB_WAVEFORM_HEADER, 0, params)) probe_rsp = self._recv_one(expected_sub=rsp_sub) # Variable length — read from probe response data[4] length = probe_rsp.data[4] if len(probe_rsp.data) > 4 else 0x30 log.debug("read_waveform_header: 0A data request offset=0x%02X", length) if length == 0: return b"", 0 self._send(build_bw_frame(SUB_WAVEFORM_HEADER, length, params)) data_rsp = self._recv_one(expected_sub=rsp_sub) header_bytes = data_rsp.data[11:11 + length] log.debug( "read_waveform_header: key=%s length=0x%02X is_full=%s", key4.hex(), length, length == 0x30, ) return header_bytes, length def read_waveform_record(self, key4: bytes) -> bytes: """ Send the SUB 0C (WAVEFORM_RECORD / FULL_WAVEFORM_RECORD) two-step read. Returns the 210-byte waveform/histogram record containing: - Record type string ("Histogram" or "Waveform") at a variable offset - Per-channel labels ("Tran", "Vert", "Long", "MicL") with PPV floats at label_offset + 6 Args: key4: 4-byte waveform record address. Returns: 210-byte record bytes (data[11:11+0xD2]). Raises: ProtocolError: on timeout, bad checksum, or wrong response SUB. Confirmed from 3-31-26 capture: 0C always uses offset=0xD2 (210 bytes). """ rsp_sub = _expected_rsp_sub(SUB_WAVEFORM_RECORD) length = DATA_LENGTHS[SUB_WAVEFORM_RECORD] # 0xD2 params = waveform_key_params(key4) log.debug("read_waveform_record: 0C probe key=%s", key4.hex()) self._send(build_bw_frame(SUB_WAVEFORM_RECORD, 0, params)) self._recv_one(expected_sub=rsp_sub) log.debug("read_waveform_record: 0C data request offset=0x%02X", length) self._send(build_bw_frame(SUB_WAVEFORM_RECORD, length, params)) data_rsp = self._recv_one(expected_sub=rsp_sub) record = data_rsp.data[11:11 + length] log.debug("read_waveform_record: received %d record bytes", len(record)) return record def advance_event(self) -> bytes: """ Send the SUB 1F (EVENT_ADVANCE) two-step read with download-mode token (0xFE) and return the next waveform key. In download mode (token=0xFE), the device skips partial histogram bins and returns the key of the next FULL record directly. This is the Blastware-observed behaviour for iterating through all stored events. Returns: key4 — 4-byte next waveform key from data[11:15]. Returns b'\\x00\\x00\\x00\\x00' when there are no more events. Raises: ProtocolError: on timeout, bad checksum, or wrong response SUB. Confirmed from 3-31-26 capture: 1F uses token=0xFE at params[6]; loop termination is key4 == b'\\x00\\x00\\x00\\x00'. """ rsp_sub = _expected_rsp_sub(SUB_EVENT_ADVANCE) length = DATA_LENGTHS[SUB_EVENT_ADVANCE] # 0x08 params = token_params(0xFE) log.debug("advance_event: 1F probe") self._send(build_bw_frame(SUB_EVENT_ADVANCE, 0, params)) self._recv_one(expected_sub=rsp_sub) log.debug("advance_event: 1F data request offset=0x%02X", length) self._send(build_bw_frame(SUB_EVENT_ADVANCE, length, params)) data_rsp = self._recv_one(expected_sub=rsp_sub) key4 = data_rsp.data[11:15] log.debug( "advance_event: next key=%s done=%s", key4.hex(), key4 == b"\x00\x00\x00\x00", ) return key4 # ── Internal helpers ────────────────────────────────────────────────────── def _send(self, frame: bytes) -> None: """Write a pre-built frame to the transport.""" log.debug("TX %d bytes: %s", len(frame), frame.hex()) self._transport.write(frame) def _recv_one( self, expected_sub: Optional[int] = None, timeout: Optional[float] = None, ) -> S3Frame: """ Read bytes from the transport until one complete S3 frame is parsed. Feeds bytes through the streaming S3FrameParser. Keeps reading until a frame arrives or the deadline expires. Args: expected_sub: If provided, raises UnexpectedResponse if the received frame's SUB doesn't match. timeout: Seconds to wait. Defaults to self._recv_timeout. Returns: The first complete S3Frame received. Raises: TimeoutError: if no frame arrives within the timeout. ChecksumError: if the frame has an invalid checksum. UnexpectedResponse: if expected_sub is set and doesn't match. """ deadline = time.monotonic() + (timeout or self._recv_timeout) self._parser.reset() while time.monotonic() < deadline: chunk = self._transport.read(256) if chunk: log.debug("RX %d bytes: %s", len(chunk), chunk.hex()) frames = self._parser.feed(chunk) if frames: frame = frames[0] self._validate_frame(frame, expected_sub) return frame else: time.sleep(0.005) raise TimeoutError( f"No S3 frame received within {timeout or self._recv_timeout:.1f}s" + (f" (expected SUB 0x{expected_sub:02X})" if expected_sub is not None else "") ) @staticmethod def _validate_frame(frame: S3Frame, expected_sub: Optional[int]) -> None: """Validate SUB; log but do not raise on bad checksum. S3 response checksums frequently fail SUM8 validation due to inner-frame delimiter bytes being captured as the checksum byte. The original s3_parser.py deliberately never validates S3 checksums for exactly this reason. We log a warning and continue. """ if not frame.checksum_valid: # S3 checksums frequently fail SUM8 due to inner-frame delimiter bytes # landing in the checksum position. Treat as informational only. log.debug("S3 frame SUB=0x%02X: checksum mismatch (ignoring)", frame.sub) if expected_sub is not None and frame.sub != expected_sub: raise UnexpectedResponse( f"Expected SUB=0x{expected_sub:02X}, got 0x{frame.sub:02X}" ) def _drain_boot_string(self, drain_ms: int = 200) -> None: """ Read and discard any boot-string bytes ("Operating System") the device may send before entering binary protocol mode. We simply read with a short timeout and throw the bytes away. The S3FrameParser's IDLE state already handles non-frame bytes gracefully, but it's cleaner to drain them explicitly before the first real frame. """ deadline = time.monotonic() + (drain_ms / 1000) discarded = 0 while time.monotonic() < deadline: chunk = self._transport.read(256) if chunk: discarded += len(chunk) else: time.sleep(0.005) if discarded: log.debug("drain_boot_string: discarded %d bytes", discarded)