Files
seismo-relay/docs/instantel_protocol_reference.md
T

2551 lines
169 KiB
Markdown
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.
# 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.
> **Certainty Ratings:** ✅ CONFIRMED | 🔶 INFERRED | ❓ SPECULATIVE
> Certainty ratings apply only to protocol semantics, not to capture tooling behavior.
---
## Changelog
| Date | Section | Change |
|---|---|---|
| 2026-02-26 | Initial | Document created from first hex dump analysis |
| 2026-02-26 | §2 Frame Structure | **CORRECTED:** Frame uses DLE-STX (`0x10 0x02`) and DLE-ETX (`0x10 0x03`), not bare `0x02`/`0x03`. `0x41` confirmed as ACK not STX. DLE stuffing rule added. |
| 2026-02-26 | §8 Timestamp | **UPDATED:** Year `0x07CB = 1995` confirmed as MiniMate hardware default date when RTC battery is disconnected. Not an encoding error. Confidence upgraded from ❓ to 🔶. |
| 2026-02-26 | §10 DLE Stuffing | **UPGRADED:** Section upgraded from ❓ SPECULATIVE to ✅ CONFIRMED. Full stuffing rules and parser state machine documented. |
| 2026-02-26 | §11 Checksum | **UPDATED:** Frame builder and parser rewritten to handle DLE framing and byte stuffing correctly. |
| 2026-02-26 | §14 Open Questions | DLE question removed (resolved). Timestamp year question removed (resolved). |
| 2026-02-26 | §7.2 Serial Number Response | **CORRECTED:** Trailing bytes are `0x79 0x11` only (2 bytes, not 3). `0x20` was misidentified as a trailing byte — it is the frame checksum. |
| 2026-02-26 | §7.2 Serial Number Response | **UPDATED:** Two-unit comparison confirms `0x11` = firmware minor version (S337.**17** → `0x11` = 17). Byte 0 is unit-specific, derivation unknown. |
| 2026-02-26 | §15 Binary Log Format | **NEW:** `.bin` logger format strips DLE from ETX (`0x10 0x03``0x03`). Not raw wire bytes. |
| 2026-02-26 | §5.1 Request Commands | **ADDED:** Three new read commands confirmed: SUB `09`, `1A`, `2E`. |
| 2026-02-26 | §5.3 Write Commands | **NEW SECTION:** Full write command family documented from compliance setup capture. SUBs `68``83`. |
| 2026-02-26 | §7.5 Full Waveform Record | **UPDATED:** Project strings field layout fully mapped from write payload diff. Client field confirmed at byte +230 in SUB `71` frame. |
| 2026-02-26 | §14 Open Questions | Write commands question resolved. Session `152427` had swapped port labels — superseded by sessions `184518` and `185019`. |
| 2026-02-26 | Global | **CORRECTED:** Firmware version `S338.17``S337.17` everywhere, including hex encoding at config offset `0x34`. |
| 2026-02-26 | §3 Payload Structure | **DOWNGRADED:** ADDR field certainty `🔶 INFERRED``❓ SPECULATIVE`. Added note that bytes 12 purpose is unconfirmed. |
| 2026-02-26 | §5.2 Response SUBs | **STRENGTHENED:** `0xFF - SUB` rule wording clarified — high confidence, no counterexample, not yet formally proven. |
| 2026-02-26 | §15 → Appendix A | **RENAMED:** Binary log format section moved to Appendix A with explicit note that it describes tooling behavior, not protocol. |
| 2026-02-26 | Header | **ADDED:** Certainty legend clarification — ratings apply to protocol semantics only, not tooling behavior. |
| 2026-02-26 | §7.6 Channel Config Float Layout | **NEW SECTION:** Trigger level confirmed as IEEE 754 BE float in in/s. Alarm level identified as adjacent float = 1.0 in/s. Unit string `"in./s"` embedded inline. `0x082A` removed as trigger level candidate. |
| 2026-03-01 | §7.6 Channel Config Float Layout | **UPGRADED:** Alarm level offset fully confirmed via controlled capture (alarm 1.0→2.0, trigger 0.5→0.6). Complete per-channel layout documented. Three-channel repetition confirmed (Tran, Vert, Long). Certainty upgraded to ✅ CONFIRMED. |
| 2026-03-01 | §7.7 `.set` File Format | **NEW SECTION:** Blastware save-to-disk format decoded. Little-endian binary struct matching wire protocol payload. Full per-channel block layout mapped. Record time confirmed as uint32 at +16. MicL unit string confirmed as `"psi\0"`. `0x082A` mystery noted — not obviously record time, needs one more capture to resolve. |
| 2026-03-02 | §7.4 Event Index Block | **CONFIRMED:** Backlight and power save offsets independently confirmed via device-set capture (backlight=100=0x64 at +75, power-save=30=0x1E at +83). On-device change visible in S3→BW read response — no Blastware write involved. Offsets are ✅ CONFIRMED. |
| 2026-03-02 | §7.4 Event Index Block | **NEW:** `Monitoring LCD Cycle` identified at offsets +84/+85 as uint16 BE. Default value = 65500 (0xFFDC) = effectively disabled / maximum. Confirmed from operator manual §3.13.1g. |
| 2026-03-02 | §7.4 Event Index Block | **UPDATED:** Backlight confirmed as uint8 range 0255 seconds per operator manual §3.13.1e ("adjustable timer, 0 to 255 seconds"). Power save unit confirmed as minutes per operator manual §3.13.1f. |
| 2026-03-02 | Global | **NEW SOURCE:** Operator manual (716U0101 Rev 15) added as reference. Cross-referencing settings definitions, ranges, and units. Header updated. |
| 2026-03-02 | §14 Open Questions | Float 6.2061 in/s mystery: manual confirms only two geo ranges (1.25 in/s and 10.0 in/s). 6.2061 is NOT a user-selectable range → originally speculated as internal ADC full-scale constant, but was NOT confirmed at this time. Using it directly as the range produces ~9× PPV overread. Meaning unknown. Downgraded to LOW 2026-03-02, re-escalated to HIGH 2026-04-16. **RESOLVED 2026-04-17 — see §7.6.2 and changelog entry.** |
| 2026-03-02 | §14 Open Questions | `0x082A` hypothesis refined: 2090 decimal. At 1024 sps, 2 sec record = 2048 samples. Possible that 0x082A = total samples including 0.25s pre-trigger (256 samples) at some adjusted rate. Needs capture with different record time. |
| 2026-03-02 | §14 Open Questions | **NEW items added:** Trigger sample width (default=2), Auto Window (1-9 sec), Aux Trigger (enabled/disabled) — all confirmed settings from operator manual not yet mapped in protocol. |
| 2026-03-02 | §14 Open Questions | Monitoring LCD Cycle resolved — removed from open questions. |
| 2026-03-02 | Appendix A | **CORRECTED:** Previous entry stated logger strips DLE from ETX. This was wrong — it applied to an older logger version. `s3_bridge v0.5.0` confirmed to preserve raw wire bytes including `0x10 0x03` intact. HxD inspection of new capture confirmed `10 03` present in S3→BW record payloads. |
| 2026-03-02 | Appendix A | **UPDATED:** New capture architecture: two flat raw wire dumps per session (`raw_s3.bin`, `raw_bw.bin`), one per direction, no record wrapper. Replaces structured `.bin` format for parser input. |
| 2026-03-02 | Appendix A | **PARSER:** Deterministic DLE state machine implemented (`s3_parser.py`). Three states: `IDLE → IN_FRAME → AFTER_DLE`. Replaces heuristic global scanning. Properly handles DLE stuffing (`10 10` → literal `10`). Only complete STX→ETX pairs counted as frames. |
| 2026-03-02 | Appendix A | **VALIDATED:** `raw_bw.bin` yields 7 complete frames via state machine. `raw_s3.bin` contains large structured responses (first frame payload ~3922 bytes). Both files confirmed lossless. BW bare `0x02` pattern confirmed as asymmetric framing (BW sends bare STX, S3 sends DLE+STX). |
| 2026-03-09 | §7.6, §Appendix B | **CONFIRMED:** Record time located in SUB E5 data page2 at payload offset `+0x28` as **float32 BE**. Confirmed via two controlled captures: 7 sec = `40 E0 00 00`, 13 sec = `41 50 00 00`. Geo range (only 1.25 or 10.0 in/s) eliminates ambiguity — 7 and 13 are not valid geo range values. |
| 2026-03-09 | §7.5, §14 | **CORRECTED:** The byte `0x0A` appearing after the "Extended Notes" null-padded label in the E5 payload is **NOT** record time. It is an unknown field that equals 10 and does not change when record time changes. False lead closed. |
| 2026-03-09 | §14 | **RESOLVED:** `0x082A` mystery closed — confirmed as fixed-size E5 payload length (2090 bytes), not a record-time-derived sample count. Value is constant regardless of record time or other settings. |
| 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"`. ~~CORRECTION 2026-04-08: This "exception" was a misidentification. The `1C` in that capture was BW→S3 (a monitor status poll), and the `6E` response was from an inner A4 sub-frame misread as a top-level S3 frame. Confirmed from 4-8-26/2ndtry capture (338 BW TX frames): SUB 0x1C always receives response SUB 0xE3 (= 0xFF 0x1C). No exceptions to the response pairing rule are known.~~ |
| 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. |
| 2026-03-30 | §3, §5.1 | **CONFIRMED — BW→S3 two-step read offset is at payload[5], NOT payload[3:4].** All BW read-command frames have `payload[3] = 0x00` and `payload[4] = 0x00` unconditionally. The two-step offset byte lives at `payload[5]`: `0x00` for the length-probe step, `DATA_LEN` for the data-fetch step. Validated against all captured frames in `bridges/captures/3-11-26/raw_bw_*.bin` — every frame is an exact bit-for-bit match when built with offset at `[5]`. The `page_hi`/`page_lo` framing in the docstring was a misattribution from the S3-side response layout (where `[3]`/`[4]` ARE page bytes). |
| 2026-03-30 | §4, §5.2 | **CONFIRMED — S3 probe response page_key is always 0x0000.** The S3 response to a length-probe step does NOT carry the data length back in `page_hi`/`page_lo`. Both bytes are `0x00` in every observed probe response. Data lengths for each SUB are fixed constants (see §5.1 table). The `minimateplus` library now uses a hardcoded `DATA_LENGTHS` dict rather than trying to read the length from the probe response. |
| 2026-03-31 | §12 TCP Transport | **NEW SECTION — TCP/modem transport confirmed transparent from Blastware Operator Manual (714U0301 Rev 22).** Key facts confirmed: (1) Protocol bytes over TCP are bit-for-bit identical to RS-232 — no handshake framing. (2) No ENQ byte on TCP connect (`Enable ENQ on TCP Connect: 0-Disable` in Raven ACEmanager). (3) Raven modem `Data Forwarding Timeout = 1 second` — modem buffers serial bytes up to 1s before forwarding over TCP; `TcpTransport.read_until_idle` uses `idle_gap=1.5s` to compensate. (4) TCP port is user-configurable (12335 in manual example; user's install uses 12345). (5) Baud rate over serial link to modem is 38400,8N1 regardless of TCP path. (6) ACH (Auto Call Home) = INBOUND to server (unit calls home); "call up" = OUTBOUND from client (Blastware/SFM connects to modem IP). `TcpTransport` implements outbound (call-up) mode. |
| 2026-03-31 | §14.3 | **NEW — Sierra Wireless RV50/RV55 Quiet Mode requirement confirmed.** Quiet Mode (ATQ) must be **enabled** on the serial port. When disabled (+ Verbose mode on), the modem injects `RING\r\nCONNECT\r\n` onto the RS-232 serial line at connection time — MiniMate receives unexpected bytes, loses protocol sync, and never responds to POLL (unit beeps but returns no S3 frame). Working RV50 field config: Quiet Mode enabled, Data Forwarding Timeout=1, TCP Connect Response Delay=0. Misconfigured RV55 had all three wrong. |
| 2026-03-31 | §14.2 | **CORRECTED — Sierra Wireless RV50/RV55 sends `RING`/`CONNECT` over TCP to caller even with Quiet Mode enabled.** Quiet Mode suppresses these only on the serial port (protecting the MiniMate). TCP client still receives `\r\nRING\r\n\r\nCONNECT\r\n` prefixed before the first S3 frame bytes. Parser handles correctly by scanning for DLE+STX (`0x10 0x02`) and discarding prefix bytes. Previous note "no CONNECT string" described Raven X ENQ-disable behaviour; RV50/RV55 differ. |
| 2026-03-31 | §7.3 | **NEW — Calibration date field confirmed** at Full Config (SUB FE) destuffed payload offsets 0x530x57. Two-unit comparison: BE18189 (calibrated 2023) has `07 E7` at 0x560x57; BE11529 (calibrated 2025) has `07 E9`. Bytes 0x560x57 = uint16 BE calibration year ✅ CONFIRMED. Adjacent bytes at 0x530x55 likely encode month/day (both units show `0x10` at offset 0x54 = BCD October; 0x53 and 0x55 differ between units). Full date layout 🔶 INFERRED — pending third-unit capture or recalibration diff. Resolves open question. |
| 2026-03-31 | §9 | **CONFIRMED via Console cold-start capture**`"Operating System"` (16 B: `4f 70 65 72 61 74 69 6e 67 20 53 79 73 74 65 6d`) arrives as first TCP bytes on cold-connect before unit enters DLE-framed mode. `TcpTransport` + retry logic handles gracefully: first attempt times out waiting for SUB A4; second connect (after unit fully booted) succeeds. |
| 2026-04-01 | §7.7.5, §8 | **CONFIRMED — Full waveform record (0C) timestamp layout** cross-referenced against Blastware event report for BE11529 thump event ("00:28:12 April 1, 2026"). 9-byte format at bytes[08]: `[day][sub_code][month][year:2 BE][unknown][hour][min][sec]`. All fields verified. Sub_code `0x10` = Waveform (continuous/single-shot). **Previous 7-byte format doc was wrong** — replaced with confirmed 9-byte layout. |
| 2026-04-01 | §7.7.5 | **CONFIRMED — Record type** encoded in byte[1] (sub_code), not as ASCII string. `0x10` = Waveform ✅. Histogram sub_code not yet captured. ASCII string search approach removed. |
| 2026-04-01 | §7.7.5, §14 | **CONFIRMED — Per-channel PPV** at label+6 (✅ all four channels), cross-referenced vs Blastware: Tran=0.420, Vert=3.870, Long=0.495 in/s. **CONFIRMED — Peak Vector Sum** at fixed offset 87 = 3.906 in/s ✅ matches Blastware "Peak Vector Sum". Is √(Tran²+Vert²+Long²) at max instantaneous vector moment, not vector sum of per-channel peaks. Open question "offset 87 purpose" closed. |
| 2026-04-01 | §8 | **RESOLVED — §8 unknown byte at offset 3.** Field is confirmed absent in the 9-byte waveform record format (no such field). The 6-byte event-index format has a separator byte at [3] whose purpose remains ❓ but is no longer actively blocking anything. |
| 2026-04-01 | §7.6.2 (NEW) | **NEW — SUB 1A multi-frame read protocol documented.** SUB 1A (compliance config read) requires a 4-frame sequence, not a simple 2-step probe+fetch. Reverse-engineered from `raw_bw_20260311_155355.bin` (Blastware TX capture, ACK+STX/ETX format, BW CMD=0x10). Sequence: (A) probe `offset=0x0000, params=...00 64 00 00`; (B) data request `offset=0x0400, params=...00 64 00 00`; (C) data request `offset=0x0400, params=...04 00 00 64 00 00`; (D) data request `offset=0x002A, params=...08 00 00 64 00 00`. E5 response to each frame carries a `page_key` field (bytes `data[3:5]`): page `0x0000` = 44-byte header chunk, page `0x0010` = main config data (~1027+1055 bytes across C and D). |
| 2026-04-01 | §7.6.2 | **NEW — BE11529 duplicate-page behaviour.** BE11529 (firmware S338.17) sometimes responds to frame D with page `0x0000` (44 bytes) instead of page `0x0010` (~1055 bytes). When this happens, frame D is an exact duplicate of frame B and must be dropped to prevent cfg mis-alignment. Detection: track `(page_key, chunk_len)` pairs and skip repeats. When frame D delivers page `0x0010` correctly, total cfg = 44 + 1027 + 1055 = 2126 bytes; with duplicate D, cfg = 44 + 1027 = 1071 bytes. Both are valid inputs to the decoder. |
| 2026-04-01 | §7.6.1 | **CORRECTED — Record time offset.** Previous doc (`+0x28` from E5 data page2 start) was correct for single-frame reads but unreliable for BE11529 due to a 1-byte DLE jitter (see §7.6.3). The `minimateplus` library now uses an anchor-based approach: search for `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` in cfg[40:100]; record time float32 BE is at anchor+10. Validated at 3.0, 5.0, and 8.0 seconds. |
| 2026-04-01 | §7.6.3 (NEW) | **NEW — Sample rate confirmed and documented.** Sample rate (Normal=1024 / Fast=2048 / Faster=4096 Sa/s) is stored as uint16 BE at anchor2, where anchor is the 10-byte sequence `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00`. DLE jitter root cause: 4096 = 0x1000, so in the raw S3 frame the sample-rate bytes are sent as `10 10 00` (DLE-escaped `10`); after DLE unstuffing → `10 00` (2 bytes instead of 3 for 1024/2048), making frame C 1 byte shorter and shifting all subsequent offsets by 1. Anchor search is immune to this shift. All three modes confirmed on BE11529 firmware S338.17. |
| 2026-04-01 | §5.1 | **CONFIRMED — `_pending_frames` buffer and `reset_parser=False` parameter.** `MiniMateProtocol._recv_one()` now supports `reset_parser=False` to preserve parser state between consecutive reads within a multi-frame sequence. A `_pending_frames: list[S3Frame]` buffer stores extra frames parsed from a single TCP chunk when multiple E5 responses arrive together. Required for reliable SUB 1A frame B/C/D sequence on BE11529. |
| 2026-04-02 | §7.8 (NEW) | **CONFIRMED — SUB 5A frame format.** `offset_hi` byte (`0x10`) must be sent **raw, not DLE-stuffed** — standard `build_bw_frame` incorrectly stuffs it to `10 10` on the wire; device ignores the frame. BW sends it as bare `10`. Checksum is **DLE-aware**: when walking the byte sequence, `10 XX` pairs contribute only `XX` to the sum; lone bytes contribute normally. `build_5a_frame()` reproduces BW's exact wire format. |
| 2026-04-02 | §7.8 | **CONFIRMED — SUB 5A params are 11 bytes (not 10) for chunk frames.** Extra trailing `0x00` confirmed from 1-2-26 BW wire capture. Probe frame and termination frame differ — see `bulk_waveform_params()` in `framing.py`. |
| 2026-04-02 | §7.7.5 | **CONFIRMED — Event-time metadata source.** `Client:`, `User Name:`, and `Seis Loc:` strings are present in **A5 frame 7** of the SUB 5A bulk waveform stream — they are NOT in the 210-byte SUB 0C waveform record. They reflect the compliance setup active when the event was stored on the device (not the current setup). `get_events()` now issues SUB 5A after each 0C download. Sequence: `1E → 0A → 0C → 5A → 1F`. |
| 2026-04-02 | §7.6.2 | **FIXED — Compliance config orphaned send bug.** An extra `self._send(SUB_COMPLIANCE / 0x2A / DATA_PARAMS)` before the B/C/D receive loop had no corresponding `recv_one()`. Every receive in the loop was consuming the previous send's response, leaving frame D's channel block unread. Bug removed. Total config bytes now ~2126 (was ~1071 due to truncation). `trigger_level_geo`, `alarm_level_geo`, `max_range_geo` are now correctly populated. |
| 2026-04-02 | §7.6.1 | **CORRECTED — Anchor search range.** Previous doc stated anchor search range `cfg[40:100]`. With the orphaned-send bug fixed, the 44-byte header padding is gone and the anchor now appears at `cfg[11]`. Corrected to `cfg[0:150]`. |
| 2026-04-03 | §7.6 | **CONFIRMED — Blast waveform format (4-2-26 capture).** Blast/waveform-mode SUB 5A stream uses 4-channel interleaved signed int16 LE, 8 bytes per sample-set [T,V,L,M]. NOT the 32-byte block format (which is noise/histogram mode only). Frame sizes are NOT multiples of 8 — cross-frame alignment correction required (track global byte offset mod 8; skip `(8-align)%8` bytes at each frame start). A5[0] STRT record confirmed: 21 bytes at db[7:]+11; waveform starts at strt_pos+27 (after 2-byte null pad + 4-byte 0xFF sentinel). Frame index 7 = metadata only, no ADC data. Full §7.6 rewritten. |
| 2026-04-03 | §7.6 | **CONFIRMED — Noise block format details.** 32-byte blocks: LE uint16 type + LE uint16 ctr + 9×int16 LE samples + 10B metadata. Samples are little-endian (previous doc said big-endian — WRONG). Type: 0x0016=sync (appears at start of each A5 frame), 0x0000=data. Noise floor ≈ 911 counts. Metadata fixed pattern `00 01 43 [2B var] 00 [pretrig] [rectime] 00 00` confirmed. |
| 2026-04-03 | client.py | **NEW — `_decode_a5_waveform()` and `download_waveform()` implemented.** `_decode_a5_waveform(frames_data, event)` decodes full A5 waveform stream into `event.raw_samples = {"Tran":[…], "Vert":[…], "Long":[…], "Mic":[…]}`. Populates `event.total_samples`, `event.pretrig_samples`, `event.rectime_seconds` from STRT record. Handles cross-frame alignment. `MiniMateClient.download_waveform(event)` calls `read_bulk_waveform_stream(stop_after_metadata=False)` then invokes the decoder. Waveform key stored on Event as `_waveform_key` during `get_events()`. |
| 2026-04-03 | §7.7.5 | **CONFIRMED — sub_code=0x03 (Waveform continuous) uses 10-byte timestamp header** — one byte wider than sub_code=0x10 layout. Cross-referenced against Blastware event report for BE11529 (15:20:17 Apr 3 2026). Raw header: `10 03 10 04 07 ea 00 0f 14 11` = [unknown_a][day][unknown_b][month][year:2 BE][unknown][hour][min][sec]. Peak Vector Sum is at `tran_label 12` (label-relative, NOT fixed offset 87 — fixed offset only incidentally correct for sub_code=0x10). |
| 2026-04-05 | §7.8 | **CONFIRMED — 5A "Project:" string is session-start config, NOT per-event.** The "Project:" value in A5 frame 7 reflects the compliance setup active when the monitoring session started, not when the individual event was recorded. `_decode_a5_metadata_into()` only sets `project` from 5A when 0C did not already supply one. "Client:", "User Name:", "Seis Loc:", and "Extended Notes" are NOT in the 0C record and are set unconditionally from 5A. |
| 2026-04-06 | §5.1 | **CORRECTED — SUB 1F token position is params[7], NOT params[6].** Both 3-31-26 and 4-3-26 BW TX captures confirm: raw params `00 00 00 00 00 00 00 FE 00 00`, token byte at index 7. Previous doc (params[6]) was wrong — with wrong position the device ignores the token and 1F returns null immediately. |
| 2026-04-06 | §6.1 | **NEW — Full event download sequence documented** (§6.1). Sequence confirmed from 4-2-26 and 4-3-26 BW TX captures: `1E(all-zero) → [per event: 0A → 1E(arm/0xFE) → 0C → 1F(arm/0xFE) → POLL×3 → 5A → 1F(browse)]`. Each step documented with confirmed requirements. |
| 2026-04-06 | §6.1 | **CONFIRMED — 1E(token=0xFE) arm step required.** Device silently ignores all 5A probe frames unless a second 1E with token=0xFE is sent between 0A and 0C. Present in every download cycle in the 4-2-26 and 4-3-26 captures. |
| 2026-04-06 | §6.1 | **CONFIRMED — SUB 1F(token=0xFE) must precede POLL×3 before 5A.** BW always sends 1F(0xFE) before the 3 POLL cycles before 5A. 5A still uses the pre-advance key (set by 0A+1E-arm+0C); 1F only arms the device's 5A state machine. |
| 2026-04-06 | §6.1 | **CONFIRMED — browse 1F must be conditional.** Calling 1F(browse=True/all-zero) after a FAILED 5A disrupts device state and causes the next event's 5A probe to time out with 0 bytes received. Browse 1F is only called after a SUCCESSFUL 5A. Failure fallback: use the key returned by the prior 1F(arm/0xFE) call. |
| 2026-04-06 | §7.8 | **ADDED — `bytes_fed` diagnostic counter on S3FrameParser.** Counts raw bytes fed to the parser since last `reset()`. Logged at WARNING when 5A probe times out — distinguishes "device sent no bytes at all" from "device responded but frame was malformed or had wrong SUB". |
| 2026-04-06 | §7.8.2 | **CORRECTED — SUB 5A chunk counter is monotonic for ALL chunks.** Previous doc hard-coded chunk 1 counter as `0x1004` (from 4-2-26 BW TX capture). This was a Blastware artifact. Empirically confirmed: `counter = chunk_num * 0x0400` works (device responds immediately); counter=0x1004 for chunk 1 causes 120 s timeout. BW's true internal formula appears to be `key4[2:4] + n * 0x0400` — for event 1 (key `01110000`) this equals `n * 0x0400`. The device does not strictly validate counter values. |
| 2026-04-06 | §7.8.4 | **NEW — 5A end-of-stream signalling confirmed.** After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to the next chunk request, then goes silent for the full recv timeout. This byte is NOT a complete DLE-framed A5 response — the frame parser accumulates it as `bytes_fed=1` and never assembles a frame. This is the device's natural end-of-stream signal. Handling: on TimeoutError, if `bytes_fed > 0` AND prior chunks were received, treat as graceful end and proceed to the termination frame. A `bytes_fed=0` timeout with no prior chunks is a genuine transport failure and must still raise. |
| 2026-04-06 | §7.8.4 | **NEW — 5A chunk timing and count (empirical, BE11529 at 1024 sps).** Each chunk response arrives within ~1 second over TCP/cellular. A 9,306-sample event (≈9.1 s at 1024 sps) produces **35 chunks** before end-of-stream. Chunks 116 have varying data lengths (10361123 bytes); chunks 1735 are uniformly 1036 bytes each (post-event silence, all-zero ADC samples). Safe recv timeout for chunk loop: **10 s** (10× typical response time). Default transport timeout (120 s) results in a ~2-minute stall per event at end-of-stream. |
| 2026-04-06 | §7.8.3 | **KNOWN ISSUE — `_decode_a5_waveform` hardcoded fi==9 skip.** The decoder contains `elif fi == 9: continue` which was written for the 9-frame original blast capture where frame 9 was a device terminator. For streams with >9 frames (current device produces 35+), frame index 9 is live waveform data — this skip discards ~1,070 bytes (~133 sample-sets) per event. The terminator is now detected via `page_key == 0x0000`, not by frame index. The fi==9 skip should be removed. |
| 2026-04-06 | §7.8 | **⚠ PARTIALLY INVALIDATED — ADC count-to-physical-unit conversion.** Raw waveform samples are signed 16-bit integers (counts). Conversion formula `value = counts × (range / 32767)` is believed correct, but the `range` value was UNKNOWN at time of writing. **UPDATED 2026-04-17:** `max_range_geo` = 6.206053 is confirmed as the ADC-to-velocity scale factor (inverse sensitivity, (in/s)/V). The correct conversion is therefore: `PPV (in/s) = counts × (1.61133 / 32767) × 6.206053` = `counts × 4.982e-5` in/s per count. The earlier ~9× overread from using 6.206053 directly as the range was because the range IS 1.61133 × 6.206053 = 10.000 in/s, not 6.206053. See §7.6.2 for the confirmed field layout. |
| 2026-04-08 | §5.1, §7.10, §12 | **NEW — Monitoring commands confirmed.** SUB 0x1C (monitor status), 0x96 (start monitoring), 0x97 (stop monitoring) all confirmed from 4-8-26/2ndtry capture. SESSION_RESET (`41 03`) required before POLL to wake a monitoring unit. |
| 2026-04-09 | §7.10 | **CORRECTED — monitoring flag and battery/memory offsets.** `section[1] == 0x10` is the monitoring flag (100% accurate across 144 data frames in 2ndtry capture). Previous note claiming `section[6]` was wrong — section[6] has device-specific non-binary values (0xea/0x07). Battery/memory offsets corrected: `section[-10:-8]` (battery×100), `section[-8:-4]` (memory_total), `section[-4:]` (memory_free). NOTE: `frame.data` has checksum stripped by parser — earlier offsets of `[-11:-9]`/`[-9:-5]`/`[-5:-1]` were wrong because they assumed a trailing checksum byte that isn't there. |
| 2026-04-08 | §7.10 | **NEW — SUBs 0x0E (channel sensor data) and 0x98 (trigger test) observed** in 4-8-26/sensor-check capture (Blastware "Unit Channel Test" comms check). SUB 0x0E: 2-step read with channel selector in `params[6:8]`, data length 0x0A per channel, RSP SUB = 0xF1. SUB 0x98: single probe frame with `params[0] = 0xFF`, RSP SUB = 0x67; sent twice per test cycle. Not yet implemented in SFM. |
| 2026-04-08 | §7.10 | **NEW — SUBs 0x15 and 0x01 observed in sensor-check capture.** SUB 0x15 (serial number short form, data length 0x0A, RSP 0xEA) and SUB 0x01 (device info block, data length 0x98 = 152 bytes, RSP 0xFE) seen in Blastware's "Unit Channel Test" init sequence. Note: SUB 0x01 response SUB 0xFE collides with the existing SUB 0xFE → RSP 0x01 naming convention — they are inverse commands. |
| 2026-04-08 | §12 | **CONFIRMED — Unit partially reachable during on-device sensor check.** 4-8-26/sensor-check capture shows: POLL responds normally throughout; SUB 0x0E channel reads partially served (channels 04 responded), then ~40s silent gap while sensor check ran, then channels 57 responded. On-device sensor check duration ≈ 40 s. SFM `_pollMonitorConfirm()` polls status every 5 s for up to 60 s after start_monitoring. |
| 2026-04-08 | §7.9 (NEW) | **NEW — Compliance config field inventory captured from Blastware UI.** See §7.9 for full field list (Recording Setup, Notes, Special Setups tabs). Most fields NOT yet mapped to raw byte offsets. Confirmed decoded: sample_rate, record_time, trigger_level_geo, alarm_level_geo, max_range_geo, backlight_on_time, power_saving_timeout, monitoring_lcd_cycle, project/client/operator/sensor_location/notes. Sensor Check dropdown (Before monitoring / After each event / Disabled) NOT YET LOCATED in raw config bytes. |
| 2026-04-11 | §5.1, §5.2 | **NEW — Erase-all command sequence confirmed from MITM capture.** SUB 0xA3 (begin erase, token=0xFE → ack 0x5C) + SUB 0xA2 (confirm erase, token=0xFE → ack 0x5D). Standard `build_bw_frame` format (not write-format). Required intermediate steps: 0x1C probe+data (monitor status read) + 0x06 probe+data (event storage range). All response SUBs follow the standard 0xFFSUB formula with no exceptions. |
| 2026-04-11 | §5.1 | **CONFIRMED — SUB 0x06 (CHANNEL CONFIG READ) now confirmed as event storage range.** Two-step read, data offset = 0x24 (36 bytes). Token=0xFE at params[7]. Last 8 bytes of response: first stored event key (bytes 8:4) and last stored event key (bytes 4:). Both equal `01110000` when device memory is empty. Used by Blastware to verify erase completion. |
| 2026-04-11 | §7.11 (NEW) | **NEW — §7.11 Erase-All Protocol added.** Full wire sequence, SUB 0x06 storage range payload layout, post-erase key counter reset (resets to `0x01110000`). Confirmed from 4-11-26 MITM capture of live Blastware ACH session. |
| 2026-04-11 | §14.6 | **RESOLVED — ACH Session Lifecycle is no longer "Future".** `bridges/ach_server.py` fully implements inbound ACH: POLL handshake, device info, event download. State tracked via `ach_state.json` (key-based, with `max_downloaded_key` for post-erase detection). `--clear-after-download` flag added for the standard delete-after-upload workflow. |
| 2026-04-17 | §7.6.2, §14 | **RESOLVED — Float 6.206053 at channel_label+28 is the ADC-to-velocity scale factor.** Confirmed from Series III Interface Handbook §4.5 formula: `Range (×1) = 1.61133 V / Sensitivity (V/unit)`. For the standard Instantel geophone at Normal range (10.000 in/s): Sensitivity = 1.61133 / 10 = 0.161133 V/(in/s). The stored value is the **inverse sensitivity** = 1/0.161133 = **6.206053 (in/s)/V**. Cross-check: 1.61133 V × 6.206053 = 10.000 in/s ✅. The firmware uses it as: `PPV (in/s) = ADC_voltage (V) × 6.206053`. Value is identical on all Instantel standard geophones — it is a hardware/firmware constant, NOT a user-configurable setting. Do NOT write this field. Open question §14 item "Max Geo Range float 6.2061" is now **RESOLVED**. |
| 2026-04-20 | §7.6.4 (NEW), §7.9, Appendix B | **CONFIRMED — Recording Mode byte location.** Three targeted captures (4-20-26) confirmed `recording_mode` at anchor8 in both the E5 read payload and the BW write payload (6-byte anchor `\xbe\x80\x00\x00\x00\x00`). BW write payload and E5 read payload are **byte-identical** around the anchor region — Blastware round-trips the wire-encoded E5 bytes verbatim with only the target field modified. Anchor position varies by ±1 depending on whether recording_mode = 0x03 (Histogram), because E5 wire-encodes `0x03` as the inner DLE+ETX pair `\x10\x03` (2 bytes), which S3FrameParser preserves as two literal bytes in `compliance_raw`. Enum: `0x00`=Single Shot, `0x01`=Continuous, `0x03`=Histogram, `0x04`=Histogram+Continuous. `0x02` value not yet observed. The byte at anchor9 is `0x00` for Single Shot / Continuous, and `0x10` for Histogram (DLE prefix from E5 encoding) and Histogram+Continuous (actual config byte). See §7.6.4 for full details. |
| 2026-04-21 | Appendix D (NEW) | **NEW — Blastware .N00 and .MLG file formats fully decoded.** `minimateplus/blastware_file.py` implements `write_n00()` and `write_mlg()`. N00 file format confirmed: 22B header + 21B STRT record + variable body + 26B footer. Body reconstructed from A5 bulk waveform stream frames with per-frame skip amounts (probe=7+strt_pos+21, A5[1]=13, A5[2+]=12, terminator=11) and DLE strip rule (strip `0x10` before `{0x02,0x03,0x04}`, keep following byte). Footer extracted verbatim from terminator frame's last 26 bytes. Split-pair edge case: when `frame.data[-1]==0x10` and `chk_byte∈{0x02,0x03,0x04}`, reunite both bytes before stripping and always remove trailing chk_byte (`stripped[:-1]`) — chk_byte is checksum, not payload. STRT record must be copied verbatim from A5[0]; bytes [10:20] are device-specific and cannot be reconstructed from Event fields. `write_n00` verified byte-perfect against `M529LIY6.N00` from 4-3-26-multi_event capture. MLG format: 308B header + N×292B records; CRC algorithm unknown (write as 0x0000). |
| 2026-04-21 | Appendix D §D.5 (NEW) | **NEW — Blastware filename encoding fully decoded.** Serial prefix: `chr(ord('B') + floor(serial/1000))` + last 3 digits zero-padded. Stem: 4-char base-36 of `floor(total_seconds/1296)`. Extension: `AB0` for manual/direct downloads (3 chars), `AB0W` or `AB0H` for ACH/call-home downloads (4 chars), where `AB` = 2-char base-36 of `total_seconds % 1296` and W/H = waveform/histogram. Epoch = 1985-01-01 00:00:00 device local time. Confirmed against 3,248 files from 10-year production archive with zero errors. 3-day cycle property: same daily recording time cycles through 3 extensions (864s/day shift, period=3 days). `blastware_filename(event, serial, ach=False)` implements full formula. |
| 2026-04-21 | §7.6.2, §5.3 | **CORRECTED — compliance_raw contains wire-encoded bytes, NOT logical bytes.** S3FrameParser appends DLE+ETX inner-frame pairs as two literal bytes to the frame body. Any `0x03` values in the compliance config appear in `compliance_raw` as `\x10\x03` (two bytes), not as a single `0x03`. The previous claim "S3FrameParser handles this transparently so compliance_raw contains logical (destuffed) bytes" was wrong. Consequence: `compliance_raw` is the wire-encoded E5 payload; anchor-relative reads work correctly because the anchor position automatically accounts for any DLE-encoded bytes before it. For write-back, round-tripping `compliance_raw` verbatim sends the correct wire bytes to the device. **DLE ETX escaping in write frames:** Blastware escapes `0x03` bytes in write frame data as `\x10\x03` on wire; our `build_bw_write_frame` does not (writes data raw). Device is confirmed to accept raw writes for all tested modes — likely uses the offset/length field for write frame framing, not ETX scanning. |
| 2026-04-20 | §7.6.2, §7.9, Appendix B | **CONFIRMED — Geophone maximum range / sensitivity selector byte location.** Two targeted captures (4-20-26, geo sensitivity folder): one at Normal 10.000 in/s, one at Sensitive 1.250 in/s. E5 read payload diff: exactly 3 bytes differ at channel_label+33 for Tran/Vert/Long. Values: `0x00`=Normal 10.000 in/s, `0x01`=Sensitive 1.250 in/s. Same offset applies to the SUB 71 write payload (which is the same 2126-byte E5-format buffer round-tripped verbatim). **`channel_label+20` reads `0x01` in ALL captures regardless of range setting — it is NOT this field.** Previous hypothesis (uint8 at Tran+20, 0x01=Normal) was WRONG. Stored as `geo_range` in `ComplianceConfig`. Encoded to all three geo channel blocks (Tran/Vert/Long) at label+33. |
| 2026-04-20 | §5.1, §5.3, §7.12 (NEW) | **NEW — Auto Call Home config protocol confirmed from 4-20-26 call home settings captures.** SUB 0x2C (Call Home Config READ, response 0xD3, data offset 0x7C=124) and SUB 0x7E/0x7F (WRITE + CONFIRM, response 0x81/0x80) confirmed. Write payload = read payload (125 bytes) + `\x00\x00` (127 bytes total). **DLE-escaped ETX at raw[117:119]:** the device returns logical value 0x03 (num_retries=3) as `\x10\x03` on the wire — S3FrameParser preserves both bytes as two literals, causing a +1 byte shift for all subsequent fields. Write frame sends these bytes verbatim (device interprets `\x10\x03` as literal value 3). Field map confirmed from 10-frame BW TX diff. See §7.12 for full layout. |
---
## 1. Physical Layer
| Parameter | Value | Certainty |
|---|---|---|
| Interface | RS-232 serial | ✅ CONFIRMED |
| Baud rate | 38400 | ✅ CONFIRMED (from bridge log header) |
| Data bits | 8 | ✅ CONFIRMED (standard for this baud/era) |
| Parity | None | 🔶 INFERRED (no parity errors observed) |
| Stop bits | 1 | 🔶 INFERRED (standard assumption) |
| Flow control | None (no RTS/CTS activity) | 🔶 INFERRED |
---
## 2. Frame Structure
> ⚠️ **2026-02-26 — CORRECTED:** Previous version incorrectly identified `0x41` as STX and `0x02`/`0x03` as bare frame delimiters. The protocol uses proper **DLE framing**. See below.
Every message follows this structure:
```
[ACK] [DLE+STX] [PAYLOAD...] [CHECKSUM] [DLE+ETX]
0x41 0x10 0x02 N bytes 1 byte 0x10 0x03
```
### Special Byte Definitions
| Token | Raw Bytes | Meaning | Certainty |
|---|---|---|---|
| ACK | `0x41` (ASCII `'A'`) | Acknowledgment / ready token. Standalone single byte. Sent before every frame by both sides. | ✅ CONFIRMED |
| DLE | `0x10` | Data Link Escape. Prefixes the next byte to give it special meaning. | ✅ CONFIRMED — 2026-02-26 |
| STX | `0x10 0x02` | DLE+STX = Start of frame (two-byte sequence) | ✅ CONFIRMED — 2026-02-26 |
| ETX | `0x10 0x03` | DLE+ETX = End of frame (two-byte sequence) | ✅ CONFIRMED — 2026-02-26 |
| CHECKSUM | 1 byte | 8-bit sum of de-stuffed payload bytes, modulo 256. Sits between payload and DLE+ETX. | ✅ CONFIRMED |
### DLE Byte Stuffing Rule
> ✅ CONFIRMED — 2026-02-26
Any `0x10` byte appearing **naturally in the payload data** is escaped by doubling it: `0x10``0x10 0x10`. This prevents the parser from confusing real data with frame control sequences.
- **Transmit:** Replace every `0x10` in payload with `0x10 0x10`
- **Receive:** Replace every `0x10 0x10` in the frame body with a single `0x10`
| Sequence on wire | Meaning |
|---|---|
| `0x10 0x02` | Frame START — only valid at beginning |
| `0x10 0x03` | Frame END |
| `0x10 0x10` | Escaped literal `0x10` byte in payload data |
| Any other `0x10 0xXX` | Protocol error / undefined |
### Frame Parser Notes
- The `0x41` ACK **always arrives in a separate `read()` call** before the frame body due to RS-232 inter-byte timing at 38400 baud. This is normal.
- Your parser must be **stateful and buffered** — read byte by byte, accumulate between DLE+STX and DLE+ETX. Never assume one `read()` = one frame.
- Checksum is computed on the **de-stuffed** payload, not the raw wire bytes.
- The ACK and DLE+STX are **not** included in the checksum.
### Checksum Verification Example
Raw frame on wire (with ACK and DLE framing):
```
41 10 02 | 10 10 00 5B 00 00 00 00 00 00 00 00 00 00 00 00 00 | 6B | 10 03
^ACK^^STX^ ^---------- stuffed payload (0x10→0x10 0x10) ------^ ^chk^ ^ETX^
```
After de-stuffing (`0x10 0x10``0x10`):
```
De-stuffed: 10 00 5B 00 00 00 00 00 00 00 00 00 00 00 00 00
Checksum: 10+00+5B+00+... = 0x6B ✅
```
---
## 3. Payload Structure
The payload (bytes between DLE+STX and CHECKSUM, after de-stuffing) has consistent internal structure:
```
[CMD] [DLE] [ADDR] [FLAGS] [SUB_CMD] [OFFSET_HI] [OFFSET_LO] [PARAMS × N]
xx 0x10 0x10 0x00 xx xx xx
```
| Field | Position | Notes | Certainty |
|---|---|---|---|
| CMD | byte 0 | Command or response code | ✅ CONFIRMED |
| DLE | byte 1 | Always `0x10`. Part of address/routing scheme. On wire this is stuffed as `0x10 0x10`. | ✅ CONFIRMED — 2026-02-26 |
| ADDR | byte 2 | Always observed as `0x10`. Also stuffed on wire. Purpose unknown — may not be an address. | ❓ SPECULATIVE |
| FLAGS | byte 3 | Usually `0x00`. Non-zero values seen in event-keyed requests. | 🔶 INFERRED |
| SUB_CMD | byte 4 | The actual operation being requested. | ✅ CONFIRMED |
| OFFSET_HI | byte 5 | High byte of data offset for paged reads. | ✅ CONFIRMED |
| OFFSET_LO | byte 6 | Low byte of data offset. | ✅ CONFIRMED |
> ❓ **NOTE on bytes 12:** After de-stuffing, bytes 1 and 2 are both `0x10` in every observed frame across all captured sessions and both units. Their semantic meaning is not yet confirmed. No capture has shown either field vary across units, commands, or directions. They may represent routing, bus ID, or fixed header constants — or the field boundaries assumed here may be wrong entirely.
> 🔶 **NOTE:** Because bytes 1 and 2 are both `0x10`, they appear on the wire as four consecutive `0x10` bytes (`0x10 0x10 0x10 0x10`). This is normal — both are stuffed. Do not mistake them for DLE+STX or DLE+ETX.
---
## 4. Communication Pattern
### 4.1 ACK Handshake (Every Transaction)
```
Side A → 0x41 (ACK: "ready / received")
Side A → 10 02 [payload] [chk] 10 03 (frame)
Side B → 0x41 (ACK)
Side B → 10 02 [payload] [chk] 10 03 (response frame)
```
### 4.2 Two-Step Paged Read Pattern
All data reads use a two-step length-prefixed pattern. It is not optional.
```
Step 1 — Request with offset=0 ("how much data is there?"):
BW → 0x41
BW → 10 02 [CMD] 10 10 00 [SUB] 00 00 [00 00 ...] [chk] 10 03
Step 2 — Device replies with total data length:
S3 → 0x41
S3 → 10 02 [RSP] 00 10 10 [SUB] 00 00 00 00 00 00 [LEN_HI] [LEN_LO] [chk] 10 03
Step 3 — Re-request using LEN as offset ("now send the data"):
BW → 0x41
BW → 10 02 [CMD] 10 10 00 [SUB] 00 00 [LEN_HI] [LEN_LO] [00 ...] [chk] 10 03
Step 4 — Device sends actual data payload:
S3 → 0x41
S3 → 10 02 [RSP] 00 10 10 [SUB] 00 00 [LEN_HI] [LEN_LO] [DATA...] [chk] 10 03
```
---
## 5. Command Reference Table
### 5.1 Request Commands (Blastware → S3)
| SUB Byte | Name | Description | Certainty |
|---|---|---|---|
| `5B` | **POLL / KEEPALIVE** | Sent continuously (~every 80ms). Requests device identity/status. | ✅ CONFIRMED |
| `15` | **SERIAL NUMBER REQUEST** | Requests device serial number. | ✅ CONFIRMED |
| `01` | **FULL CONFIG READ** | Requests complete device configuration block (~0x98 bytes). Firmware, model, serial, channel config, scaling factors. | ✅ CONFIRMED |
| `08` | **EVENT INDEX READ** | Requests the event record index (0x58 bytes). Event count and record pointers. | ✅ CONFIRMED |
| `06` | **EVENT STORAGE RANGE READ** | Requests event storage range block (0x24 = 36 bytes). Token=0xFE at params[7]. Last 8 bytes of response: first stored event key (`[-8:-4]`) and last stored event key (`[-4:]`). Both equal `01110000` when device is empty. Used by Blastware as part of the erase-all verification step. Previously labelled "CHANNEL CONFIG READ" — function now confirmed from 4-11-26 MITM capture. | ✅ CONFIRMED 2026-04-11 |
| `1C` | **TRIGGER CONFIG READ** | Requests trigger settings block (0x2C bytes). | ✅ CONFIRMED |
| `1E` | **EVENT HEADER READ** | Gets first waveform key. Token byte at params[7] (0x00=browse, 0xFE=download-arm). Key at data[11:15]; trailing offset at data[15:19] (0 = only one event). Two uses: (1) all-zero to get key0; (2) token=0xFE after 0A, before 0C — REQUIRED to arm device for SUB 5A. | ✅ CONFIRMED 2026-04-06 |
| `0A` | **WAVEFORM HEADER READ** | Checks record type for a given waveform key. Variable DATA_LENGTH: 0x30=full bin, 0x26=partial bin. Key at params[4..7]. Required before every 1F call to establish device waveform context. | ✅ CONFIRMED 2026-03-31 |
| `0C` | **FULL WAVEFORM RECORD** | Downloads 210-byte waveform/histogram record. Sub_code at byte[1]: 0x10=Waveform (9-byte timestamp hdr), 0x03=Waveform-continuous (10-byte hdr, 1-byte shift). PPV floats at label+6 (search "Tran"/"Vert"/"Long"/"MicL"). Peak Vector Sum at tran_label12 (NOT fixed offset). Key at params[4..7], DATA_LENGTH=0xD2. | ✅ CONFIRMED 2026-04-03 |
| `1F` | **EVENT ADVANCE** | Advances to next waveform key. Token byte at params[7] (⚠️ NOT params[6]): 0x00=browse (all-zero params), 0xFE=download (arm 5A state machine). Returns next key at data[11:15]; null sentinel when data[15:19]=0x00000000. Requires preceding 0A to establish context. Browse 1F must ONLY be called after successful 5A — calling it after a failed 5A disrupts device state for the next event's 5A probe. | ✅ CONFIRMED 2026-04-06 |
| `5A` | **BULK WAVEFORM STREAM** | Bulk download of raw ADC sample data. Non-standard frame format: offset_hi=0x10 sent raw (not DLE-stuffed), DLE-aware checksum. Requires 1E-arm + 0C + 1F(0xFE) + POLL×3 before first probe. A5[7] contains event-time metadata (Project:/Client:/User Name:/Seis Loc:). 9+ A5 frames for full waveform; stop_after_metadata=True exits after A5[7]. | ✅ CONFIRMED 2026-04-06 |
| `24` | **WAVEFORM PAGE A?** | Paged waveform read, possibly channel group A. | 🔶 INFERRED |
| `25` | **WAVEFORM PAGE B?** | Paged waveform read, possibly channel group B. | 🔶 INFERRED |
| `09` | **UNKNOWN READ A** | Read command, response (`F6`) returns 0xCA (202) bytes. Purpose unknown. | 🔶 INFERRED |
| `1A` | **COMPLIANCE CONFIG READ** | Multi-step sequence (A+B+C+D frames). Response (E5) carries recording_mode (uint8 at anchor4 in E5 sf1), sample_rate (uint16 BE at anchor2), record_time (float32 BE at anchor+10), trigger/alarm/max_range floats, and project strings. Anchor: `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00`, search cfg[0:150]. Total ~2126 cfg bytes. See §7.6.4 for recording_mode enum. | ✅ CONFIRMED 2026-04-02; recording_mode added 2026-04-20 |
| `2E` | **UNKNOWN READ B** | Read command, response (`D1`) returns 0x1A (26) bytes. Purpose unknown. | 🔶 INFERRED |
| `0E` | **CHANNEL SENSOR DATA** | Real-time sensor reading for one channel. Two-step read, data length 0x0A (10 bytes). Channel selector in params[6:8] (0x00000x0007 for 8 channels). Response (F1) carries amplitude, frequency, overswing data for that channel. Used by Blastware "Unit Channel Test" comms check. | ✅ CONFIRMED 2026-04-08 |
| `98` | **TRIGGER TEST** | Trigger-test command. Single probe frame; `params[0] = 0xFF`. Response (0x67) is all-zero data. Sent twice per Blastware comms-check cycle. Not a full POLL, no monitor state change. | ✅ CONFIRMED 2026-04-08 |
| `1C` | **MONITOR STATUS READ** | Two-step read, data offset 0x2C (44 bytes). `section[1] == 0x10` → monitoring; `0x00` → idle (CONFIRMED 2026-04-09, 100% accuracy on 144 frames). Payload length: 4647 bytes IDLE, 4849 bytes MONITORING. `frame.data` has checksum stripped — no trailing byte to skip. Battery/memory at end: `section[-10:-8]` = battery×100 (uint16 BE), `section[-8:-4]` = memory_total (uint32 BE), `section[-4:]` = memory_free (uint32 BE). | ✅ CONFIRMED 2026-04-09 |
| `96` | **START MONITORING** | Single write frame, no data payload. Transitions unit from idle to monitoring mode (after optional on-device sensor check ~40 s). | ✅ CONFIRMED 2026-04-08 |
| `97` | **STOP MONITORING** | Single write frame, no data payload. Stops monitoring, unit returns to idle. | ✅ CONFIRMED 2026-04-08 |
| `2C` | **CALL HOME CONFIG READ** | Two-step read, data offset 0x7C (124 bytes + 1-byte DLE artefact = 125 raw bytes). Returns Auto Call Home configuration: enable flag, dial string, scheduled call times, retry settings, modem timing. Response SUB = 0xD3. **DLE note:** logical value 0x03 (num_retries) is returned as `\x10\x03` on the wire, which S3FrameParser preserves as two literal bytes — this shifts all subsequent field positions by +1. See §7.12 for full field map. | ✅ CONFIRMED 2026-04-20 |
| `A3` | **ERASE ALL BEGIN** | Single frame, token=0xFE at params[7]. Initiates device memory erase. Must be followed by 0x1C probe+data + 0x06 probe+data + 0xA2 to complete. Standard `build_bw_frame` (not write-format). Response ack SUB = 0x5C. | ✅ CONFIRMED 2026-04-11 |
| `A2` | **ERASE ALL CONFIRM** | Single frame, token=0xFE at params[7]. Commits the erase initiated by 0xA3. After this ack (SUB 0x5D), device memory is cleared and the event counter resets to `0x01110000`. | ✅ CONFIRMED 2026-04-11 |
All requests use CMD byte `0x02`. All responses use CMD byte `0x10 0x02` (which, after de-stuffing, is just the DLE+CMD combination — see §3).
### 5.2 Response SUB Bytes (S3 → Blastware)
> 🔶 **INFERRED pattern:** Response SUB = `0xFF - Request SUB`. Verified on all observed pairs to date — no counterexample has been observed across read commands, write commands, or either unit. Confidence is high but not formally proven across the full command space.
| Request SUB | Response SUB | Certainty |
|---|---|---|
| `5B` | `A4` | ✅ CONFIRMED |
| `15` | `EA` | ✅ CONFIRMED |
| `01` | `FE` | ✅ CONFIRMED |
| `08` | `F7` | ✅ CONFIRMED |
| `06` | `F9` | ✅ CONFIRMED 2026-04-11 |
| `1C` | `E3` | ✅ CONFIRMED 2026-04-08 |
| `1E` | `E1` | ✅ CONFIRMED |
| `0A` | `F5` | ✅ CONFIRMED |
| `0C` | `F3` | ✅ CONFIRMED |
| `5A` | `A5` | ✅ CONFIRMED |
| `1F` | `E0` | ✅ CONFIRMED 2026-03-31 |
| `09` | `F6` | ✅ CONFIRMED |
| `1A` | `E5` | ✅ CONFIRMED |
| `2E` | `D1` | ✅ CONFIRMED |
| `0E` | `F1` | ✅ CONFIRMED 2026-04-08 |
| `98` | `67` | ✅ CONFIRMED 2026-04-08 |
| `96` | `69` | ✅ CONFIRMED 2026-04-08 |
| `97` | `68` | ✅ CONFIRMED 2026-04-08 |
| `2C` | `D3` | ✅ CONFIRMED 2026-04-20 |
| `A3` | `5C` | ✅ CONFIRMED 2026-04-11 |
| `A2` | `5D` | ✅ CONFIRMED 2026-04-11 |
---
### 5.3 Write Commands (Blastware → Device)
> ✅ **CONFIRMED — 2026-02-26** from compliance setup capture (session `185019`).
Write commands are initiated by Blastware (`BW->S3`) and use SUB bytes in the `0x60``0x83` range. The device acknowledges each write with a short response frame containing no data payload.
**Pattern:** Write SUB = Read SUB + `0x60` (e.g. `0x08` EVENT INDEX READ → `0x68` EVENT INDEX WRITE).
| SUB | Name | Description | Response SUB | Certainty |
|---|---|---|---|---|
| `68` | **EVENT INDEX WRITE** | Writes event index block (mirrors SUB `08` read). Contains event count and timestamps. | `97` | ✅ CONFIRMED |
| `69` | **WAVEFORM DATA WRITE** | Writes large waveform/channel data block (0xCA bytes, mirrors SUB `09`). | `96` | ✅ CONFIRMED |
| `71` | **COMPLIANCE / PROJECT STRINGS WRITE** | Writes compliance config and all project string fields. Contains setup name, project, client, operator, sensor location, and extended notes. Also contains channel scaling floats and `0x082A` threshold value. | `8E` | ✅ CONFIRMED |
| `72` | **WRITE CONFIRM A** | Short frame, no data. Likely commit/confirm step after `71`. | `8D` | ✅ CONFIRMED |
| `73` | **WRITE CONFIRM B** | Short frame, no data. | `8C` | ✅ CONFIRMED |
| `74` | **WRITE CONFIRM C** | Short frame, no data. | `8B` | ✅ CONFIRMED |
| `7E` | **CALL HOME CONFIG WRITE** | Writes Auto Call Home configuration (127 bytes: 125-byte read payload + `\x00\x00`). Offset = data[1]+2 = 0x7E. Write format (DLE-aware checksum, only BW_CMD `0x10` doubled on wire). Response SUB = 0x81. Must be followed by SUB 0x7F confirm. | `81` | ✅ CONFIRMED 2026-04-20 |
| `7F` | **CALL HOME WRITE CONFIRM** | Short frame, no data. Commits call home config write from SUB 0x7E. Response SUB = 0x80. | `80` | ✅ CONFIRMED 2026-04-20 |
| `82` | **TRIGGER CONFIG WRITE** | Writes trigger config block (0x1C bytes, mirrors SUB `1C` read). | `7D` | ✅ CONFIRMED |
| `83` | **TRIGGER WRITE CONFIRM** | Short frame, no data. Likely commit step after `82`. | `7C` | ✅ CONFIRMED |
**Write response SUB pairs** follow the same `0xFF - request` rule:
| Write SUB | Response SUB |
|---|---|
| `68` | `97` |
| `69` | `96` |
| `71` | `8E` |
| `72` | `8D` |
| `73` | `8C` |
| `74` | `8B` |
| `7E` | `81` |
| `7F` | `80` |
| `82` | `7D` |
| `83` | `7C` |
---
## 6. Session Startup Sequence
```
1. Device powers on / resets
2. S3 → "Operating System" (raw ASCII, no DLE framing — UART boot string)
3. BW → 0x41 + POLL frame (SUB 5B)
4. S3 → 0x41 + POLL RESPONSE (SUB A4, reports data length = 0x30)
5. BW → 0x41 + POLL frame (SUB 5B, offset = 0x30)
6. S3 → 0x41 + POLL RESPONSE with data: "Instantel" + "MiniMate Plus"
7. BW → SUB 06 → channel config read
8. BW → SUB 15 → serial number
9. BW → SUB 01 → full config block
10. BW → SUB 1A → compliance config (4-frame sequence: A+B+C+D)
11. BW → SUB 08 → event index
```
### 6.1 Event Download Sequence (per-event, confirmed from 4-2-26 + 4-3-26 BW captures)
```
# Once per session:
BW → SUB 1E (all-zero params) → key0, trailing0
# Per event (repeat until null sentinel data[15:19]=0x00000000):
BW → SUB 0A (key) ← REQUIRED before every 1F; establishes device context
BW → SUB 1E (token=0xFE) ← REQUIRED arm step; must be BETWEEN 0A and 0C
BW → SUB 0C (key) ← read waveform record (peaks, timestamp, project)
BW → SUB 1F (token=0xFE) ← arm 5A state machine; do NOT use returned key
BW → SUB 5B × 3 ← REQUIRED: 3 full POLL probe+data cycles before 5A
BW → SUB 5A (key) ← bulk waveform stream (A5 frames)
# IF 5A succeeded:
BW → SUB 1F (all-zero) → next_key ← use this for loop iteration
# IF 5A failed/timed out:
# Do NOT send 1F(browse) — use cached key from 1F(token=0xFE) above instead
```
**Null sentinel:** `data[15:19] == b"\x00\x00\x00\x00"` in both 1E and 1F responses.
Do NOT use `data[11:15]` (key) as sentinel — event 0 has key=0x00000000.
---
## 7. Known Data Payloads
### 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.
```
Offset 0x00: 0x08 — string length prefix
Offset 0x01: "Instantel" — manufacturer (null-padded to ~20 bytes)
Offset 0x15: "MiniMate Plus" — model name (null-padded to ~20 bytes)
```
Raw payload (after de-stuffing):
```
00 00 00 08 49 6E 73 74 61 6E 74 65 6C 00 00 00 00 00 00 00 00 00 00 00 00 00
4D 69 6E 69 4D 61 74 65 20 50 6C 75 73 00 00 00 00 00 00 00 00 00
```
### 7.2 Serial Number Response (SUB EA)
Data payload = 0x0A bytes:
```
"BE18189\x00" — 7 ASCII bytes + null terminator (8 bytes)
79 11 — 2 trailing bytes
```
| Trailing Byte | Value (Unit 1) | Value (Unit 2) | Meaning | Certainty |
|---|---|---|---|---|
| `trail[0]` | `0x79` | `0x70` | Unit-specific — factory calibration ID or HW stamp? | ❓ SPECULATIVE |
| `trail[1]` | `0x11` | `0x11` | Firmware minor version — `0x11` = 17 = `S337.**17**` | ✅ CONFIRMED — 2026-02-26 |
Two-unit comparison data:
```
Unit 1: serial="BE18189" trail=79 11 firmware=S337.17
Unit 2: serial="BE11529" trail=70 11 firmware=S337.17
```
> ✅ **2026-02-26 — CORRECTED:** Previously documented as `79 11 20` (3 bytes). `0x20` is the **frame checksum**, not payload data. Actual data block is exactly 10 bytes (`0x0A`).
> ✅ **2026-02-26 — CONFIRMED:** `trail[1]` = firmware minor version. Both units share firmware `S337.17` → minor = 17 = `0x11`. Will change if firmware differs between units.
> ❓ **Still unknown:** `trail[0]` is unit-specific. Does not derive from serial string via sum, XOR, or modulo. Possibly written at factory calibration. Needs a third unit or write-command capture to determine.
### 7.3 Full Config Response (SUB FE) — 0x98 bytes
| Offset | Raw | Decoded | Certainty |
|---|---|---|---|
| 0x00 | `42 45 31 38 31 38 39 00` | `"BE18189\x00"` — Serial number | ✅ CONFIRMED |
| 0x08 | `79 11` | Unknown — possibly HW revision or calibration stamp | ❓ SPECULATIVE |
| 0x0A | `00 01` | Unknown flags | ❓ SPECULATIVE |
| 0x14 | `3F 80 00 00` | IEEE 754 float = **1.0** (Tran scale factor) | 🔶 INFERRED |
| 0x18 | `41 00 00 00` | IEEE 754 float = **8.0** (unknown — MicL range?) | 🔶 INFERRED |
| 0x1C | `3F 80 00 00` ×6 | IEEE 754 float = **1.0** ×6 (remaining channel scales) | 🔶 INFERRED |
| 0x34 | `53 33 33 37 2E 31 37 00` | `"S337.17\x00"` — Firmware version | ✅ CONFIRMED |
| 0x3C | `31 30 2E 37 32 00` | `"10.72\x00"` — DSP / secondary firmware version | ✅ CONFIRMED |
| 0x53 | varies | Likely calibration day or time field — 0x15 (BE18189), 0x1D (BE11529) | 🔶 INFERRED |
| 0x54 | `10` | Calibration month — BCD `0x10` = October (both units) | 🔶 INFERRED |
| 0x55 | varies | Calibration day — `0x02` (BE18189), `0x04` (BE11529) | 🔶 INFERRED |
| 0x560x57 | `07 E7` / `07 E9` | Calibration year — uint16 BE. `0x07E7`=2023, `0x07E9`=2025 | ✅ CONFIRMED — 2026-03-31 |
| 0x44 | `49 6E 73 74 61 6E 74 65 6C...` | `"Instantel"` — Manufacturer (repeated) | ✅ CONFIRMED |
| 0x6D | `4D 69 6E 69 4D 61 74 65 20 50 6C 75 73` | `"MiniMate Plus"` — Model name | ✅ CONFIRMED |
### 7.4 Event Index Response (SUB F7) — 0x58 bytes
> ✅ **2026-03-02 — CONFIRMED:** Backlight and power save offsets confirmed via two independent captures with device-set values. Offsets are from the start of the **data section** (after the 16-byte protocol header).
**Layout (offsets relative to data section start):**
```
Offset +00: 00 58 09 — Total index size or record count ❓
Offset +03: 00 00 00 01 — Possibly stored event count = 1 ❓
Offset +07: 01 07 CB 00 06 1E — Timestamp of event 1 (see §8)
Offset +0D: 01 07 CB 00 14 00 — Timestamp of event 2 (see §8)
Offset +13: 00 00 00 17 3B — Unknown ❓
Offset +4B: [backlight] — BACKLIGHT ON TIME ✅ CONFIRMED
Offset +4C: 00 — padding (backlight is uint8, not uint16)
Offset +53: [power_save] — POWER SAVING TIMEOUT ✅ CONFIRMED
Offset +54: [lcd_hi] [lcd_lo] — MONITORING LCD CYCLE (uint16 BE) ✅ CONFIRMED
```
| Offset | Size | Type | Known values | Meaning | Certainty |
|---|---|---|---|---|---|
| +4B | 1 | uint8 | 250, 100 | **BACKLIGHT ON TIME** (0255 seconds per manual) | ✅ CONFIRMED |
| +4C | 1 | — | 0x00 | Padding / high byte of potential uint16 | 🔶 INFERRED |
| +53 | 1 | uint8 | 10, 30 | **POWER SAVING TIMEOUT** (minutes) | ✅ CONFIRMED |
| +54..+55 | 2 | uint16 BE | 0xFFDC = 65500 | **MONITORING LCD CYCLE** (seconds; 65500 ≈ disabled/max) | ✅ CONFIRMED |
**Confirmation captures:**
| Capture | Backlight (+4B) | Power Save (+53) | LCD Cycle (+54/55) |
|---|---|---|---|
| `20260301_160702` (BW-written) | `0xFA` = 250 | `0x0A` = 10 min | `0xFF 0xDC` = 65500 |
| `20260302_144606` (device-set) | `0x64` = 100 | `0x1E` = 30 min | `0xFF 0xDC` = 65500 |
> 📖 **Manual cross-reference (716U0101 Rev 15, §3.13.1):**
> - Backlight On Time: "adjustable timer, from 0 to 255 seconds" (§3.13.1e)
> - Power Saving Timeout: "automatically turns the Minimate Plus off" — stored in minutes (§3.13.1f)
> - Monitoring LCD Cycle: "cycles off for the time period... set to zero to turn off" — 65500 = effectively disabled (§3.13.1g)
### 7.5 Full Waveform Record (SUB F3) — 0xD2 bytes × 2 pages
> ✅ **2026-02-26 — UPDATED:** Project strings field layout confirmed by diffing compliance setup write payload (SUB `71`). Client field change `"Hello Claude"` → `"Claude test2"` isolated exact byte position.
**Project strings field layout** (confirmed from SUB `71` write frame, offset +230 from frame start):
```
Offset Field label (null-padded, ~16 bytes) Field value (null-padded, ~32 bytes)
------ ------------------------------------ ------------------------------------
+0x00 "Standard Recording Setup" ← setup name (no label)
+0x28 "Project:" project description string
+0x50 "Client:" client name string ← confirmed at +230
+0x78 "User Name:" operator name string
+0xA0 "Seis Loc:" sensor location string
+0xC8 "Extended Notes" notes string
```
> 🔶 Offsets are approximate — exact byte boundaries need one more targeted capture with a known-length string change to pin down padding rules.
Confirmed ASCII strings extracted from payload:
```
"Project:"
"I-70 at SR 51-75978 - Loc 1 - 4256 SR51 " ← project description
"BE18189" ← serial number
"Histogram" ← record type
"Standard Recording Setup" ← setup name
"Client:"
"Golden Triangle" ← client name
"User Name:"
"Terra-Mechanics Inc. - B. Harrison" ← operator
"Seis Loc:"
"Location #1 - 4256 SR 51 - Intec" ← sensor location
"Extended Notes"
"Tran" ← Transverse channel
"Vert" ← Vertical channel
"Long" ← Longitudinal channel
"MicL" ← Microphone / air overpressure
```
### 7.6 Channel Config Float Layout (SUB E5 / SUB 71)
> ✅ **CONFIRMED — 2026-03-01** from controlled captures (sessions `193237` and `151147`). Trigger changed `0.500 → 0.200`, then `0.200 → 0.600`. Alarm changed `1.0 → 2.0`. All positions confirmed.
The SUB `1A` read response (`E5`) and SUB `71` write block contain per-channel threshold and scaling values packed as **IEEE 754 big-endian floats**, with inline unit strings. This layout repeats **once per geophone channel** (Tran, Vert, Long — 3×):
```
[00 00] [max_range float] [00 00] [trigger float] ["in.\0"] [alarm float] ["/s\0\0"] [00 01] [chan_label...]
40 C6 97 FD 3F 19 99 9A 69 6E 2E 40 00 00 00 2F 73 00 00
= 6.206 = 0.600 in/s "in." = 2.000 in/s "/s"
```
| Field | Example bytes | Decoded | Certainty |
|---|---|---|---|
| `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED |
| ADC scale factor | `40 C6 97 FD` | **6.206053 (in/s)/V — CONFIRMED 2026-04-17.** This is the inverse sensitivity of the standard Instantel geophone = 1/0.161133. Interface Handbook §4.5: `Range = 1.61133 V × 6.206053 = 10.000 in/s`. Used by firmware: `PPV (in/s) = ADC_voltage × 6.206053`. Hardware constant — do NOT write. | ✅ CONFIRMED |
| `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED |
| **Trigger level** | `3F 19 99 9A` | **0.600 in/s** — IEEE 754 BE float | ✅ CONFIRMED |
| Unit string | `69 6E 2E 00` | `"in.\0"` | ✅ CONFIRMED |
| **Alarm level** | `40 00 00 00` | **2.000 in/s** — IEEE 754 BE float | ✅ CONFIRMED |
| Unit string | `2F 73 00 00` | `"/s\0\0"` | ✅ CONFIRMED |
| `[00 01]` | `00 01` | Unknown flag / separator | 🔶 INFERRED |
| Channel label | e.g. `56 65 72 74` | `"Vert"` — identifies which channel | ✅ CONFIRMED |
**State transitions observed across captures:**
| Capture | Trigger | Alarm | Notes |
|---|---|---|---|
| `193237` (read) | `3F000000` = 0.500 | `3F800000` = 1.000 | Device state before any change |
| `193237` (write 1) | `3E4CCCCD` = 0.200 | `3F800000` = 1.000 | Trigger changed only |
| `151147` (write 1) | `3E4CCCCD` = 0.200 | `40000000` = 2.000 | Alarm changed, trigger carried over |
| `151147` (write 2) | `3F19999A` = 0.600 | `40000000` = 2.000 | Trigger changed, alarm carried over |
Values are stored natively in **imperial units (in/s)** — unit strings `"in."` and `"/s"` embedded inline confirm this regardless of display locale.
### 7.6.1 Record Time
> ✅ **CONFIRMED — 2026-04-01** (BE11529 / firmware S338.17). Updated from 2026-03-09 offset-based confirmation; anchor approach supersedes the `+0x28` absolute offset.
Record time is stored as a **32-bit IEEE 754 float, big-endian**, located via an anchor pattern (see §7.6.3 below).
**Anchor-relative location:** search for the 10-byte sequence `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` in `cfg[0:150]`. Record time float is at **anchor + 10**.
> ✅ **2026-04-02 — CORRECTED:** Search range was `cfg[40:100]`. With the compliance-config orphaned-send bug fixed (§7.6.2), the 44-byte accidental header padding is gone and the anchor now appears at `cfg[11]`. Search range widened to `cfg[0:150]`.
| Record Time | float32 BE bytes | Decoded |
|---|---|---|
| 3 seconds | `40 40 00 00` | 3.0 |
| 5 seconds | `40 A0 00 00` | 5.0 |
| 7 seconds | `40 E0 00 00` | 7.0 |
| 8 seconds | `41 00 00 00` | 8.0 |
| 10 seconds | `41 20 00 00` | 10.0 |
| 13 seconds | `41 50 00 00` | 13.0 |
**⚠️ Do NOT use absolute offset `+0x28` from page2 start for BE11529.** The total cfg length varies by ±1 byte depending on sample rate setting (see §7.6.3 DLE jitter note). Absolute offsets are unreliable; anchor search is the correct approach.
**`0x0A` after "Extended Notes" label:** invariant across record time changes — not record time.
> ✅ **`0x082A` (= 2090) — RESOLVED:** Fixed payload length of the E5 response block. Constant regardless of any setting.
---
### 7.6.2 SUB 1A Multi-Frame Read Protocol
> ✅ **CONFIRMED — 2026-04-01** (BE11529 / firmware S338.17). Reverse-engineered from `raw_bw_20260311_155355.bin` (Blastware TX capture).
SUB 1A (compliance config read) requires **four frames**, not the standard 2-step probe+fetch used by other SUBs. The Blastware TX frame format for these is ACK (`0x41`) + STX (`0x02`) + [DLE-stuffed body] + ETX (`0x03`) with CMD=`0x10`.
**Frame sequence:**
| Step | Name | BW offset param | Notes |
|---|---|---|---|
| A | Probe | `0x0000` | Triggers E5 response with page_key=`0x0000` (44-byte header) |
| B | Data B | `0x0400` | params: `00 00 00 00 00 00 00 64 00 00`; E5 page_key=`0x0000` (44 bytes) |
| C | Data C | `0x0400` | params: `00 00 04 00 00 00 00 64 00 00`; E5 page_key=`0x0010` (~1027 bytes) |
| D | Data D | `0x002A` | params: `00 00 08 00 00 00 00 64 00 00`; E5 page_key=`0x0010` (~1055 bytes, or 0x0000/44 bytes when BE11529 sends a duplicate) |
**E5 response `page_key` field:** bytes `data[3:5]` of the E5 payload. `0x0000` = header/short chunk, `0x0010` = main config data.
**Assembling cfg:** concatenate non-duplicate chunks in order (B+C+D). Track `(page_key, chunk_len)` pairs; drop any repeat to avoid mis-alignment.
**Expected cfg lengths:**
- Frame D delivers page `0x0010` correctly: **44 + 1027 + 1055 = 2126 bytes**
- Frame D duplicates page `0x0000` (BE11529 occasional): **44 + 1027 = 1071 bytes** (still fully decodable)
---
### 7.6.3 Sample Rate and DLE Jitter
> ✅ **CONFIRMED — 2026-04-01** (BE11529 / firmware S338.17). Validated across Normal (1024), Fast (2048), and Faster (4096) modes.
**Location:** `uint16 BE` at **anchor 2**, where anchor = `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` in cfg[40:100].
| Device Mode | uint16 BE value | Sa/s |
|---|---|---|
| Normal | `04 00` | 1024 |
| Fast | `08 00` | 2048 |
| Faster | `10 00` | 4096 |
**DLE jitter (critical — explains the ±1 byte cfg length variation):**
The sample rate bytes sit immediately before a `0x10` (DLE) prefix byte in the raw S3 frame. For the "Faster" mode (4096 = `0x1000`), the high byte `0x10` is itself a DLE character and must be escaped in the S3 frame as `10 10`. After DLE unstuffing: `10 10 00``10 00` (2 bytes). For Normal/Fast modes (high byte = `0x04`/`0x08`), no escaping needed: payload stays 3 bytes. Result: "Faster" mode produces a cfg that is **1 byte shorter** than Normal/Fast, shifting all subsequent absolute offsets by 1.
**Why anchor search is required:** any decoder that uses fixed absolute offsets for record_time or sample_rate will produce garbage values when the device is set to "Faster" mode. The 10-byte anchor `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` (`0x012C` = 300 s max-record-length constant, followed by two alarm-level floats `\xbe\x80\x00\x00\x00\x00`) straddles the boundary and is unaffected by this shift.
---
### 7.6.4 Recording Mode
> ✅ **CONFIRMED — 2026-04-20** (BE11529 / firmware S338.17). Three targeted captures in a single Blastware session (4-20-26 directory), changing Recording Mode only between each write.
Recording mode is stored as a **uint8** with different anchor-relative positions depending on whether you are reading from a device response or constructing a write payload.
**In the SUB 71 write payload (3-chunk compliance write, `cfg[5]`):**
| Enum | Mode |
|---|---|
| `0x00` | Single Shot |
| `0x01` | Continuous |
| `0x02` | Unknown (not yet observed) |
| `0x03` | Histogram |
| `0x04` | Histogram + Continuous (combined mode) |
Anchor-relative position: **anchor 3** (3 bytes before the 10-byte anchor in the write payload). The write payload layout in the region around the anchor:
```
cfg[anchor - 3] = recording_mode (uint8)
cfg[anchor - 2] = sample_rate_hi (uint8, MSB of uint16 BE)
cfg[anchor - 1] = sample_rate_lo (uint8, LSB of uint16 BE)
cfg[anchor:anchor+10] = \x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00 ← anchor
cfg[anchor + 10:anchor + 14] = record_time (float32 BE)
```
**In the E5 read response (sub-frame 1, page=`0x0010`, `data[17]`):**
The anchor appears at `data[21]` in this sub-frame. Recording mode is at `data[17]` = **anchor 4** (one position earlier than in the write payload). This is because an extra `0x10` byte is present at `data[18]` in the read format (between recording_mode and sample_rate), which is NOT present in the write payload. The read-format layout:
```
data[17] = recording_mode (uint8)
data[18] = 0x10 ← extra byte present in E5 read only; absent in SUB 71 write
data[19] = sample_rate_hi (uint8, MSB of uint16 BE)
data[20] = sample_rate_lo (uint8, LSB of uint16 BE)
data[21:31] = anchor (\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00)
data[31:35] = record_time (float32 BE)
```
**Chunk checksum at `cfg[1024]`:** The first of the three SUB 71 write chunks (1027 bytes) contains a running checksum byte at `cfg[1024]` whose delta exactly equals the delta of `cfg[5]` (recording_mode). This byte reflects the cumulative change from `recording_mode` through to its position and should not be mistaken for a second copy of the recording_mode field.
**Decode path (`_decode_compliance_config_into`):** use `data[anchor_pos - 4]` where `anchor_pos` is the index of the first byte of the anchor in the assembled E5 cfg bytes.
**Encode path (`_encode_compliance_config`):** use `cfg[anchor_pos - 3]` = recording_mode value (write-payload offset; no extra `0x10` byte).
---
### 7.7 Blastware `.set` File Format
> 🔶 **INFERRED — 2026-03-01** from `Standard_Recording_Setup.set` cross-referenced against known wire payloads.
Blastware's "save setup to disk" feature produces a binary `.set` file that is structurally identical to the wire protocol payload, but with **all multi-byte values in little-endian byte order** (Windows-native) rather than the big-endian order used on the wire. No DLE framing, no checksums — raw struct dump.
**File layout (2522 bytes observed):**
```
0x0000 Header / metadata block (~40 bytes) — partially decoded
0x002A "Standard Recording Setup.set\0" — setup filename, null-padded
0x0078 Project strings block — same layout as SUB 71 wire payload
"Project:\0" + value, "Client:\0" + value, "User Name:\0" + value,
"Seis Loc:\0" + value, "Extended Notes\0" + value
0x06A0 Channel records block — one record per channel (geo×3 + mic×1 + duplicates)
0x0820 Device info block — serial number, firmware, model strings
0x08C0 Event index / timestamp block
0x0910 Histogram / reporting config
0x09D0 Trailer (10 bytes)
```
**Per-channel record layout (little-endian, ~46 bytes per channel):**
```
offset size type value (Tran example) meaning
+00 2 uint16 0x0001 channel type (1=geophone, 0=mic)
+02 4 char[4] "Tran" channel label
+06 2 uint16 0x0000 padding
+08 2 uint16 0x0001 unknown
+0A 2 uint16 0x0050 = 80 unknown (sensitivity? gain?)
+0C 2 uint16 0x000F = 15 unknown
+0E 2 uint16 0x0028 = 40 unknown
+10 2 uint16 0x0015 = 21 unknown
+12 4 bytes 03 02 04 01 flags (recording mode etc.)
+16 4 uint32 0x00000003 record time in seconds ✅ CONFIRMED
+1A 4 float32 6.206053 ✅ CONFIRMED 2026-04-17 — ADC-to-velocity scale factor (= 1/sensitivity = (in/s)/V). Interface Handbook §4.5: Range = 1.61133 V × 6.206053 = 10.000 in/s (Normal range). Firmware uses: PPV (in/s) = ADC_voltage × 6.206053. Hardware constant — identical on all tested units. Do NOT write.
+1E 2 00 00 padding
+20 4 float32 0.6000 trigger level ✅ CONFIRMED
+24 4 char[4] "in.\0" / "psi\0" unit string (geo vs mic)
+28 4 float32 2.0000 alarm level ✅ CONFIRMED
+2C 4 char[4] "/s\0\0" / varies unit string 2
```
**MicL channel differences:**
- `channel_type` = 0 (vs 1 for geophones)
- trigger = 0.009, alarm = 0.021 (in psi)
- unit string = `"psi\0"` instead of `"in.\0"`**confirms MicL units are psi**
**Endianness summary:**
| Context | Byte order | Example (0.6 in/s trigger) |
|---|---|---|
| `.set` file | Little-endian | `9A 99 19 3F` |
| Wire protocol (SUB 71 / E5) | Big-endian | `3F 19 99 9A` |
> ❓ **`0x082A`** — still unidentified. Record time in the `.set` file = `0x00000003` (3 sec), which would be `00 00 00 03` on wire — not `0x082A`. The original sessions had record time = 2, which would be `00 00 00 02`. `0x082A` = 2090 doesn't match any obvious record time encoding. May correspond to one of the unknown uint16 fields at +0A through +10. A capture changing sample rate or histogram interval would help isolate it.
---
### 7.8 Trigger / Advanced Config Write Frame (BW→S3 SUB `0x82`)
> ✅ **CONFIRMED — 2026-03-09** from controlled BW-side capture diff (Trigger Sample Width 4→3).
SUB `0x82` is the BW→S3 write command for the advanced trigger configuration block. It is the write counterpart to the S3→BW read response SUB `0xD1` (0xFF 0x82 = 0x7D is a separate sub; the D1/2E read pair is distinct). The `0x82` write frame is only visible in `raw_bw.bin` — it does not appear in S3-side compliance dumps.
**Destuffed BW write frame layout (47 raw bytes → 46 destuffed):**
```
offset value meaning
[00] 0x10 addr (literal 0x10 after destuffing)
[01] 0x00 unknown
[02] 0x82 SUB: advanced config write
[03] 0x00 unknown
[04] 0x00 unknown
[05] 0x1C length = 28 bytes (payload size)
[06..10] 00.. header/padding
[11..16] 00.. header/padding
[17] 0x1A unknown (constant 26 = 0x1A)
[18] 0xD5 unknown (constant)
[19] 0x00 unknown
[20] 0x00 unknown
[21] 0x10 literal 0x10 (stuffed in raw frame as 10 10)
[22] 0x04/0x03 Trigger Sample Width ✅ CONFIRMED (uint8, samples)
[23] 0x0A unknown (constant 10; NOT Auto Window)
[24..43] 0xFF.. padding
[44] 0x00 unknown
[45] checksum
```
**Confirmed Trigger Sample Width values:**
| Width setting | Byte [22] |
|---|---|
| 4 samples | `0x04` |
| 3 samples | `0x03` |
| 2 samples (default) | `0x02` (expected — not yet captured) |
**Known constants in this frame:** `[17]=0x1A`, `[18]=0xD5`, `[23]=0x0A`. These do not change with Trigger Sample Width changes. Byte `[23]` = 10 was initially a candidate for Auto Window (range 19) but cannot be Auto Window because 10 is outside the valid range.
**Mode gating:** This write frame is only transmitted when Blastware performs a Send To Unit operation in Compliance / Single-Shot / Fixed Record Time mode. The frame is absent from other session types.
---
### 7.9 Mode Gating — Protocol Architecture Note
> ✅ **CONFIRMED — 2026-03-09** from controlled captures and null-change experiments.
Several settings are **mode-gated**: the device only transmits (reads) or accepts (writes) certain fields when the appropriate operating mode is active. This is an architectural property of the protocol, not a gap in capture methodology.
**Observed mode gating:**
| Setting | Gate Condition | Evidence |
|---|---|---|
| Trigger Sample Width | Compliance / Single-Shot / Fixed Record Time mode | Not visible in S3-side reads; only in BW write frame (SUB `0x82`) when mode is active |
| Auto Window | Record Stop Mode = Auto | Capture of 3→9 change in Fixed mode produced zero wire change in all frames (F7, D1, E5 all identical) |
**Implication for captures:** To map a mode-gated setting, you must first activate the gating mode on the device, then perform the compliance dump or write capture. Changing the setting value while in the wrong mode will produce no observable wire change.
**Suspected mode-gated settings not yet captured:**
- Auto Window (requires Record Stop Mode = Auto)
- Auxiliary Trigger (unknown gate condition)
---
### 7.5 Full Waveform Record (SUB F3) — 0xD2 bytes (210 bytes)
> ✅ **Updated 2026-03-31** — Full layout confirmed. See §7.7.5 for the
> complete record structure including timestamp, record type, PPV float
> positions, and project strings.
Peak values are found by searching for channel label strings `"Tran"`,
`"Vert"`, `"Long"`, `"MicL"` and reading `float32 BE` at `label_offset + 6`.
The floats are **not 4-byte aligned** — confirmed from 3-31-26 capture.
Example peak values (event 1 from 3-31-26):
```
Tran: 3D BB 45 7A = 0.0916 in/s
Vert: 3D B9 56 E1 = 0.0907 in/s
Long: 3D 75 C2 7C = 0.0605 in/s
MicL: 39 BE 18 B8 = 0.000145 psi ✅ units confirmed
```
Example peak values (event 2 from earlier capture):
```
Tran: 3D 56 CB B9 = 0.0521 in/s
Vert: 3C F5 C2 7C = 0.0300 in/s
Long: 3C F5 C2 7C = 0.0300 in/s
MicL: 39 64 1D AA = 0.0000875 psi
```
> ⚠️ The record is delivered as `data_rsp.data[11:11+0xD2]` — the outer
> data section header (LENGTH_ECHO, KEY_ECHO) occupies data[0..10].
> Callers of `read_waveform_record()` receive the 210-byte record directly.
### 7.6 Bulk Waveform Stream (SUB A5) — Raw ADC Sample Records
**Two distinct formats exist depending on recording mode. Both confirmed from captures.**
---
#### 7.6.1 Blast / Waveform mode — ✅ CONFIRMED (4-2-26 capture)
4-channel interleaved signed 16-bit little-endian, 8 bytes per sample-set:
```
[T_lo T_hi V_lo V_hi L_lo L_hi M_lo M_hi] × N sample-sets
```
- **T** = Transverse (Tran), **V** = Vertical (Vert), **L** = Longitudinal (Long), **M** = Microphone
- Channel order follows the Blastware convention: Tran is always first (ch[0]).
- Encoding: signed int16 little-endian. Full scale = ±32768 counts.
- Sample rate: set by compliance config (typical: 1024 Hz for blast monitoring).
- Each A5 frame chunk carries a different number of waveform bytes. Frame sizes
are NOT multiples of 8, so naive concatenation scrambles channel assignments at
frame boundaries. **Always track cumulative byte offset mod 8 to correct alignment.**
**A5[0] frame layout:**
```
db[7:]: [11-byte header] [21-byte STRT record] [6-byte preamble] [waveform ...]
STRT: offset 11 in db[7:]
+0..3 b'STRT' magic
+8..9 uint16 BE total_samples (full-record expected sample-set count)
+16..17 uint16 BE pretrig_samples (pre-trigger window, in sample-sets)
+18 uint8 rectime_seconds
preamble: +19..20 0x00 0x00 null padding
+21..24 0xFF × 4 synchronisation sentinel
Waveform: starts at strt_pos + 27 within db[7:]
```
**A5[1..N] frame layout (non-metadata frames):**
```
db[7:]: [8-byte per-frame header] [waveform ...]
Header: [counter LE uint16, 0x00 × 6] — frame sequence counter (0, 8, 12, 16, 20, …×0x400)
Waveform: starts at byte 8 of db[7:]
```
**Special frames:**
| Frame index | Contents |
|---|---|
| A5[0] | Probe response: STRT record + first waveform chunk |
| A5[7] | Event-time metadata strings only (no waveform data) |
| A5[9] | Terminator frame (page_key=0x0000) — ignored |
| A5[1..6,8] | Waveform chunks |
**Confirmed from 4-2-26 blast capture (total_samples=9306, pretrig=298, rate=1024 Hz):**
```
Frame Waveform bytes Cumulative Align(mod 8)
A5[0] 933B 933B 0
A5[1] 963B 1896B 5
A5[2] 946B 2842B 0
A5[3] 960B 3802B 2
A5[4] 952B 4754B 2
A5[5] 946B 5700B 2
A5[6] 941B 6641B 4
A5[8] 992B 7633B 1
Total: 7633B → 954 naive sample-sets, 948 alignment-corrected
```
Only 948 of 9306 sample-sets captured (10%) — `stop_after_metadata=True` terminated
download after A5[7] was received.
**Channel identification note:** The 4-2-26 blast saturated all four geophone channels
to near-maximum ADC output (~3200032617 counts). Channel ordering [Tran, Vert, Long, Mic]
= [ch0, ch1, ch2, ch3] is the Blastware convention and is consistent with per-channel PPV
values (Tran=0.420, Vert=3.870, Long=0.495 in/s from 0C record), but cannot be
independently confirmed from a fully-saturating event alone.
---
#### 7.6.2 Noise monitoring / Histogram mode — ✅ CONFIRMED (3-31-26 capture)
32-byte blocks with the following layout:
```
Offset Size Type Description
0 2 uint16 LE block type: 0x0016=sync, 0x0000=data
2 2 uint16 LE block counter (ctr)
4 18 int16 LE × 9 ADC samples
22 10 bytes metadata: [00 01 43 VAR VAR 00 pretrig rectime 00 00]
```
- Sync blocks (type=0x0016) appear at the start of each A5 frame; ctr=0 in sync blocks.
- Data blocks (type=0x0000) carry actual sample data. First data block ctr=288 (empirical,
not yet decoded — likely related to a pre-trigger sample offset).
- Metadata fixed bytes: `00 01 43` then 2 variable bytes, then `00 [pretrig] [rectime] 00 00`.
Pretrig byte = 0x1E (30) and rectime byte = 0x0A (10) for the 3-31-26 capture.
- 9 samples per block (int16 LE, NOT big-endian). Noise floor ≈ 911 counts.
- **This is a different recording mode** from waveform/blast — the device firmware uses
32-byte blocks for histogram/noise monitoring and 4-channel continuous for waveform events.
> ❓ **Open:** The 9-sample-per-block structure does not divide evenly into 4 channels.
> Whether these represent a single channel, all channels in rotation, or downsampled
> aggregates is not yet determined. The first data block ctr=288 vs pretrig=30 is also
> unexplained — possibly counting in units other than sample-sets.
---
---
### 7.7 Event Download Protocol — Confirmed from 3-31-26 Capture ✅
> **Added 2026-03-31.** All findings confirmed from live bridge capture
> `bridges/captures/3-31-26/raw_bw_20260331_200245.bin` +
> `raw_s3_20260331_200245.bin` (148 BW frames / 147 S3 frames).
> Analysis scripts: `parsers/analyze_3_31_26.py`.
#### Overview
Event download uses four SUBs in a key-driven iterator loop. The
"waveform key" is a 4-byte opaque record address that uniquely identifies
one histogram bin or waveform record on the device's internal storage.
| Step | BW SUB | S3 Response | Purpose |
|---|---|---|---|
| 1 (once) | `1E` — EVENT_HEADER | `E1` | Get the first waveform key |
| 2 | `0A` — WAVEFORM_HEADER | `F5` | Check record type / confirm full bin |
| 3 | `0C` — WAVEFORM_RECORD | `F3` | Download 210-byte record (peaks, project, timestamp) |
| 4 | `1F` — EVENT_ADVANCE | `E0` | Advance iterator, get next key |
| ↑ repeat steps 24 until key == `00 00 00 00` | | | |
**Blastware optimisation (confirmed):** Step 2 (0A) is only called for the
_first_ key. Subsequent keys come from `1F` with token `0xFE` (download
mode), which guarantees they are full records — so Blastware skips 0A and
jumps directly to 0C. Our implementation follows the same pattern.
---
#### 7.7.1 Waveform Key
The waveform key is a 4-byte opaque record address (`uint32`, likely
a flash sector offset or circular-buffer pointer internal to the S3 DSP).
- First key: returned by `1E` at `data[11:15]`
- Subsequent keys: returned by `1F` at `data[11:15]`
- Terminator: `00 00 00 00` signals no more events
Example keys from 3-31-26 capture (one Blastware "event" / 4 histogram bins):
```
01 11 00 16 ← first bin (full, 0x30 length)
01 11 11 B6 ← second bin (partial, 0x26 length — skipped by 1F/0xFE)
01 11 11 F6 ← third bin (partial, 0x26 length — skipped)
01 11 12 36 ← fourth bin (full, 0x30 length — returned by 1F/0xFE)
00 00 00 00 ← terminator
```
---
#### 7.7.2 Token Byte (SUB 1E / 1F)
A token byte at `payload[12]` (= `params[6]` in `build_bw_frame`) controls
the 1F advance behaviour:
| Token | Mode | Behaviour |
|---|---|---|
| `0x00` | Browse | Advance one record, including partial histogram bins |
| `0xFE` | Download | Skip partial bins, advance to the next full record |
**We always use `0xFE`** — it minimises round trips and avoids needing to
handle partial-bin `0C` calls.
---
#### 7.7.3 Variable DATA_LENGTH for SUB 0A (WAVEFORM_HEADER)
Unlike all other SUBs, `0A` does NOT have a fixed data length. The length
is returned in the probe response at `data[4]`:
| Length | Meaning |
|---|---|
| `0x30` | Full histogram bin — has a waveform record to download |
| `0x26` | Partial histogram bin — no waveform record |
Both the probe and data-request frames carry the same key in `params[4..7]`.
The `read_waveform_header()` method in `protocol.py` reads `probe.data[4]`
and uses that value as the data-request offset.
---
#### 7.7.4 Response Data Section Layout
**All S3 event download responses** share this data section prefix:
```
data[0] LENGTH_ECHO — echoes the request DATA_LENGTH byte
data[1..4] 00 00 00 00 — four zero bytes
data[5..8] KEY_ECHO — echoes the 4-byte waveform key from the request
data[9..10] 00 00 — two zero bytes
data[11..] ACTUAL_DATA — real payload starts here
```
Actual data lengths:
- `1E` response (`E1`): `data[11:19]` — 8 bytes (`data[11:15]` = key4)
- `0A` probe response (`F5`): `data[4]` = variable length (0x30 or 0x26)
- `0A` data response (`F5`): `data[11:11+length]` — waveform header bytes
- `0C` data response (`F3`): `data[11:11+0xD2]` — 210-byte waveform record
- `1F` response (`E0`): `data[11:15]` = next key4; `data[8]` = token echo
---
#### 7.7.5 Waveform Record Layout (210 bytes, SUB F3 → response F3)
> ✅ **Updated 2026-04-01** — Full timestamp layout confirmed against Blastware
> event report (BE11529 thump event, "00:28:12 April 1, 2026"). Record type
> encoding corrected (byte[1], not ASCII string search). Peak Vector Sum field
> confirmed at fixed offset 87.
The 210-byte record (`data_rsp.data[11:11+0xD2]`) contains:
**Header / Timestamp** (9 bytes at offsets 08, ✅ CONFIRMED 2026-04-01):
```
byte[0]: day (uint8)
byte[1]: sub_code 0x10 = Waveform (continuous/single-shot) ✅
histogram code not yet captured ❓
byte[2]: month (uint8)
bytes[34]: year (uint16 big-endian)
byte[5]: unknown (0x00 in all observed samples ❓)
byte[6]: hour (uint8)
byte[7]: minute (uint8)
byte[8]: second (uint8)
```
Thump event raw bytes (2026-04-01 00:28:12):
```
01 10 04 07 ea 00 00 1c 0c
↑ ↑ ↑ ↑──↑ ↑ ↑ ↑ ↑
d=1 sub m=4 y=2026 ? h=0 m=28 s=12
```
Cross-referenced against the `.MLG` file for the same event, which stores an
8-byte timestamp at two offsets (trigger time and end time):
```
MLG format: [day:1][month:1][year:2 LE][?:1][hour:1][min:1][sec:1]
01 04 ea 07 00 00 1c 0c → trigger at April 1, 2026 00:28:12
01 04 ea 07 00 00 1c 0f → end time April 1, 2026 00:28:15 (3.0 s record time ✅)
```
**Record type** — encoded in `byte[1]` (sub_code), NOT as an ASCII string:
- `0x10``"Waveform"` (continuous / single-shot mode) ✅
- histogram sub_code: not yet confirmed — capture a histogram event with `debug=true`
**Peak particle velocity floats** (✅ CONFIRMED 2026-03-31, re-confirmed 2026-04-01):
Channel labels `"Tran"`, `"Vert"`, `"Long"`, `"MicL"` are embedded as
ASCII strings at variable offsets within the record. The PPV float for
each channel is at `label_offset + 6` (IEEE 754 big-endian float32).
The floats are **NOT 4-byte aligned** — Tran, Long, and MicL all fall at
non-aligned offsets. The previous heuristic step-4 scanner missed all three.
Confirmed offsets from thump event (2026-04-01, cross-referenced vs Blastware):
```
"Tran" at offset 99 → float at 105 = 0x3ED70A2D = 0.420 in/s ✅ Blastware: 0.420
"Vert" at offset 114 → float at 120 = 0x4077AE01 = 3.870 in/s ✅ Blastware: 3.870
"Long" at offset 129 → float at 135 = 0x3EFD7090 = 0.495 in/s ✅ Blastware: 0.495
"MicL" at offset 144 → float at 150 = 0x3985114E = 0.000254 psi
```
Channel labels are separated by inner-frame bytes `10 03` (DLE ETX),
preserved as literal data by `S3FrameParser`.
**Peak Vector Sum** (✅ CONFIRMED 2026-04-01):
```
Offset 87: IEEE 754 big-endian float32
= √(Tran² + Vert² + Long²) at the sample instant of maximum
combined geo motion
NOT the vector sum of the three per-channel peaks (those may
occur at different sample times)
Thump event: 0x4079F6C5 = 3.906 in/s ✅ matches Blastware "Peak Vector Sum: 3.906 in/s"
Near-ambient: 0x3C75C28F = 0.015 in/s (histogram event, near-zero ambient)
```
**Project strings** — ASCII label-value pairs (search for label, read null-terminated value):
```
"Project:" → project description (in 0C record ✅)
"Client:" → client name (in SUB 5A / A5 frame 7 ✅ — NOT in 0C)
"User Name:" → operator / user (in SUB 5A / A5 frame 7 ✅ — NOT in 0C)
"Seis Loc:" → sensor location (in SUB 5A / A5 frame 7 ✅ — NOT in 0C)
"Extended Notes"→ notes field (in SUB 5A / A5 frame 7 ✅)
```
> ✅ **2026-04-02 — CONFIRMED:** `Client:`, `User Name:`, and `Seis Loc:` are sourced from
> **SUB 5A (bulk waveform stream)**, specifically A5 frame 7 of the multi-frame response.
> They are NOT present in the 210-byte SUB 0C waveform record. The strings reflect the
> compliance setup that was active when the event was recorded on the device — making SUB 5A
> the authoritative source for true event-time metadata. The `get_events()` client method
> now issues a SUB 5A request after each 0C download (`stop_after_metadata=True`) and
> overwrites `event.project_info` with the decoded fields.
---
#### 7.7.6 Complete Download Loop (Python pseudocode)
```python
key4, _ = proto.read_event_first() # SUB 1E
if key4 == b'\x00\x00\x00\x00':
return [] # no events
events = []
is_first = True
while key4 != b'\x00\x00\x00\x00':
if is_first:
_header, rec_len = proto.read_waveform_header(key4) # SUB 0A
is_first = False
if rec_len < 0x30:
key4 = proto.advance_event() # skip partial first bin
continue
record = proto.read_waveform_record(key4) # SUB 0C (0xD2 bytes)
events.append(decode(record))
key4 = proto.advance_event() # SUB 1F (token=0xFE)
return events
```
---
### 7.7.7 Updated Download Loop with SUB 5A Metadata
> ✅ **Added 2026-04-02.** Confirmed working on BE11529 over TCP/cellular.
```python
key4, _ = proto.read_event_first() # SUB 1E
if key4 == b'\x00\x00\x00\x00':
return []
events = []
is_first = True
while key4 != b'\x00\x00\x00\x00':
if is_first:
_header, rec_len = proto.read_waveform_header(key4) # SUB 0A
is_first = False
if rec_len < 0x30:
key4 = proto.advance_event()
continue
record = proto.read_waveform_record(key4) # SUB 0C (0xD2 bytes)
event = decode(record)
a5_data = proto.read_bulk_waveform_stream( # SUB 5A → A5 frames
key4, stop_after_metadata=True)
client._decode_a5_metadata_into(a5_data, event) # overwrites project_info
events.append(event)
key4 = proto.advance_event() # SUB 1F (token=0xFE)
return events
```
---
### 7.8 SUB 5A — Bulk Waveform Stream (event-time metadata)
> ✅ **Added 2026-04-02.** Frame format confirmed by reproducing Blastware wire bytes
> byte-for-byte from the 1-2-26 BW capture.
SUB 5A initiates a bulk transfer of the raw sample data for a stored event. The response is a
sequence of A5 frames. Frame 7 (0-indexed) contains the full compliance setup as it existed
when the event was recorded — including `Client:`, `User Name:`, `Seis Loc:`, and
`Extended Notes` ASCII label-value pairs.
#### 7.8.1 Frame Format
SUB 5A uses a **non-standard frame layout** that differs from all other BW→S3 write commands.
```
[ACK][STX][10][10][00][5A][00][offset_hi][offset_lo][params...][chk][ETX]
41 02 10 10 00 5A 00 ^^raw^^ ^^raw^^ ^^stuffed^^
```
Two critical differences from `build_bw_frame`:
1. **`offset_hi` is sent raw, not DLE-stuffed.** When `offset_hi = 0x10`, the wire carries
a bare `0x10` — NOT the stuffed `10 10` that `build_bw_frame` would produce. The device
ignores frames where this byte is incorrectly stuffed.
2. **DLE-aware checksum.** Walking the full frame byte sequence: when a `10 XX` pair is seen,
only `XX` is added to the running sum; lone bytes are added normally.
#### 7.8.2 Request Sequence
| Frame | offset_word | counter | params | Purpose |
|---|---|---|---|---|
| Probe | `0x1004` | `0x0000` | 10 bytes (`bulk_waveform_params(0)`) | Initiate transfer |
| Chunk 1 | `0x1004` | `max(key4[2:4], 0x0400)` | 11 bytes | First data chunk |
| Chunk 2 | `0x1004` | `max(key4[2:4], 0x0400) + 0x0400` | 11 bytes | Second chunk |
| Chunk N | `0x1004` | `max(key4[2:4], 0x0400) + (N-1) * 0x0400` | 11 bytes | Nth chunk |
| … | … | … | … | … |
| Termination | `0x005A` | `max(key4[2:4], 0x0400) + N * 0x0400` | 10 bytes | End transfer |
> ⚠️ **2026-04-06 CORRECTED — chunk counter is `key4[2:4] + (N-1) * 0x0400`.**
> The 4-2-26 BW TX capture showed counter=0x1004 for chunk 1 of key `01110000`, leading to
> an interim "monotonic n * 0x0400" formula. This was accidentally correct because
> `key4[2:4] == 0x0000` for that event.
>
> **2026-04-24 CORRECTION:** The counter is an absolute circular-buffer address.
> BW's true formula is `key4[2:4] + (chunk_num - 1) * 0x0400` where `key4[2:4]` is the
> event's storage base offset (`(key4[2]<<8) | key4[3]`). For keys where
> `key4[2:4] != 0x0000` (e.g. key `01111884`), using `n * 0x0400` sends requests into the
> wrong buffer region — the device returns data from a completely different event.
>
> **2026-04-26 FINAL CORRECTION:** The formula `key4[2:4] + (N-1) * 0x0400` is wrong when
> `key4[2:4] == 0x0000` (e.g. event key `01110000`, the very first event after a device erase).
> Counter=0x0000 for chunk 1 is the same address as the probe frame — the device re-returns
> the STRT record data instead of waveform payload (frame 1 has len=1097, same as probe, and
> contains `b"STRT\xff\xfe"`, contributing zero waveform bytes).
> Final formula: `max(key4[2:4], 0x0400) + (chunk_num - 1) * 0x0400`.
> For key `01110000`: chunk 1 = 0x0400 (confirmed working, empirical test 2026-04-06).
> For key `0111245a`: chunk 1 = 0x245a (unchanged, confirmed from 4-3-26 capture).
The `stop_after_metadata=True` flag causes the loop to stop as soon as `b"Project:"` is
found in the accumulated A5 frame data, typically after 49 chunks. A termination frame
is always sent before returning.
**IMPORTANT — one extra chunk required after "Project:" for valid file footer (confirmed 2026-04-23):**
When writing a Blastware-compatible waveform file, stopping immediately at "Project:" and
sending termination produces an empty termination response with no footer bytes (`0e 08`
marker missing). Blastware downloads exactly **one more chunk** after finding "Project:"
before sending termination — that extra chunk primes the device to return valid footer
bytes (monitoring start/stop timestamps) in the termination response.
`read_bulk_waveform_stream(stop_after_metadata=True)` implements this: after the "Project:"
chunk is received, one additional chunk is requested before breaking. The termination
response (`include_terminator=True`) then contains the correct `0e 08` footer.
**do NOT use `full_waveform=True` for Blastware file writing** — for events with long
post-event silence (35 chunks), the silence chunks contain embedded device-internal
pointer structures that produce spurious STRT markers in the file body. Blastware only
downloads 45 chunks (metadata + one signal chunk) regardless of event length.
#### 7.8.3 A5 Frame Layout
Each A5 response frame contains a chunk of raw bulk data. Frame 7 of the stream carries the
compliance text block with all project-info label-value pairs. The `client` layer searches
for ASCII labels with a null-terminated value read:
```
"Project:" → null-terminated project name
"Client:" → null-terminated client name
"User Name:" → null-terminated operator name
"Seis Loc:" → null-terminated sensor location
"Extended Notes" → null-terminated notes
```
All five fields reflect the **setup at event-record time**, not the current device config.
#### 7.8.4 End-of-Stream Behaviour and Chunk Timing
> ✅ **Confirmed 2026-04-06** — empirical observation on BE11529 (S338.17) over TCP/cellular.
**End-of-stream signal:** After sending all waveform chunks, the device sends exactly **1 raw byte** in response to the next chunk request, then goes silent. This byte is not a complete DLE-framed A5 response — `S3FrameParser.bytes_fed` reports 1 and no frame is ever assembled. This is the device's natural end-of-stream indicator.
Handling logic in `read_bulk_waveform_stream`:
```
TimeoutError caught:
if bytes_fed > 0 AND frames already collected:
→ graceful end-of-stream; break loop; proceed to termination frame
else (bytes_fed == 0, no prior frames):
→ genuine transport failure; re-raise
```
**Chunk timing (BE11529, 1024 sps, TCP/cellular):**
| Metric | Observed value |
|---|---|
| Chunk response time | ~1 s per chunk |
| Chunks for a 9,306-sample event | 35 chunks |
| Data per chunk (active signal) | 1,0361,123 bytes |
| Data per chunk (post-event silence) | 1,036 bytes (uniform) |
| Safe recv timeout per chunk | **10 s** (10× typical) |
| Default transport timeout | 120 s → ~2-min stall at end-of-stream |
Chunks with uniform 1,036-byte payload (chunks 1735 in the observed event) contain all-zero ADC samples — the device continues recording silence until the configured record time expires before terminating the stream.
**ADC count-to-physical conversion — ✅ CONFIRMED 2026-04-17:**
Raw samples are signed 16-bit integers (32,768 to +32,767). Source: Interface Handbook §4.5.
**CONFIRMED 2026-04-17** — The `max_range_geo` field (float32 = 6.206053, bytes `40 C6 97 FD`) is the **ADC-to-velocity scale factor** (inverse sensitivity, (in/s)/V) for the standard Instantel geophone, confirmed from Interface Handbook §4.5. The correct conversion formula is:
```
PPV (in/s) = ADC_voltage (V) × 6.206053
= counts × (1.61133 / 32767) × 6.206053
= counts × 4.982e-5 (in/s per count at full scale)
```
where `geo_range = 1.61133 V × 6.206053 = 10.000 in/s` is the Normal (Gain=1) full-scale range. The earlier ~9× overread was caused by mistakenly using 6.206053 as the range directly — it is actually the scale factor, and the range itself is `ADC_fullscale × scale_factor = 1.61133 × 6.206053 = 10.000 in/s`. Mic channel uses psi units with its own range (still unresolved).
**Known decoder issue — fi==9 hardcoded skip:**
`_decode_a5_waveform()` contains `elif fi == 9: continue` from an earlier assumption that frame index 9 is always the device terminator. For streams with more than 9 frames, frame 9 is live waveform data. The skip discards ~1,070 bytes (~133 sample-sets) per event. Terminator detection should use `page_key == 0x0000`, not frame index. This skip should be removed.
---
## 7.9 Compliance Config Field Inventory (Blastware UI, 2026-04-08) ✅
Fields visible in the Blastware "Compliance Setup" dialog. ✅ = byte offset confirmed in code. ❓ = not yet located in raw bytes.
### Recording Setup tab
| Field | Values / Type | Status |
|---|---|---|
| Recording Mode | Single Shot (`0x00`) / Continuous (`0x01`) / Histogram (`0x03`) / Histogram+Continuous (`0x04`) | ✅ `recording_mode` — write: `cfg[anchor3]`; read E5 sf1: `data[anchor4]` — confirmed 2026-04-20 |
| Record Stop Mode | Fixed Record Time / Auto / Manual Stop | ❓ Hint: `data[40]` in E5 sf1 changed `01 7F``00 00` alongside Continuous → Single Shot; may be related but unconfirmed independently |
| Sample Rate | Standard 1024 / Fast 2048 / Faster 4096 sps | ✅ `sample_rate` (anchor2) |
| Record Time | float, seconds (3, 5, 8, 10, 13…) | ✅ `record_time` (anchor+10) |
| Histogram Interval | 5 / 15 / 30 / 60 min (mode-gated behind Histogram mode) | ❓ |
| Storage Mode | Save All Data / Save Triggered | ❓ |
| Geophone Type | Standard Triaxial / 4.5 Hz Geophone | ❓ |
| Geophone — Enable all | bool | ❓ |
| Geophone — Trigger Source | bool | ❓ |
| Chan 1-3 Trigger Level | float, in/s | ✅ `trigger_level_geo` |
| Chan 1-3 Maximum Range (range selector) | Normal 10.000 / 1.25 in/s | ✅ `geo_range` uint8 — **CONFIRMED 2026-04-20.** Offset = Tran+33 (same in E5 read and SUB 71 write — 2126-byte buffer is round-tripped verbatim). `0x00`=Normal 10 in/s, `0x01`=Sensitive 1.25 in/s. Applied to Tran/Vert/Long. **`Tran+20` is NOT this field** (constant 0x01 on all captures). |
| Chan 1-3 ADC Scale Factor | 6.206053 (in/s)/V | ✅ `geo_adc_scale` float32 — **CONFIRMED 2026-04-17.** Offset = Tran+28 (same in E5 read and SUB 71 write). Inverse sensitivity = 1/0.161133. Interface Handbook §4.5: 1.61133 V × 6.206053 = 10.000 in/s. Hardware constant — do NOT write. |
| Microphone — Enable all | bool | ❓ |
| Microphone — Trigger Source | bool | ❓ |
| Chan 4 Trigger Level | float, dB or psi | ❓ |
### Notes tab
| Field | Values / Type | Status |
|---|---|---|
| Enable User Notes | bool | ❓ |
| Project | ASCII string | ✅ (sourced from A5 frame 7 via SUB 5A) |
| Client | ASCII string | ✅ (sourced from A5 frame 7) |
| User Name | ASCII string | ✅ (sourced from A5 frame 7) |
| Seis Loc | ASCII string | ✅ (sourced from A5 frame 7) |
| Enable Extended Notes | bool | ❓ |
| Extended Notes | ASCII text | ❓ |
| Extended Notes Title | ASCII string | ❓ |
| Enable Job Number | bool | ❓ |
| Job Number | int | ❓ |
| Enable Scaled Distance | bool | ❓ |
| Distance from Blast | float | ❓ |
| Charge Weight | float | ❓ |
### Special Setups tab
| Field | Values / Type | Status |
|---|---|---|
| Unit Timer Mode | Off / On | ❓ |
| Start Date/Time | date+time | ❓ |
| Stop Date/Time | date+time | ❓ |
| Self Check Mode | Off / On | ❓ |
| Self Check Time | HH:MM | ❓ |
| **Sensor Check** | **Before monitoring / After each event / Disabled** | ❓ not yet located |
| Measurement Units | Imperial / Metric | ❓ |
| Show Mic units in dB | bool | ❓ |
| Time Format | 24 Hour / 12 Hour (AM/PM) | ❓ |
| Backlight on Time | int, seconds (0255) | ✅ event index block +75 |
| Power Saving Timeout | int, minutes | ✅ event index block +83 |
| Monitoring LCD Cycle | int | ✅ event index block +84:86 (uint16 BE) |
| Set unit time with setup | bool | ❓ |
**Note on Sensor Check:** The dropdown has three states — "Before monitoring", "After each event", "Disabled". The user's unit always runs "Before monitoring", so no enable/disable diff capture has been done yet. Finding this byte requires a capture with Disabled vs Before-monitoring with all other settings held constant.
---
## 7.10 SUB 0x0E — Channel Sensor Data & SUB 0x98 — Trigger Test ✅ 2026-04-08
Both confirmed from 4-8-26/sensor-check capture (Blastware "Unit Channel Test" comms check).
### SUB 0x0E — Channel Sensor Data
Standard two-step read. Data length: **0x0A (10 bytes)** per channel.
**Request params:** Channel selector in `params[6:8]` (bytes 6 and 7 of the 10-byte params field).
| `params[6:8]` | Channel |
|---|---|
| `00 00` | Channel 0 (Transverse) |
| `00 01` | Channel 1 (Vertical) |
| `10 02` | Channel 2 (Longitudinal) — note `0x10` in params[6] |
| `00 03` | Channel 3 |
| `10 04` | Channel 4 — note `0x10` in params[6] |
| `00 05` | Channel 5 |
| `00 06` | Channel 6 |
| `00 07` | Channel 7 |
Response SUB = 0xFF 0x0E = **0xF1**.
**Response data (10 bytes at section = data[11:]):**
The data response payload for each channel is 10 bytes. From the sensor-check S3 capture,
channels 0 and 1 have non-zero data; channels 57 are all-zero (no sensors connected to those channels). Full byte-level field mapping NOT YET done — data includes overswing ratio, frequency, and amplitude per the Blastware "Unit Channel Test" dialog columns.
Example (channel 0, first pass):
```
data[11:] = 00 01 00 00 4A 00 25 10 02 0A (17 bytes including probe + data frames combined)
```
Probe frame response confirms data length in page bytes; data frame carries the 10-byte channel reading.
**Blastware "Unit Channel Test" sequence (confirmed 4-8-26/sensor-check):**
```
SESSION_RESET + POLL × 3 (startup — SESSION_RESET required for monitoring unit)
SUB 0x08 (event index read)
POLL × 8 (Blastware polls while waiting; unit may be in sensor check during this window)
SUB 0x15 (serial number, data length 0x0A)
SUB 0x01 (device info, data length 0x98)
SUB 0x08 (event index again)
SUB 0x01 (device info again)
SUB 0x0E × 8 channels (pass 1 — channels 07, probe+data each)
[~40 second gap if unit is performing on-device sensor check]
SUB 0x0E × 8 channels (pass 2 — same channels, fresh ADC readings)
SUB 0x98 × 2 (trigger test)
```
### SUB 0x98 — Trigger Test
Single probe frame only. No data step.
**Request format:** `params[0] = 0xFF` (all other param bytes = 0x00). Sent as a single frame (probe step only, no data step).
Response SUB = 0xFF 0x98 = **0x67**. Response is a standard 17-byte zero-data S3 frame.
Wire bytes for request (confirmed frame 52 of 4-8-26 BW capture):
```
41 02 10 10 00 98 FF 00 00 00 00 00 00 00 00 00 00 00 00 A7 03
```
Blastware sends SUB 0x98 twice per comms-check cycle.
### SUB 0x01 — Device Info Block ❓
Observed in Blastware comms-check init sequence (before 0x0E channel reads). Standard two-step read with data length 0x98 (152 bytes).
**Response SUB = 0xFE** (= 0xFF 0x01 = 0xFE).
⚠️ **Naming collision:** The existing SUB 0xFE (Full Config Read, RSP 0x01) and this SUB 0x01 (RSP 0xFE) are inverse commands. They are NOT the same command. SUB 0xFE is a full 166-byte config block used for firmware version, calibration date, etc. SUB 0x01 returns 152 bytes and appears to contain a subset of device identification data.
Observed payload (data[11:], first ~40 bytes of 161 total):
```
00 00 00 00 00 42 45 31 31 35 32 39 00 ... "BE11529\0"
```
Contains serial number, firmware bytes, and floating-point calibration fields. Full field map NOT YET done.
---
## 7.11 Erase-All Protocol (SUBs 0xA3 / 0xA2 / 0x06) ✅ 2026-04-11
> ✅ **Confirmed 2026-04-11** from MITM capture of a live Blastware ACH session
> (`bridges/captures/mitm/ach_mitm_20260411_001912/`).
Blastware uses a 4-step sequence to erase all stored events from device memory.
All frames use standard `build_bw_frame` format (NOT write-format).
### 7.11.1 Wire Sequence
```
BW → device: SUB 0xA3 offset=0x0000 params=00 00 00 00 00 00 00 FE 00 00
device → BW: SUB 0x5C (begin-erase ack)
BW → device: SUB 0x1C offset=0x0000 params=00 00 00 00 00 00 00 FE 00 00 (probe)
device → BW: SUB 0xE3 (probe ack)
BW → device: SUB 0x1C offset=0x002C params=(same) (data)
device → BW: SUB 0xE3 (44-byte monitor status response)
BW → device: SUB 0x06 offset=0x0000 params=00 00 00 00 00 00 00 FE 00 00 (probe)
device → BW: SUB 0xF9 (probe ack)
BW → device: SUB 0x06 offset=0x0024 params=(same) (data)
device → BW: SUB 0xF9 (36-byte storage range response)
BW → device: SUB 0xA2 offset=0x0000 params=00 00 00 00 00 00 00 FE 00 00
device → BW: SUB 0x5D (confirm-erase ack — device memory is now cleared)
```
All response SUBs follow the standard formula `0xFF request_SUB`. No exceptions.
The `token=0xFE` at `params[7]` is required for 0xA3, 0x06, and 0xA2.
### 7.11.2 SUB 0x06 Storage Range Response (36 bytes)
The 36-byte response from the data step ends with two 4-byte event keys:
| Offset (from response end) | Field | Notes |
|---|---|---|
| `[-8:-4]` | First stored event key | e.g. `0111ea60` before erase |
| `[-4:]` | Last stored event key | e.g. `0111eaa6` before erase |
After a successful erase:
- Both keys read `01110000` (device-empty sentinel)
- The device's internal event counter has reset
Example pre-erase: `... 0111ea60 0111eaa6`
Example post-erase: `... 01110000 01110000`
### 7.11.3 Post-Erase Key Counter Reset
After a successful erase the device resets its event counter. New events start
from key `0x01110000` — the same key as the very first event ever recorded on
the device. This means:
- Any system using event keys for deduplication must clear its "seen keys" state
after an erase, or risk treating fresh events as already downloaded.
- Detection heuristic: if `max(device_keys) < historical_max_key`, the counter
was reset. All device keys should be treated as new regardless of prior state.
The `ach_server.py` implementation stores `max_downloaded_key` in `ach_state.json`
and applies this heuristic on every call-home.
### 7.11.4 Implementation Notes
- `MiniMateClient.delete_all_events()` in `client.py` orchestrates the full sequence.
- `MiniMateProtocol` exposes `begin_erase_all()`, `confirm_erase_all()`, and
`read_event_storage_range()` as separate methods.
- The ACH server `--clear-after-download` flag calls `delete_all_events()` after a
successful event download and resets `ach_state.json` state for the unit.
---
### 7.12 Auto Call Home Config (SUB 0x2C / 0x7E / 0x7F) — ✅ CONFIRMED 2026-04-20
> Confirmed from `bridges/captures/4-20-26/call home settings/` — 10 BW TX write frames
> diffed against the S3 read payload. Accessible in Blastware via Remote Access → Setup Unit.
#### 7.12.1 Read Protocol — SUB 0x2C → Response 0xD3
Standard two-step read:
| Step | Offset | Purpose |
|---|---|---|
| Probe | `0x0000` | Get ack (no data returned) |
| Data | `0x007C` (124) | Receive 125-byte raw payload |
`DATA_LENGTHS[SUB_CALL_HOME] = 0x7C`
The raw payload is accessed as `data_rsp.data[11:]` — this is 125 bytes (not 124) because
the device returns logical value 0x03 (num_retries=3) as the two-byte wire sequence
`\x10\x03`. S3FrameParser is in `STATE_IN_FRAME` when it sees `0x10`, transitions to
`STATE_AFTER_DLE`, and then on `0x03` (ETX qualifier) it would normally end the frame —
but in the `_IN_FRAME_DLE` state it instead appends **both** the `0x10` and the `0x03`
literally to the payload. The result: `raw[117] = 0x10`, `raw[118] = 0x03`, and all
subsequent fields are shifted +1 from their logical positions.
#### 7.12.2 Raw Payload Field Map (125 bytes, from `data_rsp.data[11:]`)
> All offsets are into the 125-byte raw array. Offsets ≥ 119 are shifted +1 from logical
> due to the DLE-escaped 0x03 at raw[117:119].
| Raw Offset | Field | Type | Notes |
|---|---|---|---|
| `[5]` | `auto_call_home_enabled` | uint8 | `0x00` = disabled, `0x01` = enabled |
| `[6:46]` | `dial_string` | ASCII | 40-byte null-padded, e.g. `"12345"` or phone number |
| `[87]` | `after_event_recorded` | uint8 | `0x00` = off, `0x01` = on |
| `[91]` | `at_specified_times` | uint8 | `0x00` = off, `0x01` = on |
| `[93]` | `time1_enabled` | uint8 | `0x00` = off, `0x01` = on |
| `[101]` | `time1_hour` | uint8 | 023 |
| `[102]` | `time1_min` | uint8 | 059 |
| `[95]` | `time2_enabled` | uint8 | `0x00` = off, `0x01` = on |
| `[105]` | `time2_hour` | uint8 | 023 |
| `[106]` | `time2_min` | uint8 | 059 |
| `[117]` | DLE prefix `0x10` | — | Part of `\x10\x03` wire encoding for num_retries value 3 |
| `[118]` | `num_retries` (value = 3) | uint8 | Logical value 0x03; check `raw[117] == 0x10` to detect DLE prefix |
| `[120]` | `time_between_retries_sec` | uint8 | Shift +1 from logical 119 |
| `[122]` | `wait_for_connection_sec` | uint8 | Shift +1 from logical 121 |
| `[124]` | `warm_up_time_sec` | uint8 | Shift +1 from logical 123 |
**Unconfirmed fields** (offsets not yet mapped from captures):
- Time slots 3 and 4 (if they exist — Blastware UI only shows 2 time slots in observed sessions)
- `modem_power_relay_enabled` (bool)
- `storage_mode` (call home trigger on all events vs. triggered only?)
#### 7.12.3 DLE-Escaped 0x03 — Critical Detail
The `\x10\x03` sequence at raw[117:119] is **not** a DLE stuffing artifact in the usual
sense. Standard DLE stuffing escapes `\x10``\x10\x10`. But here the device is encoding
the integer value `3` in a position where the byte `\x03` would be indistinguishable from
the frame ETX terminator. The device therefore sends `\x10\x03` (DLE + ETX = "inner-frame
terminator" in S3 inner-frame syntax). S3FrameParser correctly handles this: in
`STATE_AFTER_DLE`, seeing `\x03` (ETX) while **inside** an outer frame causes it to
append both `\x10` and `\x03` as literal bytes rather than ending the frame. The outer
frame only terminates on a **bare** `\x03` (without the DLE prefix).
The write frame sends these bytes verbatim — the device accepts `\x10\x03` in the write
payload and interprets it as the value 3. No transformation is needed in
`_encode_call_home_config()`.
**Limitation:** Any field that needs to encode the value `3` (0x03) requires this DLE
prefix. The current encoder raises `ValueError` if any hour or minute field equals 3,
since the encoder does not yet implement DLE-prefixed writes for arbitrary field positions.
In practice, 3:00 AM / 3 minutes past are unlikely scheduled call times.
#### 7.12.4 Write Protocol — SUB 0x7E → 0x7F
Write format (same as other write commands — only BW_CMD `0x10` doubled on wire;
all other bytes written raw; DLE-aware checksum):
| Step | SUB | Payload | Offset | Response |
|---|---|---|---|---|
| Data write | `0x7E` | 127 bytes (125-byte read payload + `\x00\x00`) | `data[1]+2 = 0x7E` (126) | `0x81` |
| Confirm | `0x7F` | empty | `0x00` | `0x80` |
**Write payload construction:**
```python
write_payload = bytearray(raw_125_bytes)
write_payload.append(0x00)
write_payload.append(0x00)
# patch fields in-place, then pass bytes(write_payload) to build_bw_write_frame
```
**Offset formula:** `write_payload[1] = 0x7C` (same as DATA_LENGTH).
`offset = write_payload[1] + 2 = 0x7C + 2 = 0x7E = 126`.
This follows the identical pattern as SUB 0x68 (event index write) and SUB 0x69 (waveform write).
**No preceding 0x2C read required** — Blastware sends SUB 0x7E directly using cached
state. The `seismo-relay` implementation always reads first (`get_call_home_config()`)
before writing for safety.
#### 7.12.5 Implementation Notes
- `MiniMateProtocol.read_call_home_config()` — standard two-step read; returns `data_rsp.data[11:]` (125 bytes raw)
- `MiniMateProtocol.write_call_home_config(data)` — sends SUB 0x7E (127-byte payload) then SUB 0x7F confirm
- `MiniMateClient.get_call_home_config()``CallHomeConfig` dataclass
- `MiniMateClient.set_call_home_config(...)` — reads current config, patches via `_encode_call_home_config()`, writes back
- `_decode_call_home_config(raw)` — handles DLE prefix detection at raw[117]
- `_encode_call_home_config(raw, ...)` — patches in-place, appends 2 trailing zeros; raises `ValueError` if any hour/min == 3
- REST API: `GET /device/call_home` and `POST /device/call_home` in `sfm/server.py`
- Web UI: "Call Home" tab in `sfm/sfm_webapp.html`
---
## 8. Timestamp Format
Two timestamp wire formats are used:
### 8.1 6-byte format (event index / 1E header)
> 🔶 **Updated 2026-02-26** — Year field resolved. Confidence upgraded.
Appears in event index blocks. Time-of-day fields (hour/min/sec) are absent.
**Observed example:**
```
01 07 CB 00 06 1E
```
| Byte(s) | Value | Meaning | Certainty |
|---|---|---|---|
| `01` | 1 | Record validity / type flag | 🔶 INFERRED |
| `07 CB` | 1995 | Year — 16-bit big-endian integer | ✅ CONFIRMED — 2026-02-26 |
| `00` | 0 | Unknown separator | ❓ |
| `06` | 6 | Month (June) | ✅ CONFIRMED |
| `1E` | 30 | Day (0x1E = 30 decimal) | ✅ CONFIRMED |
> ✅ **2026-02-26 — CONFIRMED:** The year 1995 is the **MiniMate Plus factory default RTC date**, which the device reverts to whenever the internal battery is disconnected or the real-time clock loses power. Any event timestamped around 1995 means the clock was not set. This is known device behavior, not an encoding anomaly.
### 8.2 9-byte format (Full Waveform Record / SUB 0C, bytes 08)
> ✅ **CONFIRMED 2026-04-01** — Cross-referenced against Blastware event report
> for BE11529 thump event: "00:28:12 April 1, 2026".
Full date + time, including a sub_code byte that encodes the recording mode.
**Observed example (thump event, 2026-04-01):**
```
01 10 04 07 ea 00 00 1c 0c
```
| Byte(s) | Value | Meaning | Certainty |
|---|---|---|---|
| `01` | 1 | Day | ✅ |
| `10` | 0x10 | Sub_code: `0x10` = Waveform (continuous mode) | ✅ / histogram code ❓ |
| `04` | 4 | Month (April) | ✅ |
| `07 ea` | 2026 | Year — 16-bit big-endian integer | ✅ |
| `00` | 0 | Unknown separator | ❓ |
| `00` | 0 | Hour | ✅ |
| `1c` | 28 | Minute | ✅ |
| `0c` | 12 | Second | ✅ |
The `.MLG` file for the same event stores the timestamp in a different binary
representation (little-endian year, no sub_code byte), confirming the waveform
record and the saved file use distinct serialisation formats.
---
## 9. Out-of-Band / Non-Frame Messages
| Message | Direction | Trigger | Certainty |
|---|---|---|---|
| `"Operating System"` | S3 → BW | Device boot / UART init / RTC reset | ✅ CONFIRMED |
> The device prints this boot string directly to the UART **before** switching to DLE-framed binary protocol mode. Your implementation should discard any non-`0x41`/non-`0x10 0x02` bytes during the connection phase. Wait for the first valid framed poll response before proceeding.
---
## 10. DLE Byte Stuffing
> ✅ **CONFIRMED — 2026-02-26** (previously ❓ SPECULATIVE)
This protocol uses standard **DLE (Data Link Escape) byte stuffing**, a classical technique used in protocols like IBM BISYNC dating to the 1970s.
### Parser State Machine
```
IDLE:
receive 0x41 → emit ACK event, stay IDLE
receive 0x10 → goto WAIT_STX
WAIT_STX:
receive 0x02 → frame started, goto IN_FRAME
receive anything → error, goto IDLE
IN_FRAME:
receive 0x10 → goto ESCAPE
receive any byte → append to buffer, stay IN_FRAME
ESCAPE:
receive 0x03 → frame complete — validate checksum, process buffer, goto IDLE
receive 0x10 → append single 0x10 to buffer, goto IN_FRAME (stuffed literal)
receive 0x02 → error (nested STX), goto IDLE
receive anything → error, goto IDLE
```
---
## 11. Checksum Reference Implementation
> ⚠️ **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
STX = 0x02
ETX = 0x03
ACK = 0x41
def stuff(data: bytes) -> bytes:
"""Escape all 0x10 bytes in payload as 0x10 0x10 for transmission."""
out = []
for b in data:
out.append(b)
if b == DLE:
out.append(DLE) # double it
return bytes(out)
def destuff(data: bytes) -> bytes:
"""Remove DLE stuffing from received payload bytes."""
out = []
i = 0
while i < len(data):
if data[i] == DLE and i + 1 < len(data) and data[i + 1] == DLE:
out.append(DLE)
i += 2
else:
out.append(data[i])
i += 1
return bytes(out)
def calc_checksum_s3(payload: bytes) -> int:
"""
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.
Output: ACK + DLE+STX + stuffed_payload + checksum + DLE+ETX
"""
chk = calc_checksum(payload)
stuffed = stuff(payload)
return bytes([ACK, DLE, STX]) + stuffed + bytes([chk, DLE, ETX])
def parse_frame(raw: bytes) -> bytes | None:
"""
Parse and validate a raw on-wire frame.
Accepts input starting with ACK (0x41) or DLE+STX (0x10 0x02).
Returns de-stuffed payload bytes on success, None on any error.
"""
# Strip optional leading ACK
if raw and raw[0] == ACK:
raw = raw[1:]
# Validate frame delimiters
if len(raw) < 5:
return None
if raw[0] != DLE or raw[1] != STX:
return None
if raw[-2] != DLE or raw[-1] != ETX:
return None
# Extract: everything between DLE+STX and DLE+ETX
inner = raw[2:-2]
chk_received = inner[-1]
stuffed_payload = inner[:-1]
# De-stuff and validate checksum
payload = destuff(stuffed_payload)
if calc_checksum(payload) != chk_received:
return None
return payload
# ── Example: build a POLL request (SUB 5B) ────────────────────────────────────
poll_payload = bytes([
0x02, # CMD
0x10, 0x10, # DLE, ADDR (each stuffed to 0x10 0x10 on wire)
0x00, # FLAGS
0x5B, # SUB: POLL
0x00, 0x00, # OFFSET_HI, OFFSET_LO
0x00, 0x00, 0x00, 0x00, # padding
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00,
])
frame = build_frame(poll_payload)
# Wire output: 41 10 02 02 10 10 10 10 00 5B 00 00 00 00 00 00 00 00 00 00 6B 10 03
```
---
## 12. Recommended Implementation Sequence
Build in this order — each step is independently testable:
1. **DLE frame parser** — stateful byte-by-byte reader implementing the §10 state machine. Handles ACK, stuffing, de-stuffing, checksum validation.
2. **`connect(port, baud=38400)`** — open port, flush buffer, discard ASCII boot strings, send first POLL frame.
3. **`identify()`** — SUB `5B` two-step read → returns `{"manufacturer": "Instantel", "model": "MiniMate Plus"}`.
4. **`get_serial()`** — SUB `15` two-step read → returns serial number string.
5. **`get_config()`** — SUB `01` two-step read → returns full config dict (firmware, channel scales, etc.).
6. **`get_event_count()`** — SUB `08` two-step read → returns number of stored events.
7. **`get_event_header(index)`** — SUB `1E` read → returns timestamp dict.
8. **`get_event_record(timestamp)`** — SUB `0C` paginated read → returns PPV dict per channel.
9. **`download_waveform(timestamp)`** — SUB `5A` bulk stream → returns raw ADC arrays per channel.
10. **`set_*()`** write commands — not yet captured, requires additional sniffing sessions.
---
## 13. Device Under Test
| Field | Value |
|---|---|
| Manufacturer | Instantel |
| Model | MiniMate Plus |
| Serial Number | BE18189 |
| Firmware | S337.17 |
| DSP / Secondary FW | 10.72 |
| Channels | Tran, Vert, Long, MicL (4 channels) |
| Sample Rate | ~1024 sps (🔶 INFERRED) |
| Bridge Config | COM5 (Blastware) ↔ COM4 (Device), 38400 baud |
| Capture Tool | s3_bridge v0.4.0 |
---
## 14. TCP / Modem Transport
> ✅ **CONFIRMED — 2026-03-31** from Blastware Operator Manual 714U0301 Rev 22 §4.4 and ACEmanager Raven modem configuration screenshots.
The MiniMate Plus protocol is **fully transport-agnostic at the byte level**. The same DLE-framed S3/BW frame stream that flows over RS-232 is transmitted unmodified over a TCP socket. No additional framing, handshake bytes, or session tokens are added at the application layer.
---
### 14.1 Two Usage Modes
**"Call Up" (Outbound TCP — SFM connects to modem)**
Blastware or SFM opens a TCP connection to the modem's static IP address on its device port. The modem bridges the TCP socket to its RS-232 serial port, which is wired directly to the MiniMate Plus. From the protocol perspective this is identical to a direct serial connection.
```
SFM ──TCP──► Raven modem ──RS-232──► MiniMate Plus
(static IP, port N) (38400,8N1)
```
This is the mode implemented by `TcpTransport(host, port)`. Typical call:
```
GET /device/info?host=203.0.113.5&tcp_port=12345
```
**"Call Home" / ACH (Inbound TCP — unit calls the server)**
The MiniMate Plus is configured with an IP address and port. On an event trigger or scheduled time it powers up its modem, which establishes a TCP connection outbound to the server. Blastware (or a future SFM ACH listener) accepts the incoming connection. After the unit connects, the PC has a configurable "Wait for Connection" window to send the first command before the unit times out and hangs up.
```
MiniMate Plus ──RS-232──► Raven modem ──TCP──► ACH server (listening)
(static office IP, port N)
```
`TcpTransport` is a **client** (outbound connect only). A separate `AchServer` listener component is needed for this mode — not yet implemented.
---
### 14.2 No Application-Layer Handshake on TCP Connect
**Confirmed from ACEmanager configuration screenshot:**
```
Enable ENQ on TCP Connect: 0-Disable
```
When a TCP connection is established (in either direction), **no ENQ byte or other handshake marker is sent** by the modem before the protocol stream starts. The first byte from either side is a raw protocol byte — for SFM-initiated call-up, SFM sends POLL_PROBE immediately after `connect()`.
**Sierra Wireless RV50/RV55 note:** Even with Quiet Mode enabled, these modems send `\r\nRING\r\n\r\nCONNECT\r\n` over the TCP connection to the calling client at connect time. Quiet Mode only suppresses these strings on the *serial* port (protecting the MiniMate Plus). The TCP client must tolerate these prefix bytes — scan for DLE+STX (`0x10 0x02`) and discard everything before it. This is the same approach used for the `"Operating System"` boot string (§9).
The Raven X (deprecated) did not exhibit this behaviour. The note below about "no CONNECT string" describes Raven X with ENQ-disable; it does **not** apply to RV50/RV55.
No ENQ byte or other application-layer handshake is added. The Raven modem's TCP dialog is configured with:
| ACEmanager Setting | Value | Meaning |
|---|---|---|
| TCP Auto Answer | 2 — Telnet Server | TCP mode (transparent pass-through, not actually Telnet) |
| Telnet Echo Mode | 0 — No Echo | No echo of received bytes |
| Enable ENQ on TCP Connect | 0 — Disable | No ENQ byte on connect |
| TCP Connect Response Delay | 0 | No delay before first byte |
| TCP Idle Timeout | 0 | No modem-level idle disconnect |
---
### 14.3 Modem Serial Port Configuration
> **Hardware note:** The Raven X modem shown in the Blastware manual is 3G-only and no longer operational (3G network shutdown). The current field hardware is the **Sierra Wireless RV55** (and newer RX55). Both run ALEOS firmware and have an identical ACEmanager web UI — the settings below apply to all three generations.
The modem's RS-232 port (wired to the MiniMate Plus) must be configured as:
| ACEmanager Setting | Value |
|---|---|
| Configure Serial Port | **38400,8N1** |
| Flow Control | None |
| DB9 Serial Echo | OFF |
| Data Forwarding Timeout | **1 second** (S50=1) |
| Data Forwarding Character | 0 (disabled) |
The **Data Forwarding Timeout** is the most protocol-critical setting. The modem **accumulates bytes from the RS-232 port for up to 1 second** before forwarding them as a TCP segment. This means:
- A large S3 response frame may arrive as multiple TCP segments with up to 1-second gaps between them.
- A `read_until_idle` implementation with `idle_gap < 1.0 s` will **incorrectly declare the frame complete mid-stream**.
- `TcpTransport.read_until_idle` overrides the default `idle_gap=0.05 s` to `idle_gap=1.5 s` to compensate.
If connecting to a unit via a direct Ethernet connection (no serial modem in the path), the 1.5 s idle gap will still work but will feel slower. In that case you can pass `idle_gap=0.1` explicitly.
#### Sierra Wireless RV50 / RV55 Required Settings
> ✅ **CONFIRMED — 2026-03-31** from working RV50 field config vs misconfigured RV55.
The following ACEmanager Serial settings are required for correct transparent operation. A single wrong setting is enough to break the protocol exchange (unit beeps on connect but never returns an S3 frame):
| ACEmanager Setting | Required Value | Why |
|---|---|---|
| **Quiet Mode** | **Enable** | Disabling it causes the modem to inject `RING\r\nCONNECT\r\n` onto the RS-232 serial line at connection time, corrupting the S3 handshake. |
| **Configure Serial Port** | `38400,8N1` | Must match MiniMate baud rate. |
| **Flow Control** | `None` | Hardware flow control (CTS/RTS) will block unit's serial TX if pins are not wired. |
| **Data Forwarding Timeout** | `1` (= 0.1 s) | Controls RS-232→TCP forwarding latency. `5` (0.5 s) works but is sluggish; `1` matches working field units. |
| **TCP Connect Response Delay** | `0` | Any non-zero value causes the modem to silently discard our POLL frame during the delay window. |
| **TCP Idle Timeout** | `2` (minutes) | Prevents premature disconnect. Too low and the modem drops the session before the unit responds. |
| **DB9 Serial Echo** | `Disable` | Echo would corrupt the S3 stream. |
---
### 14.4 Connection Timeouts on the Unit Side
The MiniMate Plus firmware has two relevant timeouts configurable via Blastware's Call Home Setup dialog:
| Timeout | Description | Impact |
|---|---|---|
| **Wait for Connection** | Seconds after TCP connect during which the unit waits for the first BW frame. If nothing arrives, unit terminates the session. | SFM must send POLL_PROBE within this window after `connect()`. Default appears short (≈1530 s). |
| **Serial Idle Time** | Seconds of inactivity after which the unit terminates the connection. | SFM must complete its work and disconnect cleanly — or send periodic keep-alive frames — within this window. |
For our `TcpTransport` + `MiniMateProtocol` stack, both timeouts are satisfied automatically because `connect()` is immediately followed by `protocol.poll()` which sends POLL_PROBE, and the full session (POLL + read + disconnect) typically completes in < 30 seconds.
---
### 14.5 Port Numbers
The TCP port is **user-configurable** in both Blastware and the modem. There is no universally fixed port.
| Setting location | Value in manual example | Value in user's install |
|---|---|---|
| Blastware TCP Communication dialog | 12335 | 12345 |
| Raven ACEmanager Destination Port | 12349 (UDP example) | varies |
`TcpTransport` defaults to `DEFAULT_TCP_PORT = 12345` which matches the user's install. This can be overridden by the `port` argument or the `tcp_port` query parameter in the SFM server.
---
### 14.6 ACH Session Lifecycle (Call Home Mode) ✅ IMPLEMENTED 2026-04-11
When the unit calls home under ACH, the session lifecycle from the unit's perspective is:
1. Unit triggers (event or scheduled time)
2. Unit powers up modem, dials / connects TCP to server IP:port
3. Unit waits for "Wait for Connection" window for first BW frame from server
4. Server sends POLL_PROBE → unit responds with POLL_RESPONSE (same as serial)
5. Server reads serial number, full config, events as needed
6. (Optional) Server erases device memory: SUB 0xA3 → 0x1C → 0x06 → 0xA2
7. Server disconnects (or unit disconnects on Serial Idle Time expiry)
8. Unit detects DCD/DTR going low (modem signals line drop), returns to monitor mode automatically
Step 4 onward is **identical to the serial/call-up protocol**. The only difference
from our perspective is that we are the **listener** rather than the **connector**.
**Implementation: `bridges/ach_server.py`** — run with `python bridges/ach_server.py`.
Key flags:
- `--clear-after-download` — erase device memory after a successful event download
- `--allow-ip IP` — restrict to specific unit IPs
- `--max-events N` — cap events per session for safety
**State persistence: `ach_state.json`** — tracks `downloaded_keys` (set of event key
hex strings) and `max_downloaded_key` (high-water mark) per unit serial number.
Post-erase key reuse (`0x01110000` recycled) is detected via the high-water mark.
**Note on DCD/DTR:** The MiniMate Plus monitors the RS-232 DCD line. When the TCP
connection closes, the Sierra Wireless modem drops DCD, which the unit interprets as
"serial connection ended" and automatically resumes monitoring. No `start_monitoring()`
(SUB 0x96) command is needed from the server. ⚠️ Newer RV55 firmware may not assert DCD
by default — known issue, not yet resolved.
---
## Appendix A — s3_bridge Capture Format
> ✅ **CONFIRMED — 2026-02-26**
> ⚠️ **This behavior is not part of the Instantel protocol. It is an artifact of the bridge logger implementation.**
The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger makes one modification:
| Wire sequence | In .bin file | Notes |
|---|---|---|
| `0x10 0x03` (DLE+ETX) | `0x03` | DLE stripped from end-of-frame marker |
| All other bytes | Unchanged | ACK, DLE+STX, stuffed payload, checksum all preserved verbatim |
**Practical impact for parsing `.bin` files:**
- Frame end: scan for bare `0x03` (not `0x10 0x03`)
- Checksum: the byte immediately before the bare `0x03` is the checksum
- Everything else (ACK detection, DLE+STX, payload de-stuffing) works as documented in §10
> ⚠️ This means checksums cannot be verified on frames where the stuffed payload ends in `0x10` — that trailing `0x10` would normally be the DLE prefix of ETX, but the logger strips it, making the frame boundary ambiguous in that edge case. In practice this has not been observed in captured data.
---
## 14. Open Questions / Still Needs Cracking
| Question | Priority | Added | Notes |
|---|---|---|---|
| Timestamp 6-byte format byte[3] — purpose of the separator `0x00` byte | LOW | 2026-02-26 | Not blocking; 9-byte waveform record format (§8.2) fully confirmed without this byte. |
| `trail[0]` in serial number response — unit-specific byte, derivation unknown. `trail[1]` resolved as firmware minor version. | MEDIUM | 2026-02-26 | |
| Full channel ID mapping in SUB `5A` stream (01/02/03/04 → which sensor?) | MEDIUM | 2026-02-26 | |
| Exact byte boundaries of project string fields in SUB `71` write frame — padding rules unconfirmed | MEDIUM | 2026-02-26 | |
| Purpose of SUB `09` / response `F6` — 202-byte read block | MEDIUM | 2026-02-26 | |
| Purpose of SUB `2E` / response `D1` — 26-byte read block | MEDIUM | 2026-02-26 | |
| Full field mapping of SUB `1A` / response `E5` — channel scaling / compliance config block. **SUBSTANTIALLY RESOLVED 2026-04-01:** Multi-frame sequence (§7.6.2), page_key chunking, duplicate-page detection, record_time (§7.6.1), sample_rate (§7.6.3), setup_name, project strings all confirmed. Trigger/alarm level floats in the channel block (§7.6) also confirmed. Remaining unknowns: trigger level (geo), alarm level (geo), max range (in CFG header not yet decoded). | MEDIUM | 2026-02-26 | Substantially resolved 2026-04-01 |
| `0x082A` in channel config block — not trigger, alarm, or record time directly. **RESOLVED: fixed E5 payload length (2090 bytes).** Constant regardless of all settings. | RESOLVED | 2026-03-01 | Resolved 2026-03-09 |
| **Record time in wire protocol** — float32 BE, anchor-relative in cfg (see §7.6.1/§7.6.3). **RESOLVED.** Previous `+0x28` offset was unreliable due to DLE jitter — superseded by anchor search. Confirmed at 3, 5, 7, 8, 10, 13 seconds. | RESOLVED | 2026-03-09 | Confirmed 2026-03-09; anchor method confirmed 2026-04-01 |
| **Sample rate** — uint16 BE at anchor2 in cfg. **RESOLVED.** Normal=1024, Fast=2048, Faster=4096. Anchor required to handle DLE jitter. See §7.6.3. | RESOLVED | 2026-04-01 | Confirmed 2026-04-01 |
| Unknown uint16 fields at channel block +0A (=80), +0C (=15), +0E (=40), +10 (=21) — manual describes "Sensitive (Gain=8) / Normal (Gain=1)" per-channel range; 80/15/40/21 might encode gain, sensitivity, or ADC config. | LOW | 2026-03-01 | |
| Full trigger configuration field mapping (SUB `1C` / write `82`) | LOW | 2026-02-26 | |
| Whether SUB `24`/`25` are distinct from SUB `5A` or redundant | LOW | 2026-02-26 | |
| **Meaning of `0x07 E7` field in config block — RESOLVED:** Calibration year. uint16 BE at destuffed payload offset 0x560x57. Confirmed via two-unit comparison: BE18189 (calibrated 2023) = `07 E7`; BE11529 (calibrated 2025) = `07 E9`. Adjacent bytes at 0x530x55 encode remaining calibration date (month confirmed as BCD October for both units; full layout 🔶 INFERRED). | RESOLVED | 2026-02-26 | Resolved 2026-03-31 |
| **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 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`**~~~~RESOLVED 2026-04-08: This was a misidentification.~~ The `1C → 6E` "exception" was misread — likely an inner A4 sub-frame. Confirmed from 4-8-26 capture (338 frames): SUB 0x1C always → 0xE3. No exceptions to the `0xFF SUB` rule are known. | RESOLVED | 2026-04-08 | CLOSED |
| ~~**Max Geo Range float 6.2061**~~**RESOLVED 2026-04-17.** Confirmed as the **ADC-to-velocity scale factor** = inverse sensitivity = 1/0.161133 = **6.206053 (in/s)/V**. Source: Interface Handbook §4.5 formula `Range = 1.61133 V / Sensitivity`. For standard Instantel geo at Normal (Gain=1) range: Sensitivity = 1.61133/10 = 0.161133 V/(in/s), scale = 6.206053. Firmware: `PPV (in/s) = ADC_voltage × 6.206053`. The earlier ~9× overread was from using 6.206053 directly as range instead of as scale factor (range = 1.61133 V × 6.206053 = 10.000 in/s). Hardware constant — do NOT write. | RESOLVED | 2026-02-26 | Resolved 2026-04-17 |
| 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 | |
| Power save offset — **RESOLVED: +53 in event index data**, uint8, minutes | RESOLVED | 2026-03-02 | |
| Monitoring LCD Cycle — **RESOLVED: +54/+55 in event index data**, uint16 BE, seconds (65500 = disabled) | RESOLVED | 2026-03-02 | |
| **SUB 0x06 purpose — RESOLVED: event storage range.** Previously labeled "CHANNEL CONFIG READ". 4-11-26 MITM capture confirms it returns first/last stored event keys in the final 8 bytes of the 36-byte response. Used by Blastware as part of the erase-all verification step. | RESOLVED | 2026-04-11 | |
| **Erase-all command sequence — RESOLVED.** SUB 0xA3 (begin) + 0x1C (monitor status) + 0x06 (storage range) + 0xA2 (confirm). Confirmed from 4-11-26 MITM capture. All frames standard `build_bw_frame`, token=0xFE. | RESOLVED | 2026-04-11 | |
| **ACH inbound server — RESOLVED.** `bridges/ach_server.py` implements full inbound ACH pipeline. `--clear-after-download` flag for delete-after-upload workflow. Post-erase key-reuse detection via `max_downloaded_key` high-water mark. | RESOLVED | 2026-04-11 | |
| **Sensor Check dropdown byte location** — byte offset in 1A compliance config payload for the "Sensor Check: Before monitoring / After each event / Disabled" setting is NOT YET LOCATED. Confirmed: unit always runs with "Before monitoring" set. Need a capture with "Disabled" to diff. | MEDIUM | 2026-04-08 | Still open |
| **RV55 DCD/DTR default** — newer Sierra Wireless RV55 firmware does not assert DCD/DTR by default, so the MiniMate Plus never detects TCP disconnect and stays idle instead of resuming monitoring. Root cause: RV55 ACEmanager `DCD Control` setting. Workaround not yet found. | MEDIUM | 2026-04-11 | Still open |
---
---
## Appendix B — Operator Manual Cross-Reference (716U0101 Rev 15)
> Added 2026-03-02. Cross-referencing confirms setting names, ranges, units, and behavior for fields found in protocol captures. The manual does NOT describe the wire protocol — it describes the user-facing device interface. Use to infer data types, ranges, and semantics of protocol fields.
| Setting Name (Manual) | Manual Location | Protocol Location | Type | Range / Notes |
|---|---|---|---|---|
| Backlight On Time | §3.13.1e | Event Index +4B | uint8 | 0255 seconds |
| Power Saving Timeout | §3.13.1f | Event Index +53 | uint8 | minutes (user sets 160+) |
| Monitoring LCD Cycle | §3.13.1g | Event Index +54/55 | uint16 BE | seconds; 0=off; 65500≈disabled |
| Trigger Level (Geo) | §3.8.6 | Channel block, float | float32 BE | 0.00510.000 in/s |
| Alarm Level (Geo) | §3.9.9 | Channel block, float | float32 BE | higher than trigger level |
| Trigger Level (Mic) | §3.8.6 | Channel block, float | float32 BE | 100148 dB in 1 dB steps |
| Alarm Level (Mic) | §3.9.10 | Channel block, float | float32 BE | higher than mic trigger |
| Record Time | §3.8.9 | cfg anchor+10, float32 BE (wire); `.set` +16, uint32 LE (file) | float32 BE (wire) | 1105 s; confirmed 3→`40400000`, 5→`40A00000`, 8→`41000000`, 13→`41500000`. Use anchor §7.6.1/§7.6.3 — NOT fixed offset. |
| ADC Scale Factor (geo_adc_scale) | §3.8.4 / Interface Handbook §4.5 | Channel block, Tran+28 (same in E5 read and SUB 71 write), float32 BE | float32 BE = 6.206053 | ✅ CONFIRMED 2026-04-17 — inverse sensitivity (in/s)/V. `Range = 1.61133 V × 6.206053 = 10.000 in/s`. Firmware: `PPV (in/s) = ADC_voltage × 6.206053`. Hardware constant, identical on all units. Do NOT write. |
| Max Geo Range (geo_range) | §3.8.4 | Channel block, Tran+33 (same in E5 read and SUB 71 write), uint8; applied to Tran/Vert/Long | uint8 | ✅ CONFIRMED 2026-04-20 — `0x00`=Normal 10.000 in/s, `0x01`=Sensitive 1.250 in/s. **NOTE: `Tran+20` reads `0x01` on ALL captures regardless of range — it is NOT this field.** |
| Microphone Units | §3.9.7 | Inline unit string | char[4] | `"psi\0"`, `"pa.\0"`, `"dB\0\0"` |
| Sample Rate | §3.8.2 | cfg anchor2, uint16 BE — anchor=`\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` in cfg[40:100] | uint16 BE | Normal=1024, Fast=2048, Faster=4096 ✅ CONFIRMED 2026-04-01 (BE11529 S338.17). Anchor required — see §7.6.3 DLE jitter. |
| Record Mode | §3.8.1 | Write: `cfg[anchor3]`, uint8. Read (E5 sf1): `data[anchor4]`, uint8. Note: extra `0x10` byte at read `data[anchor3]` shifts offset by 1 vs write. | uint8 | `0x00`=Single Shot, `0x01`=Continuous, `0x02`=unknown, `0x03`=Histogram, `0x04`=Histogram+Continuous. ✅ CONFIRMED 2026-04-20 |
| 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? | 19 seconds; only active when Record Stop Mode = Auto. Capture in Fixed mode produced no wire change. |
| 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 |
---
## Appendix C — Logger & Parser Validation (2026-03-02)
> Documents the logger integrity verification and parser refactor completed 2026-03-02. Tooling behavior only — not protocol semantics.
### C.1 Logger Validation
**Concern:** Earlier sessions noted that the `s3_bridge` logger may have been stripping `0x10` from `DLE ETX` sequences, producing bare `0x03` terminators in the capture file.
**Resolution:** HxD inspection of a new capture produced by `s3_bridge v0.5.0` confirmed that `10 03` sequences are present intact inside S3→BW record payloads. The `forward_loop` function writes raw bytes to the `.bin` before any sniffer or framing logic runs — there is no ETX stripping in v0.5.0.
The earlier stripping behavior applied to a previous logger version. v0.5.0 is confirmed lossless with respect to wire bytes.
**Confirmed wire framing:**
- Frame start: `0x10 0x02` (DLE STX) ✅
- Frame end: `0x10 0x03` (DLE ETX) ✅
- DLE stuffing: `0x10 0x10` in payload = literal `0x10`
### C.2 Capture Architecture (Current)
As of 2026-03-02 the capture pipeline produces two flat raw wire dump files per session:
| File | Contents |
|---|---|
| `raw_s3.bin` | All bytes transmitted by S3 (device → Blastware), in order |
| `raw_bw.bin` | All bytes transmitted by BW (Blastware → device), in order |
No record headers, no timestamps, no framing logic applied by the dumper. Files are flat concatenations of `serial.read()` chunks. Frame boundaries must be recovered by the parser.
### C.3 Parser Design — DLE State Machine
A deterministic state machine replaces all prior heuristic scanning.
**States:**
```
STATE_IDLE — scanning for frame start
STATE_IN_FRAME — consuming payload bytes
STATE_AFTER_DLE — last byte was 0x10, awaiting qualifier
```
**Transitions:**
| Current State | Byte | Action | Next State |
|---|---|---|---|
| IDLE | `10 02` | Begin new frame | IN_FRAME |
| IDLE | any | Discard | IDLE |
| IN_FRAME | `!= 10` | Append to payload | IN_FRAME |
| IN_FRAME | `10` | — | AFTER_DLE |
| AFTER_DLE | `10` | Append literal `0x10` | IN_FRAME |
| AFTER_DLE | `03` | Frame complete, emit | IDLE |
| AFTER_DLE | other | Treat as payload (recovery) | IN_FRAME |
**Properties:**
- Does not scan globally for `10 02`
- Only complete STX→ETX pairs are emitted as frames
- Incomplete trailing frames at EOF are discarded (expected at capture boundaries)
- DLE stuffing handled correctly
### C.4 Observed Traffic (Validation Captures)
**`raw_bw.bin`** (Blastware → S3):
- 7 complete frames via state machine
- Mostly small command/control frames, several zero-length payloads
- Bare `0x02` used as STX (asymmetric — BW does not use DLE STX)
- Contains project metadata strings: `"Standard Recording Setup.set"`, `"Claude test2"`, `"Location #1 - Brians House"`
**`raw_s3.bin`** (S3 → Blastware):
- First frame payload ~3922 bytes (large structured response)
- Repeated `"Instantel"` / `"MiniMate Plus"` / `"BE18189"` strings throughout
- Multiple medium-length structured frames
- DLE+ETX confirmed intact
### C.5 Key Lessons
1. **Global byte counting ≠ frame counting.** `0x10 0x02` appears inside payloads. Only state machine transitions produce valid frame boundaries.
2. **STX count ≠ frame count.** Only STX→ETX pairs within proper state transitions count.
3. **EOF mid-frame is normal.** Capture termination during active traffic produces an incomplete trailing frame. Not an error.
4. **Layer separation.** The parser extracts frames only. Decoding block IDs, validating checksums, and interpreting semantics are responsibilities of a separate protocol decoder layer above it.
### C.6 Parser Layer Architecture
```
raw_s3.bin / raw_bw.bin
DLE Frame Parser (s3_parser.py) <- framing only
Protocol Decoder (future) <- SUB IDs, block layout, checksums
Semantic Interpretation <- settings, events, responses
```
---
---
## Appendix D — Blastware Binary File Formats (.N00 / .MLG / others)
> ✅ CONFIRMED 2026-04-21 — all fields verified by binary diff of reconstructed vs reference
> files from the 4-3-26-multi_event capture (M529LIY6.N00, BE11529.MLG).
>
> ⚠️ EXTENSION MAPPING REFUTED 2026-04-21 — earlier assumption that extension encodes
> recording mode is **FALSE**. A continuous-mode event produced `.EI0`, not `.9T0`.
> Extension encoding algorithm is unknown. Do not use extension to infer recording mode.
### D.1 Common File Header (22 bytes)
All Blastware files (regardless of type) share an 18-byte prefix followed by a 4-byte type tag.
| Offset | Length | Value | Description |
|---|---|---|---|
| 0x00 | 6 | `10 00 01 80 00 00` | Fixed prefix |
| 0x06 | 10 | `Instantel\x00` | ASCII string |
| 0x10 | 2 | `07 2c` | Fixed suffix |
| 0x12 | 4 | varies | File type tag (see below) |
**Total header: 22 bytes.**
**Type tags:**
| Extension | Type tag | Description |
|---|---|---|
| `.N00` | `00 12 03 00` | Waveform event (confirmed) |
| `.9T0` | `00 12 03 00` | Waveform event — same type tag as .N00 (assumed; not independently confirmed) |
| `.EI0` | `00 12 03 00` | Waveform event — same type tag (assumed; continuous-mode event observed 2026-04-21) |
| `.MLG` | `22 01 0e a0` | Monitor log |
**Extension encoding — new firmware (V10.72+) FULLY DECODED (confirmed 2026-04-22):**
The extension differs depending on how the file was saved:
| Download method | Extension format | Example |
|---|---|---|
| Manual / direct (Blastware connected to unit) | `AB0` (3 chars) | `.CE0` |
| Call-home / ACH | `AB0W` or `AB0H` (4 chars) | `.CE0H` |
Where:
- `AB` = 2-char base-36 of `total_seconds % 1296`; `A = value // 36`, `B = value % 36`
- `total_seconds = (event_local_time 1985-01-01T00:00:00_local)` in seconds
- `0` = always literal digit zero
- `W` = Full Waveform, `H` = Full Histogram (ACH only)
Base-36 alphabet: `09` = 09, `AZ` = 1035.
The 10-year production archive contains only ACH files (all end in W or H). Manual Blastware downloads produce the same `AB0` prefix but without the trailing type character.
**3-day cycle property (confirmed 2026-04-22):** A unit recording at a fixed daily time cycles through exactly **3 different extensions** with a 3-day period. Each calendar day shifts `total_seconds % 1296` by 864 (since `86400 % 1296 = 864`). The cycle repeats every 3 days because `gcd(1296, 864) = 432`. Confirmed from archive: top 3 extensions `CE0H` (95), `0E0H` (93), `OE0H` (91) are the 3-day cycle of a 06:00:14 daily call-in (seconds-in-window = 446, 14, 878).
**B character invariance:** `864 = 24 × 36`, so adding one day never changes `value % 36` — the second extension character is invariant for a fixed daily recording time. Only the first character cycles through 3 values.
**Old firmware (S338):** 3-char extensions observed (`.N00`, `.EI0`, etc.) — may simply be manual downloads under the same AB0 scheme, or a different encoding. Not yet confirmed.
**Micromate Series 4** uses a different extension format (observed: `IDFH`, `IDFW`). This formula does NOT apply to Micromate units.
All waveform files share the same `00 12 03 00` type tag regardless of extension. Blastware identifies file type by extension, not by type tag alone.
### D.2 Timestamp Encoding (Blastware files)
All timestamps in N00 and MLG files use an **8-byte big-endian format**:
| Byte | Field |
|---|---|
| 0 | day (uint8) |
| 1 | month (uint8) |
| 23 | year (uint16 BE) |
| 4 | `0x00` (reserved) |
| 5 | hour (uint8) |
| 6 | minute (uint8) |
| 7 | second (uint8) |
Example: `01 04 07 ea 00 00 1c 08` → April 1, 2026, 00:28:08.
Note: this differs from the 8-byte protocol timestamp (`[day][sub_code][month][year_HI][year_LO][0x00][hour][min][sec]` = 9 bytes) used in the device's on-wire 0C waveform records. The file format uses a compact 8-byte layout without the `sub_code` byte.
### D.3 N00 File Format — Single-Shot Waveform Event
**File layout:** `[22B header] [21B STRT record] [body bytes] [26B footer]`
#### D.3.1 STRT Record (21 bytes)
The STRT record immediately follows the 22-byte header.
| Offset | Length | Field | Notes |
|---|---|---|---|
| 0 | 4 | `STRT` | ASCII literal |
| 4 | 2 | `ff fe` | Fixed |
| 6 | 4 | event key (key4) | 4-byte waveform key |
| 10 | 4 | device-specific | NOT a repeat of key4 — device-internal field |
| 14 | 6 | device-specific | NOT zero-padded — device-internal fields |
| 20 | 1 | rectime | uint8 seconds |
**Critical:** The STRT record must be copied verbatim from A5[0].data[7+strt_pos:] — bytes [10:20] contain device-specific values that cannot be reconstructed from protocol-level Event fields alone.
#### D.3.2 Body Bytes (variable)
The body is reconstructed from the raw A5 bulk waveform stream frames by stripping DLE framing markers and taking the appropriate slice of each frame's data section.
**Per-frame contribution (from `frame.data`):**
| Frame | Skip amount | Notes |
|---|---|---|
| A5[0] (probe) | `7 + strt_pos_in_w0 + 21` | Skip frame.data prefix + STRT record |
| A5[1] | 13 | 7-byte prefix + 6-byte first-chunk header |
| A5[2..N] | 12 | 7-byte prefix + 5-byte chunk header |
| Terminator (page_key=0x0000) | 11 | 7-byte prefix + 4-byte terminator header |
**DLE strip rule:** For each frame's contribution (`frame.data[skip:]`), strip any `0x10` byte immediately followed by `0x02`, `0x03`, or `0x04`. Only the `0x10` is stripped; the following byte is kept as payload.
**Split-pair edge case:** When `frame.data[-1] == 0x10` AND `frame.chk_byte ∈ {0x02, 0x03, 0x04}`, the S3FrameParser split a DLE+XX pair at the payload/checksum boundary. Reunite the bytes before stripping (`relevant + bytes([chk_byte])`), then always remove the trailing chk_byte from the result (`stripped[:-1]`) — chk_byte is the wire checksum, never payload.
**Body/footer split:** Accumulate all frame contributions (data frames + terminator) into `all_bytes`. Then:
- `body = all_bytes[:-26]` (variable length)
- `footer = all_bytes[-26:]` (always 26 bytes — extracted from terminator content)
#### D.3.3 Footer (26 bytes)
The footer terminates the N00 file. Its bytes come directly from the terminator A5 frame's inner content — do NOT reconstruct from event metadata.
| Offset | Length | Field | Notes |
|---|---|---|---|
| 0 | 2 | `0e 08` | Fixed marker |
| 2 | 8 | ts1 | Start timestamp (8B big-endian) |
| 10 | 8 | ts2 | Stop timestamp (8B big-endian) |
| 18 | 6 | `00 01 00 02 00 00` | Fixed |
| 24 | 2 | CRC | 2-byte CRC — algorithm unconfirmed |
**CRC:** The 2-byte CRC at footer[24:26] has an unconfirmed algorithm. In M529LIY6.N00 it reads `fe da`. Attempts to match CRC-16/CCITT, CRC-16/IBM, CRC-32 (truncated), and 40+ polynomial/init combinations all failed. The writer copies it verbatim from the terminator frame.
### D.4 MLG File Format — Monitor Log
**File layout:** `[308B header] [N × 292B records]`
#### D.4.1 MLG Header (308 bytes)
| Offset | Length | Field | Notes |
|---|---|---|---|
| 0x00 | 22 | common header | prefix + `22 01 0e a0` type tag |
| 0x16 | 16 | unknown | observed as zeros in BE11529.MLG |
| 0x2A | 8 | serial number | null-padded ASCII (e.g. `"BE11529"`) |
| 0x32 | remainder | zero pad | pads to 308 bytes total |
#### D.4.2 MLG Record (292 bytes each)
| Offset | Length | Field | Notes |
|---|---|---|---|
| 0 | 2 | CRC | 2-byte CRC — algorithm unconfirmed; write as `00 00` |
| 2 | 4 | `22 01 0e 80` | Record marker |
| 6 | 8 | ts1 | Start timestamp (8B big-endian) |
| 14 | 8 | ts2 | Stop timestamp (8B big-endian); zeros if no stop |
| 22 | 4 | flags | Record type flags (see below) |
| 26 | 10 | serial | Null-padded ASCII serial number |
| 36 | variable | text | Type-dependent content |
| — | remainder | zero pad | pads to 292 bytes total |
**Record flags:**
| Value | Meaning |
|---|---|
| `ff ff 00 00` | Monitoring start with no stop recorded |
| `01 00 02 00` | Triggered event (has ts1 + ts2) |
| `02 00 00 00` | Monitoring interval (has ts1 + ts2) |
**Text content for triggered events (`flags = 01 00 02 00`):**
| Byte | Field |
|---|---|
| 0 | `0x08` |
| 18 | ts1 copy (8B big-endian) |
| 9+ | `"Geo: X.XXX in/s\x00"` ASCII geo threshold |
#### D.4.3 MLG CRC
The 2-byte CRC at record[0:2] uses an unconfirmed algorithm. Tested against CRC-16/CCITT, CRC-16/IBM, CRC-32 (truncated), word sums, XOR variants, and 40+ polynomial/init combinations — none matched. The writer emits `00 00`. Blastware may reject files with incorrect CRCs (impact on import unknown — TODO: test).
### D.5 Filename Encoding ✅ PARTIALLY CONFIRMED 2026-04-22
Blastware assigns waveform filenames of the form `<prefix_letter><serial3><stem><ext>`, where:
#### D.5.1 Serial Prefix ✅ CONFIRMED 2026-04-22
The first 4 characters of the filename encode the full device serial number:
```
prefix_letter = chr(ord('B') + floor(serial_numeric / 1000))
serial3 = f"{serial_numeric % 1000:03d}" (last 3 digits, zero-padded)
```
Where `serial_numeric` is the integer after the "BE" device-type prefix.
Examples (all confirmed from archive):
| Serial | serial_numeric / 1000 | prefix_letter | serial3 | Filename prefix |
|--------|----------------------|---------------|---------|-----------------|
| BE6907 | 6 | H | 907 | H907 |
| BE7145 | 7 | I | 145 | I145 |
| BE11529 | 11 | M | 529 | M529 |
| BE14036 | 14 | P | 036 | P036 |
| BE17353 | 17 | S | 353 | S353 |
| BE18003 | 18 | T | 003 | T003 |
| BE18191 | 18 | T | 191 | T191 |
| BE18676 | 18 | T | 676 | T676 |
**Interpretation:** The prefix letter encodes the production generation (batch of 1000 units). B=generation 0 (serials 0999), C=generation 1 (10001999), etc. No units with prefix A have been observed — the earliest known units start around serial 2000+ (prefix D).
**Note:** The "BE" device-type prefix is implicit. The filename only encodes the numeric part of the serial. Other Instantel device types (Micromate, Blastmate) may use a different scheme.
#### D.5.2 Stem + Extension — full timestamp encoding ✅ FULLY CONFIRMED 2026-04-22
The stem (4 chars) and AB extension (2 chars) together form a 6-digit base-36 number encoding a complete second-resolution timestamp:
```python
total_seconds = stem_int * 1296 + ab_int
event_local_time = datetime(1985, 1, 1) + timedelta(seconds=total_seconds)
```
- **Epoch:** `1985-01-01 00:00:00` **device local time** ✅ CONFIRMED — verified against 3,248 files from a 10-year production archive; zero errors (only 2 mismatches were Micromate `IDFH`/`IDFW` files which use a completely different naming scheme)
- **Unit:** 1296 seconds = 36² ≈ 21.6 minutes per stem increment
- **Alphabet:** `"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (digits then uppercase letters)
- **Collision:** Events within the same 21.6-minute window share a stem; extension distinguishes them
**Decoding example — `P036L318.C80H` (BE14036, Full Histogram):**
```
stem L318 = 21×36³ + 3×36² + 1×36 + 8 = 983,708
AB C8 = 12×36 + 8 = 440
total_sec = 983,708 × 1296 + 440 = 1,274,886,008
event_time = 1985-01-01 + 1,274,886,008s = 2025-05-26 15:00:08 local
```
**Note on local time:** The device's onboard clock is set to the local timezone of the deployment site. The epoch and all timestamps are in that same local time — there is no UTC conversion. Files moved between timezones will decode to the original deployment timezone.
#### D.5.3 Extension taxonomy
Third character of extension is always `'0'`. File type is identified by extension, not by the type tag in the header (all waveform extensions share type tag `00 12 03 00`).
| Extension | Recording mode | Sample rate | Status |
|---|---|---|---|
| `.N00` | Single Shot (0x00) | 1024 sps | ✅ CONFIRMED |
| `.9T0` | Continuous (0x01) | 1024 sps | ✅ CONFIRMED |
| `.490` | ? | ? | ❓ observed from M529LJ8V.490 |
| `.5K0` | ? | ? | ❓ observed from M529LJDY.5K0 |
| `.980` | ? | ? | ❓ observed from M529LJDY.980 |
| `.ML0` | ? | ? | ❓ observed from M529LJDY.ML0 (167s duration; possibly Histogram) |
**Why 5 extensions for "Continuous"?** Binary analysis of all 6 example files shows that `.9T0`, `.490`, `.5K0`, `.980`, `.ML0` are byte-for-byte identical in all metadata regions (compliance anchor block, channel descriptor blocks `Tran/Vert/Long/MicL`). The A5 frame 7 body reflects the **session-start** compliance config, not the per-event capture config. All 5 files show recording_mode=0x01 and sample_rate=1024 in the body. The extension must therefore encode the **capture-time** compliance state — likely a combination of recording mode, sample rate, and possibly mic units or other options. This cannot be determined from file body alone without capture-time compliance data from the 0C record sub_code and the actual waveform sample count.
**DLE-shift offset note for reading recording_mode from N00/9T0 body:**
The compliance block in the file body has been through `_strip_inner_frame_dles`. The 0x10 constant at logical `anchor7` (between recording_mode and sample_rate_HI) gets stripped when sample_rate_HI = `0x04` (1024 sps), because `0x10` precedes `0x04 ∈ {0x02,0x03,0x04}`. After stripping, the anchor shifts left by 1, so:
| 1024 sps (strip occurs) | 2048 or 4096 sps (no strip) |
|---|---|
| `file[anc7]` = recording_mode | `file[anc8]` = recording_mode |
| `file[anc6:anc4]` = sample_rate | `file[anc6:anc4]` = sample_rate |
For 1024 sps files, the expected file bytes around the anchor are:
```
file[anc9]: mode_prefix (0x00 for Single Shot/Continuous; 0x10 for Histogram)
file[anc8]: 0x00 (was recording_mode, but shifted away — now reads 0x00 for mode_prefix)
file[anc7]: recording_mode (0x00=Single Shot, 0x01=Continuous, etc.)
file[anc6]: 0x04 (sample_rate_HI for 1024 sps)
file[anc5]: 0x00 (sample_rate_LO)
file[anc4]: histogram_interval_HI
file[anc3]: histogram_interval_LO
```
---
*All findings reverse-engineered from live RS-232 bridge captures.*
*Cross-referenced from 2026-03-02 with Instantel MiniMate Plus Operator Manual (716U0101 Rev 15).*
*This is a living document — append changelog entries and timestamps as new findings are confirmed or corrected.*