311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""
|
|
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)
|