Files
seismo-relay/micromate/framing.py
T
serversdownandClaude Opus 5 5fe99568a2 feat(micromate): framing layer -- and three spec rules the bytes refuted
Step 1 of docs/micromate_client_spec.md: micromate/framing.py plus 31
offline tests.  Every rule was checked against the captures BEFORE being
written, which is the only reason this commit is not a bug.

Three things the spec asserted are wrong, all of which fail silently:

1. Requests are NOT plain Series III frames.  A Micromate escapes four
   byte values -- 0x02 0x03 0x04 0x10 -- where Series III escapes one.
   minimateplus.build_bw_frame reproduces 161 of Thor's 218 captured read
   frames; build_request() reproduces 218/218.  The 57 it missed include
   EVERY 0x5A download (offset 0x0400 puts a literal 0x04 in offset_hi)
   and the scheduler enable.  An unescaped 0x03/0x04 terminates the frame,
   so the unit never answers -- indistinguishable from a dead unit, and
   event download would have hit it on the first request ever sent.

2. The checksum is plain SUM8 of the destuffed payload, not the DLE-aware
   variant.  251/251 both directions.  The DLE-aware form is correct
   paired with Series III destuffing, which leaves an escaped byte as two
   bytes; after uniform destuffing it subtracts the correction twice and
   disagrees with the wire on 55 of 251 responses.

   scratch/mm_frame_parse.py shipped with exactly that pairing and looked
   clean only because it accepts either rule -- so it labelled those 55
   "SUM8" and never flagged one bad.  "Zero bad checksums" was true and
   carried no information.  A tool that tries N candidate rules cannot
   falsify any of them.  Fixed to validate against SUM8 alone.

3. SUB 0x5A is a 1024-byte chunk loop, not one request per event.  Thor's
   form, verified on all six bench events (4,076 -> 13,424 B): chunks =
   ceil(size/1024), offset = min(1024, size - 1024*i) as a byte count,
   params[2:4] = the byte offset, response data = offset + 11.
   sum(offsets) == size exactly, every time.

   This does not retract the earlier single-request observation -- that
   used offset_hi = 0x10, which in Series III is the bulk-stream marker,
   so it is plausibly a distinct streaming mode returning several frames.
   Those captures never landed in the repo, so it cannot be re-derived.
   Implement Thor's form; the other is worth one bench test.

Also: a 0x10 inside request params needs no special handling (settled --
Thor sends it, the wire doubles it), so the planned NotImplementedError
guard is gone.  declared_length -> probe_length, because it is only
meaningful in a probe reply and Thor never probes.

Synthesised test frames are marked and each says what it stands in for.
The flags=0x03 case is the only coverage of the Thor firmware line -- it
wants a real 11.0BD capture next time UM20147 is on a bench.

No writes.  Read-path framing only; nothing here can originate a command.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ru8Lg9HkkYvX9VWWo65SmL
2026-09-27 05:24:24 -04:00

