diff --git a/docs/instantel_protocol_reference.md b/docs/instantel_protocol_reference.md index bc0d16a..2890234 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 +# Instantel MiniMate Plus — Blastware RS-232 Protocol Reference v0.22 ### "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. @@ -50,6 +50,10 @@ | 2026-03-09 | §7.8, §14, Appendix B | **NEW — Trigger Sample Width confirmed:** Located in BW→S3 write frame SUB `0x82`, destuffed payload offset `[22]`, uint8. Confirmed via BW-side capture (`raw_bw.bin`) diffing two sessions: Width=4 → `0x04`, Width=3 → `0x03`. Setting is **transmitted only on BW→S3 write** (SUB `0x82`), invisible in S3-side compliance dumps. | | 2026-03-09 | §14, Appendix B | **CONFIRMED — Mode gating is a real protocol behavior:** Several settings are only transmitted (and possibly only interpreted by the device) when the required mode is active. Trigger Sample Width is only sent when in Compliance/Single-Shot/Fixed Record Time mode. Auto Window is only relevant when Record Stop Mode = Auto — attempting to capture it in Fixed mode produced no change on the wire (F7 and D1 blocks identical before/after). This is an architectural property, not a gap in the capture methodology. Future capture attempts for mode-gated settings must first activate the appropriate mode. | | 2026-03-09 | §14 | **UPDATED — Auto Window:** Capture attempted (Auto Window 3→9) in Fixed record time mode. No change observed in any S3-side frame (F7, D1, E5 all identical). Confirmed mode-gated behind Record Stop Mode = Auto. Not capturable without switching modes — deferred. | +| 2026-03-11 | §14, Appendix B | **CONFIRMED — Aux Trigger read location:** SUB `FE` (FULL_CONFIG_RESPONSE), destuffed payload offset `0x0109`, uint8. `0x00` = disabled, `0x01` = enabled. Confirmed via controlled capture: changed Aux Trigger in Blastware, sent to unit, re-read config. FE diff showed clean isolated flip at `0x0109` with only 3 other bytes changing (likely counters/checksums at `0x0033`, `0x00C0`, `0x04ED`). | +| 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"`. | --- @@ -293,7 +297,9 @@ Write commands are initiated by Blastware (`BW->S3`) and use SUB bytes in the `0 ## 7. Known Data Payloads -### 7.1 Poll Response (SUB A4) — Device Identity Block +### 7.1 Poll Response (SUB A4) — Device Identity Block / Composite Container + +> ⚠️ **SUB A4 is a composite container frame.** The large A4 payload (~3600+ bytes) contains multiple embedded inner sub-frames using the same DLE framing as the outer protocol (`10 02` start, `10 03` end, `10 10` stuffing). Inner frames carry WRITE_CONFIRM_RESPONSE and TRIGGER_CONFIG_RESPONSE sub-frames among others. Flat byte-by-byte diffing of A4 is unreliable due to phase shifting — use inner-frame-aware diffing (`_diff_a4_payloads()` in s3_analyzer.py). Confirmed 2026-03-11. Two-step read. Data payload = 0x30 bytes. @@ -883,7 +889,9 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger | Meaning of `0x07 E7` field in config block | LOW | 2026-02-26 | | | **Trigger Sample Width** — **RESOLVED:** BW→S3 write frame SUB `0x82`, destuffed payload offset `[22]`, uint8. Width=4 → `0x04`, Width=3 → `0x03`. Confirmed via BW-side capture diff. Only visible in `raw_bw.bin` write traffic, not in S3-side compliance reads. | RESOLVED | 2026-03-02 | Confirmed 2026-03-09 | | **Auto Window** — "1 to 9 seconds" per manual (§3.13.1b). **Mode-gated:** only transmitted/active when Record Stop Mode = Auto. Capture attempted in Fixed mode (3→9 change) — no wire change observed in any frame. Deferred pending mode switch. | LOW | 2026-03-02 | Updated 2026-03-09 | -| **Auxiliary Trigger** — Enabled/Disabled per manual (§3.13.1d). Location in protocol not yet mapped. | LOW | 2026-03-02 | NEW | +| **Auxiliary Trigger read location** — **RESOLVED:** SUB `FE` offset `0x0109`, uint8, `0x00`=disabled, `0x01`=enabled. Confirmed 2026-03-11 via controlled toggle capture. | RESOLVED | 2026-03-02 | Resolved 2026-03-11 | +| **Auxiliary Trigger write path** — Write command not yet captured in a clean session. Inner frame handshake visible in A4 (multiple WRITE_CONFIRM_RESPONSE SUBs appear, TRIGGER_CONFIG_RESPONSE removed), but the BW→S3 write command itself was in a partial session. Likely SUB `15` or similar. Deferred for clean capture. | LOW | 2026-03-11 | NEW | +| **SUB `6E` response to SUB `1C`** — S3 responds to TRIGGER_CONFIG_READ (SUB `1C`) with SUB `6E`, NOT `0xE3` as the `0xFF - SUB` rule would predict. Only known exception to the response pairing rule observed to date. Payload starts with ASCII `"Long2"`. Purpose unknown. | LOW | 2026-03-11 | NEW | | **Max Geo Range float 6.2061 in/s** — NOT a user-selectable range (manual only shows 1.25 and 10.0 in/s). Likely internal ADC full-scale constant or hardware range ceiling. Not worth capturing. | LOW | 2026-02-26 | Downgraded 2026-03-02 | | MicL channel units — **RESOLVED: psi**, confirmed from `.set` file unit string `"psi\0"` | RESOLVED | 2026-03-01 | | | Backlight offset — **RESOLVED: +4B in event index data**, uint8, seconds | RESOLVED | 2026-03-02 | | @@ -914,7 +922,7 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger | Record Mode | §3.8.1 | Unknown | — | Single Shot, Continuous, Manual, Histogram, Histogram Combo | | Trigger Sample Width | §3.13.1h | BW→S3 SUB `0x82` write frame, destuffed `[22]`, uint8 | uint8 | Default=2; confirmed 4=`0x04`, 3=`0x03`. **BW-side write only** — not visible in S3 compliance reads. Mode-gated: only sent in Compliance/Single-Shot/Fixed mode. | | Auto Window | §3.13.1b | **Mode-gated — NOT YET MAPPED** | uint8? | 1–9 seconds; only active when Record Stop Mode = Auto. Capture in Fixed mode produced no wire change. | -| Auxiliary Trigger | §3.13.1d | **NOT YET MAPPED** | bool | Enabled/Disabled | +| Auxiliary Trigger | §3.13.1d | SUB `FE` (FULL_CONFIG_RESPONSE) offset `0x0109` (read); write path not yet isolated | uint8 (bool) | `0x00`=disabled, `0x01`=enabled; confirmed 2026-03-11 | | Password | §3.13.1c | Unknown | — | 4-key sequence | | Serial Connection | §3.9.11 | Unknown | — | Direct / Via Modem | | Baud Rate | §3.9.12 | Unknown | — | 38400 for direct | diff --git a/parsers/s3_analyzer.py b/parsers/s3_analyzer.py index b17d2bc..c86477d 100644 --- a/parsers/s3_analyzer.py +++ b/parsers/s3_analyzer.py @@ -140,6 +140,15 @@ class Session: index: int bw_frames: list[AnnotatedFrame] s3_frames: list[AnnotatedFrame] + # None = infer from SUB 0x74 presence; True/False = explicitly set by splitter + complete: Optional[bool] = None + + def is_complete(self) -> bool: + """A session is complete if explicitly marked, or if it contains SUB 0x74.""" + if self.complete is not None: + return self.complete + return any(af.header is not None and af.header.sub == SESSION_CLOSE_SUB + for af in self.bw_frames) @property def all_frames(self) -> list[AnnotatedFrame]: @@ -384,6 +393,7 @@ def split_sessions_at_marks( session_offset = 0 bw_prev = s3_prev = 0 + n_segments = len(bw_cuts) for seg_i, (bw_end, s3_end) in enumerate(zip(bw_cuts, s3_cuts)): bw_chunk = bw_blob[bw_prev:bw_end] s3_chunk = s3_blob[s3_prev:s3_end] @@ -394,11 +404,20 @@ def split_sessions_at_marks( seg_sessions = split_into_sessions(bw_frames, s3_frames) + # A mark-bounded segment is complete by definition — the user placed the + # mark after the read finished. Only the last segment (trailing, unbounded) + # may be genuinely in-progress. + is_last_segment = (seg_i == n_segments - 1) + # Re-index sessions so they are globally unique for sess in seg_sessions: sess.index = session_offset for f in sess.all_frames: f.session_idx = session_offset + # Explicitly mark completeness: mark-bounded segments are complete; + # the trailing segment falls back to 0x74 inference. + if not is_last_segment: + sess.complete = True session_offset += 1 all_sessions.append(sess) @@ -683,11 +702,7 @@ def render_session_report( n_bw = len(session.bw_frames) n_s3 = len(session.s3_frames) total = n_bw + n_s3 - is_complete = any( - af.header is not None and af.header.sub == SESSION_CLOSE_SUB - for af in session.bw_frames - ) - status = "" if is_complete else " [IN PROGRESS]" + status = "" if session.is_complete() else " [IN PROGRESS]" lines.append(f"{'='*72}") lines.append(f"SESSION {session.index}{status}") @@ -847,11 +862,7 @@ def render_claude_export( lines += ["## Capture Summary", ""] lines.append(f"Sessions found: {len(sessions)}") for sess in sessions: - is_complete = any( - af.header is not None and af.header.sub == SESSION_CLOSE_SUB - for af in sess.bw_frames - ) - status = "complete" if is_complete else "partial/in-progress" + status = "complete" if sess.is_complete() else "partial/in-progress" n_bw, n_s3 = len(sess.bw_frames), len(sess.s3_frames) changed = len(diffs[sess.index] or []) if sess.index < len(diffs) else 0 changed_str = f" ({changed} SUBs changed vs prev)" if sess.index > 0 else " (baseline)" @@ -1119,14 +1130,7 @@ def live_loop( # Check for session close all_sessions = split_into_sessions(bw_annotated, s3_annotated) - # A complete session has the closing 0x74 - complete_sessions = [ - s for s in all_sessions - if any( - af.header is not None and af.header.sub == SESSION_CLOSE_SUB - for af in s.bw_frames - ) - ] + complete_sessions = [s for s in all_sessions if s.is_complete()] # Emit reports for newly completed sessions for sess in complete_sessions[len(sessions):]: @@ -1157,13 +1161,7 @@ def live_loop( s3_annotated = annotate_frames(s3_frames_raw, "S3") bw_annotated = annotate_frames(bw_frames_raw, "BW") all_sessions = split_into_sessions(bw_annotated, s3_annotated) - incomplete = [ - s for s in all_sessions - if not any( - af.header is not None and af.header.sub == SESSION_CLOSE_SUB - for af in s.bw_frames - ) - ] + incomplete = [s for s in all_sessions if not s.is_complete()] for sess in incomplete: report = render_session_report(sess, diffs=None, prev_session_index=None) out_path = write_report(sess, report, outdir) diff --git a/seismo_lab.py b/seismo_lab.py index 11ee77d..5f67fd1 100644 --- a/seismo_lab.py +++ b/seismo_lab.py @@ -706,9 +706,7 @@ class AnalyzerPanel(tk.Frame): def _rebuild_tree(self) -> None: self.tree.delete(*self.tree.get_children()) for sess in self.state.sessions: - is_complete = any(af.header and af.header.sub == 0x74 - for af in sess.bw_frames) - label = f"Session {sess.index}" + ("" if is_complete else " [partial]") + label = f"Session {sess.index}" + ("" if sess.is_complete() else " [partial]") n_diff = len(self.state.diffs[sess.index] or []) diff_str = f"{n_diff} changes" if n_diff else "" sid = self.tree.insert("", tk.END, text=label,