From a27693242db41d11ba7dd5303455b068453d4a75 Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Tue, 5 May 2026 18:28:28 -0400 Subject: [PATCH] fix(protocol): implement partial DLE stuffing for 0x10 bytes in params to prevent request corruption --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 29 +++++++++++++++++++++--- minimateplus/framing.py | 34 +++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01c0dde..29e50d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ All notable changes to seismo-relay are documented here. --- +## v0.14.3 — 2026-05-05 + +### Fixed + +- **`build_5a_frame` — DLE-stuffing rule for 0x10 bytes in params (the + long-standing >1-sec event 0 "won't open in BW" bug).** + + Previously `build_5a_frame` wrote params bytes RAW with no DLE stuffing, + based on the incorrect assumption that the device handled all `0x10` + bytes in params literally. It does not. The device's actual de-stuffing + rule for the params region is: + + - `10 10` → de-stuffs to `10` + - `10 02/03/04` → kept literal (inner-frame markers) + - `10 X` for other X → de-stuffs to just `X` (drops the `0x10`) + + When the counter passed in params has `0x10` in the high byte (e.g. + counter=`0x1000` produces params bytes `... 10 00 ...`), the device + silently corrupts the request to counter=`0x__00` and responds with + whatever lives at that wrong address. For counter=0x1000 the wrong + address was 0x0000, so the response was a copy of the file header + + STRT record. That STRT block then got embedded in the assembled body + at file offset `0x1016`, and Blastware refused to open the file + (interprets the second STRT as a malformed multi-event file). + + This explains the entire >1-sec event-0 failure pattern: + + - 1-sec events have `end_offset < 0x1000`, so the chunk walk never + requests counter `0x10__` and the bug never triggers. + - 2-sec / 3-sec / longer events all need a chunk at counter `0x1000` + (and longer events also need `0x1200`, `0x1400`, etc., none of which + have `0x10` in the high byte except `0x1000`). Just one corrupted + response is enough to embed STRT in the body and break the file. + + Verified against BW 5-1-26 "copy 3sec" capture: all 17 5A request + frames (probe + 2 metadata pages + 13 sample chunks + TERM) now match + BW's wire output **byte-for-byte**, including the doubled `10 10 00` + for counter=0x1000. + +### Notes + +- `0x10` bytes in `offset_hi` (the standalone offset field at body[5]) + are still written RAW — confirmed correct per the 1-2-26 capture. +- BW's actual encoding of `10 02` / `10 04` for meta pages 0x1002 / + 0x1004 is *not* doubled — it relies on the device keeping `10 02` + and `10 04` as literal pairs. This is preserved by the fix. + +--- + ## v0.14.2 — 2026-05-04 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 6894e6a..0675bd7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ Ground-up Python replacement for **Blastware**, Instantel's Windows-only software for managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem -(Sierra Wireless RV50 / RV55). Current version: **v0.14.2**. +(Sierra Wireless RV50 / RV55). Current version: **v0.14.3**. When new information about the protocol is discovered, please update the instantel_protocol_reference.md with the findings in addition to this document @@ -115,8 +115,31 @@ S3→BW (response): section contribute only `XX` to the running sum; lone bytes contribute normally. This differs from the standard SUM8-of-destuffed-payload that all other commands use. -Both differences confirmed by reproducing Blastware's exact wire bytes from the 1-2-26 -BW TX capture. All 10 frames verified. +3. **Params region uses partial DLE stuffing (CONFIRMED 2026-05-05).** The device's + de-stuffing rule for bytes inside the params region is: + + - `10 10` → de-stuffs to `10` + - `10 02 / 03 / 04` → kept literal (these are inner-frame markers) + - `10 X` for other X → de-stuffs to just `X` (drops the leading `0x10`) + + Therefore any `0x10` byte in the *logical* params that is followed by a byte NOT in + `{0x02, 0x03, 0x04, 0x10}` MUST be doubled on the wire (`10 X` → `10 10 X`) so the + device's de-stuffer reproduces the original `10 X` pair. This applies most commonly + to counters with `0x10` in the high byte (e.g. counter=`0x1000` produces logical + params bytes `... 10 00 ...`, which BW encodes on the wire as `... 10 10 00 ...`). + Without this stuffing the device interprets counter=`0x1000` as `0x0000` and returns + the probe response (which contains a copy of the file header + STRT record). That + STRT block then gets embedded in the assembled file body at offset `0x1016`, and + Blastware refuses to open the file — see the v0.14.3 entry in `CHANGELOG.md`. + + `0x10` bytes in `offset_hi` (body[5]) are still written RAW — only the params region + has this stuffing requirement. The metadata-page params for counter `0x1002` / + `0x1004` survive without stuffing because `10 02` and `10 04` fall in the "kept + literal" carve-out. + +Both differences (1) and (2) confirmed by reproducing Blastware's exact wire bytes from +the 1-2-26 BW TX capture (10 frames). Difference (3) confirmed against the 5-1-26 +"bwcap3sec" capture (17 frames, all match byte-for-byte after fix). ### SUB 5A — chunk counter formula (REWRITTEN 2026-05-01 — see 5-1-26 captures) diff --git a/minimateplus/framing.py b/minimateplus/framing.py index 2011cc9..e26e0f0 100644 --- a/minimateplus/framing.py +++ b/minimateplus/framing.py @@ -137,8 +137,40 @@ def build_5a_frame(offset_word: int, raw_params: bytes) -> bytes: s += b"\x00" # field3 s += bytes([(offset_word >> 8) & 0xFF, # offset_hi — raw, NOT stuffed offset_word & 0xFF]) # offset_lo - for b in raw_params: # params — NOT DLE-stuffed (raw bytes, match BW wire format) + # Params — partial DLE stuffing of 0x10 bytes (CONFIRMED 2026-05-05). + # + # The device's de-stuffing rule for params is: + # • `10 10` → de-stuffs to `10` + # • `10 02/03/04` → kept literal (these are inner-frame markers) + # • `10 X` other → de-stuffs to just `X` (drops the 0x10) + # + # So for any 0x10 byte in the *logical* params that is followed by a + # byte NOT in {0x02, 0x03, 0x04, 0x10}, we must double the 0x10 on the + # wire (`10 X` → `10 10 X`) so the device's de-stuffer reproduces the + # original `10 X` pair. Without this, counter values with `0x10` in + # the high byte (e.g. counter=0x1000 has params bytes `10 00`) are + # silently corrupted to `0x__00` on the device side, and the device + # responds for the wrong address — for counter=0x1000 it returns the + # probe response (counter=0x0000), which contains the file header + + # STRT. That STRT block then lands in the assembled file body and + # Blastware rejects the file as malformed. + # + # Confirmed against BW capture 5-1-26 / bwcap3sec frame 20: params + # logical bytes `00 01 11 10 00 00 00 00 00 00 00` (counter=0x1000) + # are encoded on the wire as `00 01 11 10 10 00 00 00 00 00 00 00`. + # BW frames 13/14 (meta @ 0x1002 / 0x1004) leave `10 02` and `10 04` + # raw — the device handles those literal pairs correctly. + i = 0 + while i < len(raw_params): + b = raw_params[i] s.append(b) + if ( + b == 0x10 + and i + 1 < len(raw_params) + and raw_params[i + 1] not in (0x02, 0x03, 0x04, 0x10) + ): + s.append(0x10) # double the 0x10 so it survives device de-stuffing + i += 1 # DLE-aware checksum: for 0x10 XX pairs count XX; for lone bytes count them chk, i = 0, 0