""" 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, 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_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_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) DEFAULT_RECV_TIMEOUT = 3.0 POLL_RECV_TIMEOUT = 2.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=POLL_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=POLL_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) # ── 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 checksum and SUB, raising on failure.""" if not frame.checksum_valid: raise ChecksumError( f"Bad checksum in S3 frame SUB=0x{frame.sub:02X}" ) 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)