diff --git a/docs/instantel_protocol_reference.md b/docs/instantel_protocol_reference.md index 2890234..3f8b962 100644 --- a/docs/instantel_protocol_reference.md +++ b/docs/instantel_protocol_reference.md @@ -1,4 +1,4 @@ -# Instantel MiniMate Plus — Blastware RS-232 Protocol Reference v0.22 +# Instantel MiniMate Plus — Blastware RS-232 Protocol Reference ### "The Rosetta Stone" > Reverse-engineered via RS-232 serial bridge sniffing between Blastware software and an Instantel MiniMate Plus seismograph (S/N: BE18189). > Cross-referenced against Instantel MiniMate Plus Operator Manual (716U0101 Rev 15) from v0.18 onward. @@ -54,6 +54,8 @@ | 2026-03-11 | §14, Appendix B | **PARTIAL — Aux Trigger write path:** Write command not yet isolated. The BW→S3 write appears to occur inside the A4 (POLL_RESPONSE) stream via inner frame handshaking — multiple WRITE_CONFIRM_RESPONSE inner frames (SUBs `7C`, `7D`, `8B`, `8C`, `8D`, `8E`, `96`, `97`) appeared in A4 after the write, and the TRIGGER_CONFIG_RESPONSE (SUB `E3`) inner frames were removed. Write command itself not yet captured in a clean session — likely SUB `15` or embedded in the partial session 0. Write path deferred for a future clean capture. | | 2026-03-11 | §4, §14 | **NEW — SUB A4 is a composite container frame:** A4 (POLL_RESPONSE) payload contains multiple embedded inner frames using the same DLE framing (10 02 start, 10 03 end, 10 10 stuffing). Phase-shift diffing issue resolved in s3_analyzer.py by adding `_extract_a4_inner_frames()` and `_diff_a4_payloads()` — diff count reduced from 2300 → 17 meaningful entries. | | 2026-03-11 | §14 | **NEW — SUB `6E` response anomaly:** BW sends SUB `1C` (TRIGGER_CONFIG_READ) and S3 responds with SUB `6E` — does NOT follow the `0xFF - SUB` rule (`0xFF - 0x1C = 0xE3`). Only known exception to the response pairing rule observed to date. SUB `6E` payload starts with ASCII string `"Long2"`. | +| 2026-03-12 | §11 | **CONFIRMED — BW→S3 large-frame checksum algorithm:** SUBs `68`, `69`, `71`, `82`, and `1A` (with data) use: `chk = (sum(b for b in payload[2:-1] if b != 0x10) + 0x10) % 256` — SUM8 of payload bytes `[2:-1]` skipping all `0x10` bytes, plus `0x10` as a constant, mod 256. Validated across 20 frames from two independent captures with differing string content (checksums differ between sessions, both validate correctly). Small frames (POLL, read commands) continue to use plain SUM8 of `payload[0:-1]`. The two formulas are consistent: small frames have exactly one `0x10` (CMD at `[0]`), which the large-frame formula's `[2:]` start and `+0x10` constant account for. | +| 2026-03-12 | §11 | **RESOLVED — BAD CHK false positives on BW POLL frames:** Parser bug — BW frame terminator (`03 41`, ETX+ACK) was being included in the de-stuffed payload instead of being stripped as framing. BW frames end with bare `0x03` (not `10 03`). Fix: strip trailing `03 41` from BW payloads before checksum computation. | --- @@ -715,7 +717,32 @@ ESCAPE: --- ## 11. Checksum Reference Implementation -> ⚠️ **Updated 2026-02-26** — Rewritten for correct DLE framing and byte stuffing. +> ⚠️ **Updated 2026-03-12** — BW→S3 large-frame checksum algorithm confirmed. Two distinct formulas apply depending on frame direction and size. + +### Checksum Overview + +| Direction | Frame type | Formula | Coverage | +|---|---|---|---| +| S3→BW | All frames | `sum(payload) & 0xFF` | All de-stuffed payload bytes `[0:-1]` | +| BW→S3 | Small frames (POLL, read cmds) | `sum(payload) & 0xFF` | All de-stuffed payload bytes `[0:-1]` | +| BW→S3 | Large write frames (SUB `68`,`69`,`71`,`82`,`1A`+data) | See formula below | De-stuffed payload bytes `[2:-1]`, skipping `0x10` bytes, plus constant | + +### BW→S3 Large-Frame Checksum Formula + +```python +def calc_checksum_bw_large(payload: bytes) -> int: + """ + Checksum for large BW→S3 write frames (SUB 68, 69, 71, 82, 1A with data). + + Formula: sum all bytes in payload[2:-1], skipping 0x10 bytes, add 0x10, mod 256. + Confirmed across 20 frames from two independent captures (2026-03-12). + """ + return (sum(b for b in payload[2:-1] if b != 0x10) + 0x10) & 0xFF +``` + +**Why this formula:** The CMD byte at `payload[0]` is always `0x10` (DLE). The byte at `payload[1]` is always `0x00`. Starting from `payload[2]` skips both. All `0x10` bytes in the data section are excluded from the sum, then `0x10` is added back as a constant — effectively treating DLE as a transparent/invisible byte in the checksum. This is consistent with `0x10` being a framing/control character in the protocol. + +**Consistency check:** For small frames, `payload[0]` = `0x10` and there are no other `0x10` bytes in the payload. The large-frame formula applied to a small frame would give `(sum(payload[2:-1]) + 0x10) = sum(payload[0:-1])` — identical to the plain SUM8. The two formulas converge for frames without embedded `0x10` data bytes. ```python DLE = 0x10 @@ -748,14 +775,27 @@ def destuff(data: bytes) -> bytes: return bytes(out) -def calc_checksum(payload: bytes) -> int: +def calc_checksum_s3(payload: bytes) -> int: """ - 8-bit sum of de-stuffed payload bytes, modulo 256. - Pass the original (pre-stuff) payload — not the wire bytes. + Standard SUM8: used for all S3→BW frames and small BW→S3 frames. + Sum of all payload bytes (excluding the checksum byte itself), mod 256. """ return sum(payload) & 0xFF +def calc_checksum_bw_large(payload: bytes) -> int: + """ + Large BW→S3 write frame checksum (SUB 68, 69, 71, 82, 1A with data). + Sum payload[2:-1] skipping 0x10 bytes, add 0x10, mod 256. + """ + return (sum(b for b in payload[2:-1] if b != 0x10) + 0x10) & 0xFF + + +# Backwards-compatible alias +def calc_checksum(payload: bytes) -> int: + return calc_checksum_s3(payload) + + def build_frame(payload: bytes) -> bytes: """ Build a complete on-wire frame from a raw payload. diff --git a/parsers/s3_parser.py b/parsers/s3_parser.py index e76a691..bfec36b 100644 --- a/parsers/s3_parser.py +++ b/parsers/s3_parser.py @@ -109,6 +109,28 @@ def _try_validate_sum8(body: bytes) -> Optional[Tuple[bytes, bytes, str]]: return None +def _try_validate_sum8_large(body: bytes) -> Optional[Tuple[bytes, bytes, str]]: + """ + Large BW->S3 write frame checksum (SUBs 68, 69, 71, 82, 1A with data). + + Formula: (sum(b for b in payload[2:-1] if b != 0x10) + 0x10) & 0xFF + - Starts from byte [2], skipping CMD (0x10) and DLE (0x10) at [0][1] + - Skips all 0x10 bytes in the covered range + - Adds 0x10 as a constant offset + - body[-1] is the checksum byte + + Confirmed across 20 frames from two independent captures (2026-03-12). + """ + if len(body) < 3: + return None + payload = body[:-1] + chk = body[-1] + calc = (sum(b for b in payload[2:] if b != 0x10) + 0x10) & 0xFF + if calc == chk: + return payload, bytes([chk]), "SUM8_LARGE" + return None + + def _try_validate_crc16(body: bytes) -> Optional[Tuple[bytes, bytes, str]]: """ body = payload + crc16(2 bytes) @@ -137,11 +159,16 @@ def validate_bw_body_auto(body: bytes) -> Optional[Tuple[bytes, bytes, str]]: Try to interpret the tail of body as a checksum in several ways. Return (payload, checksum_bytes, checksum_type) if any match; else None. """ - # Prefer SUM8 first (it fits small frames and is cheap) + # Prefer plain SUM8 first (small frames: POLL, read commands) hit = _try_validate_sum8(body) if hit: return hit + # Large BW->S3 write frames (SUBs 68, 69, 71, 82, 1A with data) + hit = _try_validate_sum8_large(body) + if hit: + return hit + # Then CRC16 variants hit = _try_validate_crc16(body) if hit: