Files
seismo-relay/minimateplus/protocol.py
2026-03-31 20:48:03 -04:00

486 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)