305 lines
13 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.
"""
framing.py — frame codec for the Instantel Micromate (Series IV) wire protocol.
A Micromate answers Series III *command* frames, so the request side looks
familiar. The framing underneath is not the same, and the differences are all
of the kind that produce a silently-ignored frame rather than an error:
Series III response: [DLE 0x10] [STX 0x02] … [chk] [ETX 0x03]
Micromate response: [STX 0x02] … [chk] [ETX 0x03]
^ no leading DLE
That missing byte is why `minimateplus.framing.S3FrameParser` returns *nothing*
on Micromate traffic — it locates frames by scanning for `DLE STX`, which never
occurs. A capture holding 12 acknowledged writes reads as 12 unanswered
requests.
De-stuffed payload layout (both directions):
request response
[0] CMD 0x10 [0] CMD 0x00
[1] flags 0x00 [1] flags 0xC5 / 0x03 ← firmware line
[2] SUB [2] SUB 0xFF − request_SUB
[3] 0x00 [3] PAGE_HI
[4] offset_hi [4] PAGE_LO
[5] offset_lo [5+] data
[6:16] params (10 bytes)
Everything below was established against the 251 request and 251 response
frames in `bridges/captures/9-24-26 - micromate2/` (UM12947, firmware 11.0CB).
Where a rule is asserted, the number of frames it was checked on is given — the
two rules that look like small details cost 26% and 22% of frames respectively
when guessed wrong, so the counts are the point.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
# ── Protocol byte constants ───────────────────────────────────────────────────
DLE = 0x10 # Data Link Escape
STX = 0x02 # Start of text — begins a frame
ETX = 0x03 # End of text — ends a frame
ACK = 0x41 # Frame-start marker on the request side
MM_CMD = 0x10 # payload[0] in a request
MM_RSP_CMD = 0x00 # payload[0] in a response
# payload[1] of a response identifies the firmware line it came from.
# ⚠ Two units, one of each — a strong hypothesis, not a proven encoding.
FLAGS_BLASTWARE = 0xC5 # the 11.0CB line (UM12947)
FLAGS_THOR = 0x03 # the 11.0BD line (UM20147)
# ⚠ THE ESCAPE SET. A Micromate escapes exactly these four byte values,
# prefixing each with a DLE — and nothing else. Established by re-stuffing
# every captured frame and comparing to the wire: 251/251 responses and 251/251
# requests reproduce byte-for-byte with this set, and no other candidate set
# reproduces even 200 of either.
#
# The two near-misses are worth naming, because both look plausible:
# * `{0x10}` alone — the Series III rule — reproduces 130/251 responses and
# 177/251 requests.
# * adding ACK (0x41) reproduces only 196/251 responses: a literal 0x41 in
# the data is NOT escaped.
_ESCAPED = frozenset({STX, ETX, 0x04, DLE})
# A response header is 5 bytes; a frame must also carry its checksum.
_MIN_PAYLOAD = 5
_REQUEST_PAYLOAD_SIZE = 16
# ── Stuffing ──────────────────────────────────────────────────────────────────
def stuff(data: bytes) -> bytes:
"""Escape every byte the Micromate escapes: `XX` → `10 XX` for the four."""
out = bytearray()
for b in data:
if b in _ESCAPED:
out.append(DLE)
out.append(b)
return bytes(out)
def unstuff(data: bytes) -> bytes:
"""Reverse `stuff()`: `10 XX` → `XX`, for any XX.
Uniform, with no inner-frame carve-out — which is a real simplification
over Series III, where `DLE+ETX` inside a frame is literal data that must
survive de-stuffing. Since only four byte values are ever escaped, taking
*any* `10 XX` as `XX` is exact rather than merely convenient.
"""
out = bytearray()
i = 0
while i < len(data):
if data[i] == DLE and i + 1 < len(data):
out.append(data[i + 1])
i += 2
else:
out.append(data[i])
i += 1
return bytes(out)
# ── Checksum ──────────────────────────────────────────────────────────────────
def checksum(payload: bytes) -> int:
"""SUM8 of the **de-stuffed** payload, mod 256. 251/251 both directions.
⚠ Do NOT exclude `0x10` bytes from this sum. The DLE-aware checksum that
Series III uses for its `5A` and write frames is the right answer to a
*different* question: it pairs with Series III de-stuffing, which leaves
escaped bytes in the payload as two bytes. De-stuffing uniformly already
removes the DLE, so excluding `0x10` as well subtracts the correction
twice.
That combination — uniform de-stuffing *and* an exclusive sum — is what
`scratch/mm_frame_parse.py` shipped with. It disagrees with the wire on
**55 of 251** captured response frames, all of them frames whose payload
holds a literal `0x10`. The script only ever looked correct because it
accepts a frame that matches *either* rule, so it reported those 55 as
plain SUM8 and never flagged one bad.
"""
return sum(payload) & 0xFF
# ── Request builder ───────────────────────────────────────────────────────────
def build_request(sub: int, offset: int = 0, params: bytes = bytes(10)) -> bytes:
"""Build a host→unit command frame.
⚠ Do **not** substitute `minimateplus.framing.build_bw_frame()` here, even
though the payload layout is identical. That builder escapes only `0x10`,
so it reproduces just **161 of Thor's 218** captured read frames. The 57 it
gets wrong are not edge cases:
* every `SUB 0x5A` bulk download — `offset = 0x0400` puts a literal
`0x04` in `offset_hi`, which must go out as `10 04`
* `SUB 0x47` (scheduler enable), whose params carry a `0x03`
An unescaped `0x03` or `0x04` reads as a frame terminator, so the unit sees
a truncated frame and simply does not answer. That is indistinguishable
from a dead unit, and event download would have hit it on the first try.
With the correct escape set this builder reproduces **218/218**.
Args:
sub: command SUB byte.
offset: uint16 at payload[4:5]. Micromate reads are single-step —
Thor asks for `0xFFFF` and gets the whole block — so this is
usually `0xFFFF`, not Series III's probe-then-data pair.
params: exactly 10 bytes at payload[6:16].
A `0x10` inside `params` is fine and needs no special handling: Thor sends
`SUB 0x5A` with `params = 00 00 10 00 …` and the wire carries `10 10`.
(This was the spec's one open question; five captured frames settle it.)
"""
if len(params) != 10:
raise ValueError(f"params must be exactly 10 bytes, got {len(params)}")
if not 0 <= offset <= 0xFFFF:
raise ValueError(f"offset must fit in uint16, got {offset:#x}")
if not 0 <= sub <= 0xFF:
raise ValueError(f"sub must be a single byte, got {sub:#x}")
payload = bytes([MM_CMD, 0x00, sub, 0x00, (offset >> 8) & 0xFF, offset & 0xFF]) + params
body = payload + bytes([checksum(payload)])
return bytes([ACK, STX]) + stuff(body) + bytes([ETX])
# ── Response frame ────────────────────────────────────────────────────────────
@dataclass
class MicromateFrame:
"""A parsed, de-stuffed unit→host response frame."""
sub: int # response SUB; the request was 0xFF − this
flags: int # payload[1] — 0xC5 Blastware line, 0x03 Thor line
page_hi: int
page_lo: int
data: bytes # payload[5:], checksum stripped
checksum_valid: bool
chk_byte: int = 0 # the checksum byte as received
@property
def request_sub(self) -> int:
"""The SUB this is answering. No known exception to `0xFF − SUB`."""
return 0xFF - self.sub
@property
def page_key(self) -> int:
"""payload[3:5] as a uint16 BE — a page/address on `0x5A` responses."""
return (self.page_hi << 8) | self.page_lo
@property
def firmware_line(self) -> str:
return {FLAGS_BLASTWARE: "blastware", FLAGS_THOR: "thor"}.get(self.flags, "unknown")
@property
def probe_length(self) -> Optional[int]:
"""Data length declared by a **probe** response: uint16 BE at data[3:5].
⚠ Only meaningful in the reply to an `offset = 0` probe. Series III
hardcodes a `DATA_LENGTHS` table; a Micromate will tell you instead,
which already caught one divergence (call-home config is `0x7E`, where
Series III has `0x7C`).
⚠ It is a **uint16 BE**, not a byte. Read as `data[3]` alone it is
right only while the high byte is zero, and wrong by 47x for
`SUB 0x1A`: a true `0x082C` (2092) reads as 44.
Returns None on a frame too short to hold the field. Note this reads
as 0 on the single-step reads Thor actually uses — those are not probes,
and `page_key` is the meaningful field there.
"""
if len(self.data) < 5:
return None
return (self.data[3] << 8) | self.data[4]
# ── Streaming parser ──────────────────────────────────────────────────────────
class MicromateFrameParser:
"""Incremental parser for unit→host frames. Mirrors `S3FrameParser`.
Feed bytes with `feed()`; completed frames are returned and also collected
in `.frames`.
IDLE — scanning for a bare STX
IN_FRAME — collecting; bare ETX terminates
AFTER_DLE — the next byte is literal, whatever it is
Request frames are rejected rather than parsed: a frame whose `payload[0]`
is not `0x00` is dropped, so feeding a bidirectional capture yields only
the responses.
"""
_IDLE, _IN_FRAME, _AFTER_DLE = 0, 1, 2
def __init__(self) -> None:
self._state = self._IDLE
self._body = bytearray()
self.frames: list[MicromateFrame] = []
# Distinguishes "no bytes at all" from "bytes but no complete frame" on
# a timeout. That distinction earned its keep during the Series III
# work and costs one integer here.
self.bytes_fed: int = 0
def reset(self) -> None:
self._state = self._IDLE
self._body.clear()
self.bytes_fed = 0
def feed(self, data: bytes) -> list[MicromateFrame]:
self.bytes_fed += len(data)
completed: list[MicromateFrame] = []
for b in data:
frame = self._step(b)
if frame is not None:
completed.append(frame)
self.frames.append(frame)
return completed
def _step(self, b: int) -> Optional[MicromateFrame]:
if self._state == self._IDLE:
if b == STX:
self._body.clear()
self._state = self._IN_FRAME
# Boot strings, modem RING/CONNECT chatter and stray ACKs land here
# and are discarded.
elif self._state == self._IN_FRAME:
if b == DLE:
self._state = self._AFTER_DLE
elif b == ETX:
self._state = self._IDLE
return self._finalise()
else:
self._body.append(b)
elif self._state == self._AFTER_DLE:
# Uniform rule: the escaped byte is itself, including 0x03.
self._body.append(b)
self._state = self._IN_FRAME
return None
def _finalise(self) -> Optional[MicromateFrame]:
body = bytes(self._body)
if len(body) < _MIN_PAYLOAD + 1:
return None
payload, chk_received = body[:-1], body[-1]
if payload[0] != MM_RSP_CMD:
return None # a request frame, or garbage that framed by accident
return MicromateFrame(
sub = payload[2],
flags = payload[1],
page_hi = payload[3],
page_lo = payload[4],
data = payload[5:],
checksum_valid = (chk_received == checksum(payload)),
chk_byte = chk_received,
)