Compare commits

..

2 Commits

Author SHA1 Message Date
serversdwn
43c9c8b3a3 feat: added raw binary data tracking for accurate format parser. 2026-03-02 15:47:52 -05:00
serversdwn
413fc53a39 chanel config float layout mapped, .set file format analyized.
docs: updated
2026-03-01 16:51:18 -05:00
3 changed files with 484 additions and 189 deletions

View File

@@ -1,29 +1,23 @@
#!/usr/bin/env python3
"""
s3_bridge.py — S3 <-> Blastware serial bridge with frame-aware session logging
Version: v0.4.0
s3_bridge.py — S3 <-> Blastware serial bridge with raw binary capture + DLE-aware text framing
Version: v0.5.0
Key features:
- Low CPU: avoids per-byte console printing
- Forwards bytes immediately (true bridge)
- Frame-aware logging: buffers per direction until ETX (0x03), then logs full frame on one line
- Also logs plain ASCII bursts (e.g., "Operating System") cleanly
- Dual log output: hex text log (.log) AND raw binary log (.bin) written simultaneously
- Interactive annotation: type 'm' + Enter to stamp a [MARK] into both logs mid-capture
- Binary sentinel markers: out-of-band FF FF FF FF <len> <label> in .bin for programmatic correlation
- Auto-marks on session start and end
Whats new vs v0.4.0:
- .bin is now a TRUE raw capture stream with direction + timestamps (record container format).
- .log remains human-friendly and frame-oriented, but frame detection is now DLE-aware:
- frame start = 0x10 0x02 (DLE STX)
- frame end = 0x10 0x03 (DLE ETX)
(No longer splits on bare 0x03.)
- Marks/Info are stored as proper record types in .bin (no unsafe sentinel bytes).
Usage examples:
python s3_bridge.py
python s3_bridge.py --bw COM5 --s3 COM4 --baud 38400
python s3_bridge.py --quiet
Annotation:
While running, type 'm' and press Enter. You will be prompted for a label.
The mark is written to the .log as:
[HH:MM:SS.mmm] >>> MARK: your label here
And to the .bin as an out-of-band sentinel (never valid frame data):
FF FF FF FF <1-byte length> <label bytes>
BIN record format (little-endian):
[type:1][ts_us:8][len:4][payload:len]
Types:
0x01 BW->S3 bytes
0x02 S3->BW bytes
0x03 MARK (utf-8)
0x04 INFO (utf-8)
"""
from __future__ import annotations
@@ -39,38 +33,56 @@ from typing import Optional
import serial
VERSION = "v0.5.0"
VERSION = "v0.4.0"
DLE = 0x10
STX = 0x02
ETX = 0x03
ACK = 0x41
# Sentinel prefix for binary markers. Four 0xFF bytes can never appear in
# valid Instantel DLE-framed data (0xFF is not a legal protocol byte in any
# framing position), so this sequence is unambiguously out-of-band.
BIN_MARK_SENTINEL = b"\xFF\xFF\xFF\xFF"
REC_BW = 0x01
REC_S3 = 0x02
REC_MARK = 0x03
REC_INFO = 0x04
def now_ts() -> str:
# Local time with milliseconds, like [13:37:06.239]
t = _dt.datetime.now()
return t.strftime("%H:%M:%S.") + f"{int(t.microsecond/1000):03d}"
def now_us() -> int:
# Wall-clock microseconds (fine for correlation). If you want monotonic, we can switch.
return int(time.time() * 1_000_000)
def bytes_to_hex(b: bytes) -> str:
return " ".join(f"{x:02X}" for x in b)
def looks_like_text(b: bytes) -> bool:
# Heuristic: mostly printable ASCII plus spaces
if not b:
return False
printable = 0
for x in b:
if x in (9, 10, 13): # \t \n \r
if x in (9, 10, 13):
printable += 1
elif 32 <= x <= 126:
printable += 1
return (printable / len(b)) >= 0.90
def pack_u32_le(n: int) -> bytes:
return bytes((n & 0xFF, (n >> 8) & 0xFF, (n >> 16) & 0xFF, (n >> 24) & 0xFF))
def pack_u64_le(n: int) -> bytes:
out = []
for i in range(8):
out.append((n >> (8 * i)) & 0xFF)
return bytes(out)
class SessionLogger:
def __init__(self, path: str, bin_path: str):
self.path = path
@@ -83,24 +95,24 @@ class SessionLogger:
with self._lock:
self._fh.write(line + "\n")
def log_raw(self, data: bytes) -> None:
"""Write raw bytes directly to the binary log."""
def bin_write_record(self, rec_type: int, payload: bytes, ts_us: Optional[int] = None) -> None:
if ts_us is None:
ts_us = now_us()
header = bytes([rec_type]) + pack_u64_le(ts_us) + pack_u32_le(len(payload))
with self._lock:
self._bin_fh.write(data)
self._bin_fh.write(header)
if payload:
self._bin_fh.write(payload)
def log_mark(self, label: str) -> None:
"""
Write an annotation mark to both logs simultaneously.
.log — visually distinct line: [TS] >>> MARK: label
.bin — out-of-band sentinel: FF FF FF FF <len> <label utf-8, max 255 bytes>
"""
ts = now_ts()
label_bytes = label.encode("utf-8", errors="replace")[:255]
sentinel = BIN_MARK_SENTINEL + bytes([len(label_bytes)]) + label_bytes
with self._lock:
self._fh.write(f"[{ts}] >>> MARK: {label}\n")
self._bin_fh.write(sentinel)
self.log_line(f"[{ts}] >>> MARK: {label}")
self.bin_write_record(REC_MARK, label.encode("utf-8", errors="replace"))
def log_info(self, msg: str) -> None:
ts = now_ts()
self.log_line(f"[{ts}] [INFO] {msg}")
self.bin_write_record(REC_INFO, msg.encode("utf-8", errors="replace"))
def close(self) -> None:
with self._lock:
@@ -112,51 +124,87 @@ class SessionLogger:
self._bin_fh.close()
class FrameAssembler:
class DLEFrameSniffer:
"""
Maintains a rolling buffer of bytes for one direction and emits complete frames.
We treat ETX=0x03 as an end-of-frame marker.
DLE-aware sniffer for logging only.
Extracts:
- ACK bytes (0x41) as single-byte events
- DLE-framed blocks starting at 10 02 and ending at 10 03
- Occasional ASCII bursts (e.g. "Operating System") outside framing
It does NOT modify bytes; it just segments them for the .log.
"""
def __init__(self):
self.buf = bytearray()
def push(self, chunk: bytes) -> list[bytes]:
def push(self, chunk: bytes) -> list[tuple[str, bytes]]:
if chunk:
self.buf.extend(chunk)
frames: list[bytes] = []
events: list[tuple[str, bytes]] = []
# Opportunistically peel off leading ACK(s) when idle-ish.
# We do this only when an ACK is not inside a frame (frames start with DLE).
while self.buf and self.buf[0] == ACK:
events.append(("ACK", bytes([ACK])))
del self.buf[0]
# Try to parse frames: find DLE STX then scan for DLE ETX
while True:
try:
etx_i = self.buf.index(0x03)
except ValueError:
# Find start of frame
start = self._find_dle_stx(self.buf)
if start is None:
# No frame start. Maybe text?
txt = bytes(self.buf)
if looks_like_text(txt):
events.append(("TEXT", txt))
self.buf.clear()
break
# include ETX byte
frame = bytes(self.buf[: etx_i + 1])
del self.buf[: etx_i + 1]
# Emit any leading text before the frame
if start > 0:
leading = bytes(self.buf[:start])
if looks_like_text(leading):
events.append(("TEXT", leading))
else:
# Unknown junk; still preserve in log as RAW so you can see it
events.append(("RAW", leading))
del self.buf[:start]
# ignore empty noise
if frame:
frames.append(frame)
# Now buf starts with DLE STX
end = self._find_dle_etx(self.buf)
if end is None:
break # need more bytes
return frames
frame = bytes(self.buf[:end])
del self.buf[:end]
def drain_as_text_if_any(self) -> Optional[bytes]:
"""
If buffer contains non-framed data (no ETX) and looks like text, emit it.
Useful for things like "Operating System" that come as raw ASCII.
"""
if not self.buf:
events.append(("FRAME", frame))
# peel off any ACKs that may immediately follow
while self.buf and self.buf[0] == ACK:
events.append(("ACK", bytes([ACK])))
del self.buf[0]
return events
@staticmethod
def _find_dle_stx(b: bytearray) -> Optional[int]:
for i in range(len(b) - 1):
if b[i] == DLE and b[i + 1] == STX:
return i
return None
b = bytes(self.buf)
if looks_like_text(b):
self.buf.clear()
return b
@staticmethod
def _find_dle_etx(b: bytearray) -> Optional[int]:
# Find first occurrence of DLE ETX after the initial DLE STX.
# Return index *after* ETX (slice end).
for i in range(2, len(b) - 1):
if b[i] == DLE and b[i + 1] == ETX:
return i + 2
return None
def open_serial(port: str, baud: int) -> serial.Serial:
# timeout keeps read() from blocking forever, enabling clean Ctrl+C shutdown
return serial.Serial(
port=port,
baudrate=baud,
@@ -170,6 +218,7 @@ def open_serial(port: str, baud: int) -> serial.Serial:
def forward_loop(
name: str,
rec_type: int,
src: serial.Serial,
dst: serial.Serial,
logger: SessionLogger,
@@ -177,22 +226,24 @@ def forward_loop(
quiet: bool,
status_every_s: float,
) -> None:
assembler = FrameAssembler()
sniffer = DLEFrameSniffer()
last_status = time.monotonic()
while not stop.is_set():
try:
n = src.in_waiting
if n:
chunk = src.read(n if n < 4096 else 4096)
else:
chunk = src.read(1) # will return b"" after timeout
chunk = src.read(n if n and n < 4096 else (4096 if n else 1))
except serial.SerialException as e:
logger.log_line(f"[{now_ts()}] [ERROR] {name} serial exception: {e!r}")
break
if chunk:
# forward immediately
ts = now_us()
# 1) RAW BIN CAPTURE (absolute truth)
logger.bin_write_record(rec_type, chunk, ts_us=ts)
# 2) Forward immediately (bridge behavior)
try:
dst.write(chunk)
except serial.SerialTimeoutException:
@@ -201,55 +252,42 @@ def forward_loop(
logger.log_line(f"[{now_ts()}] [ERROR] {name} dst write exception: {e!r}")
break
# frame-aware logging
frames = assembler.push(chunk)
for frame in frames:
# Some devices send leading STX separately; we still log as-is.
logger.log_line(f"[{now_ts()}] [{name}] {bytes_to_hex(frame)}")
logger.log_raw(frame)
# If we have non-ETX data that looks like text, flush it as TEXT
text = assembler.drain_as_text_if_any()
if text is not None:
# 3) Human-friendly .log segmentation (DLE-aware)
for kind, payload in sniffer.push(chunk):
if kind == "ACK":
logger.log_line(f"[{now_ts()}] [{name}] [ACK] 41")
elif kind == "FRAME":
logger.log_line(f"[{now_ts()}] [{name}] {bytes_to_hex(payload)}")
elif kind == "TEXT":
try:
s = text.decode("ascii", errors="replace").strip("\r\n")
s = payload.decode("ascii", errors="replace").strip("\r\n")
except Exception:
s = repr(text)
s = repr(payload)
logger.log_line(f"[{now_ts()}] [{name}] [TEXT] {s}")
logger.log_raw(text)
else: # RAW
logger.log_line(f"[{now_ts()}] [{name}] [RAW] {bytes_to_hex(payload)}")
# minimal console heartbeat (cheap)
if not quiet and status_every_s > 0:
now = time.monotonic()
if (now - last_status) >= status_every_s:
print(f"[{now_ts()}] {name} alive")
last_status = now
# tiny sleep only when idle to avoid spin
if not chunk:
time.sleep(0.002)
def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None:
"""
Runs on the main thread (or a dedicated thread) reading stdin.
Type 'm' + Enter to trigger an annotation prompt.
Any other non-empty input is ignored with a hint.
Bare Enter (empty line) is silently ignored to prevent accidental marks.
"""
print("[MARK] Type 'm' + Enter to annotate the capture. Ctrl+C to stop.")
while not stop.is_set():
try:
line = input()
except EOFError:
# stdin closed (e.g. piped input exhausted)
break
except KeyboardInterrupt:
except (EOFError, KeyboardInterrupt):
break
line = line.strip()
if not line:
continue # bare Enter — ignore silently
continue
if line.lower() == "m":
try:
@@ -292,11 +330,12 @@ def main() -> int:
log_path = os.path.join(args.logdir, f"s3_session_{ts}.log")
bin_path = os.path.join(args.logdir, f"s3_session_{ts}.bin")
logger = SessionLogger(log_path, bin_path)
print(f"[LOG] Writing hex log to {log_path}")
print(f"[LOG] Writing binary log to {bin_path}")
logger.log_line(f"[{now_ts()}] [INFO] s3_bridge {VERSION} start")
logger.log_line(f"[{now_ts()}] [INFO] BW={args.bw} S3={args.s3} baud={args.baud}")
logger.log_info(f"s3_bridge {VERSION} start")
logger.log_info(f"BW={args.bw} S3={args.s3} baud={args.baud}")
logger.log_mark(f"SESSION START — BW={args.bw} S3={args.s3} baud={args.baud}")
stop = threading.Event()
@@ -309,16 +348,15 @@ def main() -> int:
t1 = threading.Thread(
target=forward_loop,
name="BW_to_S3",
args=("BW->S3", bw, s3, logger, stop, args.quiet, args.status_every),
args=("BW->S3", REC_BW, bw, s3, logger, stop, args.quiet, args.status_every),
daemon=True,
)
t2 = threading.Thread(
target=forward_loop,
name="S3_to_BW",
args=("S3->BW", s3, bw, logger, stop, args.quiet, args.status_every),
args=("S3->BW", REC_S3, s3, bw, logger, stop, args.quiet, args.status_every),
daemon=True,
)
# Annotation loop runs in its own daemon thread so it doesn't block shutdown
t_ann = threading.Thread(
target=annotation_loop,
name="Annotator",
@@ -335,12 +373,11 @@ def main() -> int:
time.sleep(0.05)
finally:
print("\n[INFO] Ctrl+C detected, shutting down...")
logger.log_line(f"[{now_ts()}] [INFO] shutdown requested")
logger.log_info("shutdown requested")
stop.set()
t1.join(timeout=1.0)
t2.join(timeout=1.0)
# t_ann is daemon — don't join, it may be blocked on input()
try:
bw.close()
@@ -352,8 +389,7 @@ def main() -> int:
pass
logger.log_mark("SESSION END")
logger.log_line(f"[{now_ts()}] [INFO] ports closed, session end")
print("[LOG] Closing session log")
logger.log_info("ports closed, session end")
logger.close()
return 0

View File

@@ -1,7 +1,7 @@
# 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).
> All findings derived from live packet capture. No vendor documentation was used.
> 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.
@@ -11,10 +11,10 @@
| Date | Section | Change |
|---|---|---|
| 2026-02-25 | Initial | Document created from first hex dump analysis |
| 2026-02-25 | §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-25 | §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-25 | §10 DLE Stuffing | **UPGRADED:** Section upgraded from ❓ SPECULATIVE to ✅ CONFIRMED. Full stuffing rules and parser state machine documented. |
| 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. |
@@ -30,7 +30,16 @@
| 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-02-25 | Appendix A | **UPDATED:** v0.4.0 — annotation markers added. `.bin` sentinel format documented. Parser caveat added for SUB `5A` raw ADC payloads. |
| 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 → likely internal ADC full-scale calibration constant or hardware range ceiling. Downgraded to LOW priority. |
| 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. |
---
@@ -332,15 +341,41 @@ Unit 2: serial="BE11529" trail=70 11 firmware=S337.17
### 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 0x00: 00 58 09 — Total index size or record count ❓
Offset 0x03: 00 00 00 01 — Possibly stored event count = 1 ❓
Offset 0x07: 01 07 CB 00 06 1E — Timestamp of event 1 (see §8)
Offset 0x0D: 01 07 CB 00 14 00 — Timestamp of event 2 (see §8)
Offset 0x13: 00 00 00 17 3B — Unknown ❓
Offset 0x50: 10 02 FF DC — Sub-block pointer or data segment header ❓
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.
@@ -383,27 +418,106 @@ Confirmed ASCII strings extracted from payload:
### 7.6 Channel Config Float Layout (SUB E5 / SUB 71)
> ✅ **CONFIRMED — 2026-02-26** from trigger level change capture (session `193237`). Trigger changed `0.500 → 0.200 in/s`, alarm level independently read as `1.0 in/s`.
> ✅ **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 an inline unit string:
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×):
```
[max_range float] [trigger float] ["in.\0"] [alarm float] ["/s\0\0"]
40 C6 97 FD 3E 4C CC CD 69 6E 2E 3F 80 00 00 2F 73 00 00
= 6.206 = 0.200 in/s "in." = 1.000 in/s "/s"
[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"
```
| Float | Value observed | Meaning | Certainty |
| Field | Example bytes | Decoded | Certainty |
|---|---|---|---|
| `40 C6 97 FD` | 6.206 | Maximum range (likely full-scale ADC range in in/s) | 🔶 INFERRED |
| `3E 4C CC CD` | 0.200 | **Geophone trigger level** — changed `0.500 → 0.200` in capture | ✅ CONFIRMED |
| `3F 80 00 00` | 1.000 | **Geophone alarm level** — matched UI value of 1.0 in/s | ✅ CONFIRMED |
| `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED |
| Max range float | `40 C6 97 FD` | 6.206 — full-scale range in in/s | 🔶 INFERRED |
| `[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 |
Unit strings `"in.\0"` and `"/s\0\0"` are embedded inline between the floats, confirming values are stored natively in **imperial units (in/s)** regardless of display locale.
**State transitions observed across captures:**
> ❓ **`0x082A` (= 2090)** — appears in the same block but did not change when trigger or alarm level was adjusted. Previous hypothesis that it was the trigger level is incorrect. Possibly record time, sample count, or a different threshold. Needs a targeted capture changing a known integer field to identify.
| 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.
> ❓ **`0x082A` (= 2090)** — appears in the same block but did not change when trigger or alarm level was adjusted. Possibly record time, sample count, or a different threshold. Needs a targeted capture changing a known integer field to identify.
---
### 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.2061 max range (in/s for geo, psi for mic)
+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.5 Full Waveform Record (SUB F3) — 0xD2 bytes × 2 pages
Peak values as IEEE 754 big-endian floats (restored section header):
> 🔶 **Pending confirmation:** Alarm level identification is based on value match (`3F 80 00 00` = 1.0 = UI value). A capture changing the alarm level will confirm the exact byte offset.
```
Tran: 3D BB 45 7A = 0.0916 (in/s — unit config dependent)
Vert: 3D B9 56 E1 = 0.0907
@@ -632,7 +746,7 @@ Build in this order — each step is independently testable:
| 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 (annotation markers, dual .log/.bin output) |
| Capture Tool | s3_bridge v0.4.0 |
---
@@ -640,13 +754,10 @@ Build in this order — each step is independently testable:
## Appendix A — s3_bridge Capture Format
> ✅ **CONFIRMED — 2026-02-26**
> ⚠️ **Updated for v0.4.0 — annotation markers added.**
> ⚠️ **This behavior is not part of the Instantel protocol. It is an artifact of the bridge logger implementation.**
### A.1 Binary modifications
The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger makes one modification to frame data:
The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger makes one modification:
| Wire sequence | In .bin file | Notes |
|---|---|---|
@@ -660,55 +771,64 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger
> ⚠️ 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.
### A.2 Annotation markers (v0.4.0+)
When the operator types `m` + Enter during a capture, both files receive a marker at that timestamp.
**`.log` format:**
```
[HH:MM:SS.mmm] >>> MARK: label text here
```
The `>>>` prefix never appears in frame log lines (which use `[direction]`) and is trivially skippable by a parser.
**`.bin` format — out-of-band sentinel:**
```
FF FF FF FF <len: 1 byte> <label: len bytes, UTF-8>
```
The four `0xFF` sentinel bytes are chosen because `0xFF` is not a valid byte in any Instantel framing position:
- Not a valid ACK (`0x41`), DLE (`0x10`), STX (`0x02`), or ETX (`0x03`)
- The `0xFF - SUB` response pattern produces values like `0xA4`, `0xEA`, `0xFE` — never a bare `0xFF` in the framing layer
**⚠️ Parser caveat — SUB `5A` raw ADC payloads:**
The sentinel assumption is robust for the framing layer, but the raw ADC sample data in SUB `5A` bulk waveform streams is less constrained. High-amplitude samples could theoretically produce `FF FF FF FF` within the data portion of a frame. **Do not scan the entire `.bin` file as a flat byte stream for sentinels.** Instead:
1. Parse frame boundaries first (walk `0x41` ACK → `0x10 0x02` STX → ... → bare `0x03` ETX)
2. Only scan for `FF FF FF FF` in the **gaps between frames** — sentinels are always written between complete frames, never mid-frame
3. Any `FF FF FF FF` appearing inside a frame boundary is ADC data, not a marker
Session start and end are automatically marked in both files.
---
## 14. Open Questions / Still Needs Cracking
| Question | Priority | Added |
|---|---|---|
| Byte at timestamp offset 3 — hours, minutes, or padding? | MEDIUM | 2026-02-26 |
| `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 | MEDIUM | 2026-02-26 |
| `0x082A` in channel config block — not trigger or alarm level. Possibly record time, sample count, or secondary threshold. Needs targeted capture. | MEDIUM | 2026-02-26 |
| Geophone alarm level float offset confirmation — value match suggests `3F 80 00 00` at known position, needs change capture to confirm. | LOW | 2026-02-26 |
| Max range float `40 C6 97 FD` = 6.206 — meaning unclear. Screenshot shows "Normal 10.000 in/s" range setting. | LOW | 2026-02-26 |
| 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 | LOW | 2026-02-26 |
| MicL channel units — PSI, dB linear, or dB(L)? | LOW | 2026-02-26 |
| Question | Priority | Added | Notes |
|---|---|---|---|
| Byte at timestamp offset 3 — hours, minutes, or padding? | MEDIUM | 2026-02-26 | |
| `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 | MEDIUM | 2026-02-26 | |
| `0x082A` in channel config block — not trigger, alarm, or record time directly. **Hypothesis:** total sample count at 1024 sps: 2 sec record + 0.25 pre-trigger = 2.25 sec × ~930 sps? Or encoded differently. Capture with different record time needed. | MEDIUM | 2026-03-01 | Updated 2026-03-02 |
| 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 | LOW | 2026-02-26 | |
| **Trigger Sample Width** — setting confirmed in manual (default=2 samples, §3.13.1h). Location in protocol not yet mapped. | LOW | 2026-03-02 | NEW |
| **Auto Window** — "1 to 9 seconds" per manual (§3.13.1b). Location in protocol not yet mapped. | LOW | 2026-03-02 | NEW |
| **Auxiliary Trigger** — Enabled/Disabled per manual (§3.13.1d). Location in protocol not yet mapped. | LOW | 2026-03-02 | NEW |
| **Max Geo Range float 6.2061 in/s** — NOT a user-selectable range (manual only shows 1.25 and 10.0 in/s). Likely internal ADC full-scale constant or hardware range ceiling. Not worth capturing. | LOW | 2026-02-26 | Downgraded 2026-03-02 |
| MicL channel units — **RESOLVED: psi**, confirmed from `.set` file unit string `"psi\0"` | RESOLVED | 2026-03-01 | |
| Backlight offset — **RESOLVED: +4B in event index data**, uint8, seconds | RESOLVED | 2026-03-02 | |
| 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 | |
---
*All findings reverse-engineered from live RS-232 bridge captures. No Instantel proprietary documentation was referenced or used.*
---
## 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 | `.set` +16 confirmed | uint32 | 1500 seconds |
| Max Geo Range | §3.8.4 | Channel block, float | float32 BE | 1.25 or 10.0 in/s (user); 6.2061 in protocol = internal constant |
| Microphone Units | §3.9.7 | Inline unit string | char[4] | `"psi\0"`, `"pa.\0"`, `"dB\0\0"` |
| Sample Rate | §3.8.2 | Unknown — needs capture | — | 1024, 2048, 4096 (compliance); up to 65536 (advanced) |
| Record Mode | §3.8.1 | Unknown | — | Single Shot, Continuous, Manual, Histogram, Histogram Combo |
| Trigger Sample Width | §3.13.1h | **NOT YET MAPPED** | uint8? | Default=2 samples |
| Auto Window | §3.13.1b | **NOT YET MAPPED** | uint8? | 19 seconds |
| Auxiliary Trigger | §3.13.1d | **NOT YET MAPPED** | bool | Enabled/Disabled |
| 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 |
---
*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.*

View File

@@ -0,0 +1,139 @@
# Instantel MiniMate Plus — RS-232 Protocol RE
**Session Summary: Chat Compacted 2026-03-01**
Device: MiniMate Plus S/N BE18189
FW S338.17 / DSP 10.72
Capture: 38400 baud, COM4/COM5
---
# Session 1 — Protocol Foundations & Write Command Discovery
**2026-02-27**
## Frame Structure Confirmed
- DLE framing: `ACK (0x41)` + `DLE+STX (0x10 0x02)` … payload … checksum … `DLE+ETX (0x10 0x03)`
- DLE byte stuffing: `0x10` in payload → `0x10 0x10` on wire
- Checksum: 8-bit sum of de-stuffed payload bytes, mod 256
- Payload structure:
`CMD | DLE | ADDR | FLAGS | SUB | OFFSET_HI | OFFSET_LO | data…`
- All BW→S3 requests use `CMD=0x02`
- All responses use CMD matching the DLE prefix
- Response `SUB = 0xFF Request SUB`
## Session Startup Sequence
Device boot prints ASCII **“Operating System”** before binary protocol mode.
Blastware init sequence:
1. POLL (SUB 5B)
2. Channel config (06)
3. Serial (15)
4. Full config (01)
5. Event index (08)
6. Event headers (1E)
7. Waveform records (0C)
8. Bulk stream (5A)
## Write Commands Discovered
| SUB (Req) | SUB (Resp) | Function |
|---|---|---|
| 0x71 | 0x8E | Trigger config write |
| 0x72 | 0x8D | Trigger config page 2 |
| 0x73 | 0x8C | Unknown write |
| 0x74 | 0x8B | Unknown write |
| 0x82 | 0x7D | Unknown write (post config) |
| 0x83 | 0x7C | Unknown write (terminal) |
| 0x68 | 0x97 | Event index write? |
| 0x09 | 0xF6 | Unknown read |
| 0x1A | 0xE5 | Unknown multi-page read |
| 0x2E | 0xD1 | Unknown short read |
---
# Session 2 — Trigger & Alarm Level Floats
**2026-03-01 ~20:51**
## Key Findings
- Trigger & alarm levels are IEEE754 singleprecision **bigendian floats**
- Trigger level change verified (0.5 → 0.2 in/s)
- Alarm level verified (1.0 → 2.0 in/s)
- Unit strings embedded inline (`"psi"`, `"in./s"`)
- `0x082A` ruled out as trigger candidate
## SUB 71 Float Offsets
| Offset | Field | Value | Encoding |
|---|---|---|---|
| d[32..35] | MicL trigger | 0.0450 psi | IEEE754 BE |
| d[38..41] | MicL low thresh | 0.0100 psi | IEEE754 BE |
| d[46..49] | MicL alarm | 0.0210 psi | IEEE754 BE |
| d[42..44] | Units | psi\0 | ASCII |
---
# Session 3 — MultiParameter Capture
**2026-03-01 ~20:53**
| Parameter | Change | Result |
|---|---|---|
| Alarm level | 2.0 in/s | Confirmed |
| Trigger level | 0.6 in/s | Confirmed |
| Record time | 3s | Confirmed |
| Sentinels | FF FF FF FF | Write boundaries confirmed |
---
# Session 4 — .set File Decode
**2026-03-01 ~20:55**
## .set Format
- Binary perchannel structs
- Backlight field at **+0x0C**
- MicL units confirmed as **psi**
- Record time offset confirmed
Unknown uint16 fields:
- +0x0A = 80
- +0x0E = 40
- +0x10 = 21
## Backlight / Power Saving Tests
Changes tested:
- Backlight 15 → 30
- Power save 2 → 5
- Mic dB toggle
Result:
- SUB 71 frames identical
- No new writes after sentinels
- Device confirmed to support settings → offsets unknown
---
# Current State — Pending Capture
Next capture targets:
- Backlight = 250 → search `0xFA`
- Power saving = 10 → search `0x0A`
- Possible encodings:
- uint16 BE
- uint32 BE
- Littleendian variants
---
# Open Questions
| Question | Priority | Status |
|---|---|---|
| Timestamp byte 3 | MEDIUM | Open |
| Serial response trailing bytes | MEDIUM | Open |
| Channel ID mapping | MEDIUM | Open |
| Write config coverage | MEDIUM | Partial |
| Backlight offsets | HIGH | Active |
| MicL units | LOW | Resolved |
| SUB 24/25 vs 5A | LOW | Open |
| 0x07E7 config field | LOW | Open |
---
All findings reverseengineered from RS232 captures. No vendor docs used.