## v0.13.0 — 2026-05-01 ### Fixed - **SUB 5A bulk waveform stream — over-read bug for events ≥ 2 sec.** `read_bulk_waveform_stream` was walking the chunk counter past the actual end of the event, picking up post-event circular-buffer garbage that corrupted reconstructed Blastware files for any waveform > ~1 sec. The loop now extracts the event's `end_offset` from the STRT record at `data[23:27]` of the probe response and stops the chunk walk when the next counter would step past it. Verified against three BW MITM captures (4-27-26 + 5-1-26): 2-sec event drops from 37 over-read chunks to 7 bounded chunks; 3-sec drops to 9; non-zero-start "event 2" drops to 9. ### Added - `framing.bulk_waveform_term_v2(key4, end_offset, last_chunk_counter)` — computes the corrected SUB 5A TERM frame's `(offset_word, params)` per the formula confirmed across all 3 BW captures. Not yet wired into `read_bulk_waveform_stream` (the legacy TERM is still used to preserve the existing `blastware_file.write_blastware_file` frame-structure expectations); available for the next iteration that switches to BW's 0x0200 chunk step. - `framing.parse_strt_end_offset(a5_data)` — extracts the event-end pointer from the STRT record in an A5 response payload.
59 KiB
CLAUDE.md — seismo-relay
Ground-up Python replacement for Blastware, Instantel's Windows-only software for managing MiniMate Plus seismographs. Connects over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55). Current version: v0.13.0.
When new information about the protocol is discovered, please update the instantel_protocol_reference.md with the findings in addition to this document
Project layout
minimateplus/ ← Python client library (primary focus)
transport.py ← SerialTransport, TcpTransport
framing.py ← DLE codec, frame builders, S3FrameParser
protocol.py ← MiniMateProtocol — wire-level read/write methods
client.py ← MiniMateClient — high-level API (connect, get_events, …)
models.py ← DeviceInfo, EventRecord, ComplianceConfig, …
sfm/server.py ← FastAPI REST server exposing device data over HTTP
seismo_lab.py ← Tkinter GUI (Bridge + Analyzer + Console tabs)
docs/
instantel_protocol_reference.md ← reverse-engineered protocol spec ("the Rosetta Stone")
CHANGELOG.md ← version history
Current implementation state (v0.12.3)
Full read pipeline + write pipeline + erase pipeline + monitor log + call home config working end-to-end over TCP/cellular:
| Step | SUB | Status |
|---|---|---|
| POLL / startup handshake | 5B | ✅ |
| Serial number | 15 | ✅ |
| Full config (firmware, calibration date, etc.) | FE | ✅ |
| Compliance config (record time, sample rate, geo thresholds) | 1A | ✅ |
| Event index | 08 | ✅ |
| Event header / first key | 1E | ✅ |
| Waveform header | 0A | ✅ |
| Waveform record (peaks, timestamp, project) | 0C | ✅ |
| Bulk waveform stream (event-time metadata) | 5A | ✅ over-read bug fixed v0.13.0 (chunk loop bounded by STRT end_offset); minor wire diffs vs BW deferred — see "SUB 5A — chunk counter formula" |
| Event advance / next key | 1F | ✅ |
| Write commands (push config to device) | 68–83 | ✅ new v0.8.0 |
| Erase all events | 0xA3 → 0x1C → 0x06 → 0xA2 | ✅ new v0.9.0 |
| Monitor log entries (partial 0x2C records) | 0A browse | ✅ new v0.10.0 |
| Auto Call Home config (read + write) | 2C → 7E → 7F | ✅ new v0.12.3 |
get_events() sequence per event: 1E → 0A → 0C → 5A → 1F
push_config_raw() write sequence: 68→73 | 71×3→72 | 82→83 | 69→74→72
delete_all_events() erase sequence: 0xA3 → 0x1C → 0x06 → 0xA2
Protocol fundamentals
DLE framing
BW→S3 (our requests): [ACK=0x41] [STX=0x02] [stuffed payload+chk] [ETX=0x03]
S3→BW (device replies): [DLE=0x10] [STX=0x02] [stuffed payload+chk] [bare ETX=0x03]
- DLE stuffing rule: any literal
0x10byte in the payload is doubled on the wire (0x10→0x10 0x10). This includes the checksum byte. - Inner-frame terminators: large S3 responses (A4, E5) contain embedded sub-frames
using
DLE+ETXas inner terminators. The outer parser treatsDLE+ETXinside a frame as literal data — the bare ETX is the ONLY real frame terminator. - Response SUB rule:
response_SUB = 0xFF - request_SUB(no known exceptions — earlier note claiming1C→6Ewas WRONG;1C→0xE3confirmed across 338 frames in 4-8-26 captures) - Two-step read pattern: every read command is sent twice — probe step (
offset=0x00, get length) then data step (offset=DATA_LENGTH, get payload). All data lengths are hardcoded constants, not read from the probe response.
De-stuffed payload header
BW→S3 (request):
[0] CMD 0x10
[1] flags 0x00
[2] SUB command byte
[3] 0x00 always zero
[4] 0x00 always zero
[5] OFFSET 0x00 for probe step; DATA_LENGTH for data step
[6-15] params (key, token, etc. — see helpers in framing.py)
S3→BW (response):
[0] CMD 0x00
[1] flags 0x10
[2] SUB response sub byte
[3] PAGE_HI
[4] PAGE_LO
[5+] data
Critical protocol gotchas (hard-won — do not re-derive)
SUB 5A — bulk waveform stream — NON-STANDARD frame format
Always use build_5a_frame() for SUB 5A. Never use build_bw_frame() for SUB 5A.
build_bw_frame produces WRONG output for 5A for two reasons:
-
offset_hi = 0x10must NOT be DLE-stuffed. Blastware sends the offset field raw.build_bw_framewould stuff it to10 10on the wire — the device silently ignores the frame.build_5a_framewrites it as a bare10. -
DLE-aware checksum. When computing the checksum,
10 XXpairs in the stuffed section contribute onlyXXto the running sum; lone bytes contribute normally. This differs from the standard SUM8-of-destuffed-payload that all other commands use.
Both differences confirmed by reproducing Blastware's exact wire bytes from the 1-2-26 BW TX capture. All 10 frames verified.
SUB 5A — chunk counter formula (FINAL CORRECTION 2026-04-26)
Chunk counter = max(key4[2:4], 0x0400) + (chunk_num - 1) * 0x0400 for ALL chunks.
where key4[2:4] = (key4[2] << 8) | key4[3] is the event's circular-buffer base offset.
The max(..., 0x0400) guard is critical for events at the start of the circular buffer
(key4[2:4] == 0x0000, e.g. key 01110000). Without it, chunk 1 gets counter=0x0000, which
is the same address as the probe frame — the device re-returns the STRT record data instead
of waveform payload. With the guard, chunk 1 gets counter=0x0400, which is confirmed correct
from the empirical live-device test 2026-04-06 (counter=0x0400 → responds immediately and streams all frames correctly).
The 4-3-26 capture confirms the pattern for a second event (key 0111245a, key4[2:4]=0x245a):
chunk 1 = 0x245A, chunk 2 = 0x285A, chunk 3 = 0x2C5A (each +0x0400).
max(0x245a, 0x0400) = 0x245a → formula works correctly for non-zero base offset too.
History:
- Original:
_CHUNK1_COUNTER = 0x1004hardcoded (Blastware capture artifact — WRONG). - 2026-04-06: Corrected to
chunk_num * 0x0400(worked for key 01110000 only). - 2026-04-24: Corrected to
key4[2:4] + (chunk_num-1) * 0x0400(fixed non-zero offsets, but accidentally broke key 01110000 — counter=0x0000 sends probe address again). - 2026-04-26: Final formula:
max(key4[2:4], 0x0400) + (chunk_num-1) * 0x0400.
SUB 5A — params are 11 bytes for chunk frames, 10 for termination
bulk_waveform_params() returns 11 bytes (extra trailing 0x00). The 11th byte was
confirmed from the BW wire capture. bulk_waveform_term_params() returns 10 bytes.
Do not swap them.
SUB 5A — event-time metadata lives in A5 frame 7
The bulk stream sends 9+ A5 response frames. Frame 7 (0-indexed) contains the compliance setup as it existed when the event was recorded:
"Project:" → project description
"Client:" → client name ← NOT in the 0C record
"User Name:" → operator name ← NOT in the 0C record
"Seis Loc:" → sensor location ← NOT in the 0C record
"Extended Notes"→ notes
IMPORTANT — 5A "Project:" is session-start config, NOT per-event (confirmed 2026-04-05):
The "Project:" string in the A5 frame 7 payload reflects the compliance setup from when
the monitoring session first started, not the individual event's project name. The per-
event project name is correctly stored in the 210-byte 0C waveform record and must be
used as the authoritative source. _decode_a5_metadata_into therefore only sets
project from 5A when 0C didn't already supply one.
"Client:", "User Name:", "Seis Loc:", and "Extended Notes" are NOT present in the 0C record — 5A remains the sole source for those fields and they are set unconditionally.
stop_after_metadata=True (default) stops the 5A loop as soon as b"Project:" appears,
then sends the termination frame.
SUB 5A — end-of-stream signal (confirmed 2026-04-06)
After streaming all waveform chunks, the device sends exactly 1 raw byte in response to
the next chunk request, then goes silent. This is the natural end-of-stream indicator — NOT
a complete A5 frame. S3FrameParser.bytes_fed will be 1; no frame is assembled.
Handling: on TimeoutError, if bytes_fed > 0 AND frames were already collected, treat as
graceful end-of-stream, break the loop, and proceed to the termination frame. If bytes_fed == 0 with no prior frames, it is a genuine transport failure — re-raise.
Chunk recv timeout must be 10 s, not the default 120 s. Chunks arrive within ~1 s each.
Using 120 s causes a ~2-minute stall at every end-of-stream detection. The _recv_one call
in the chunk loop passes timeout=10.0 explicitly.
Typical chunk count (BE11529, 1024 sps): A 9,306-sample event produces 35 chunks before end-of-stream. Chunks with uniform 1,036-byte data are all-zero ADC samples (post-event silence). Only the initial variable-size chunks contain actual signal.
SUB 5A — fi==9 hardcoded skip (FIXED 2026-04-06)
_decode_a5_waveform() previously had elif fi == 9: continue — a leftover from the
9-frame original blast capture where frame 9 was assumed to be a terminator. For current
35-frame streams, fi==9 is live waveform data (~133 sample-sets were being dropped).
Removed. Terminator detection is via page_key == 0x0000 in read_bulk_waveform_stream,
not frame index.
SUB 1E / 1F — event iteration null sentinel and token position (FIXED, do not re-introduce)
token_params bug (FIXED): The token byte was at params[6] (wrong). Both 3-31-26 and
4-3-26 BW TX captures confirm it belongs at params[7] (raw: 00 00 00 00 00 00 00 fe 00 00).
With the wrong position the device ignores the token and 1F returns null immediately.
1F token depends on context: In browse mode (no 5A), use all-zero params (browse=True).
In download mode (get_events with 5A), use token=0xFE (browse=False) — this is required to
arm the device's 5A bulk stream state machine. The earlier "empirical" test showing token=0xFE
returns null was done WITHOUT the 1E(arm) step; that test is invalid. BW always uses 1F(0xFE)
in download mode. count_events uses browse=True (no 5A needed).
0A context requirement: advance_event() (1F) only returns a valid next-event key
when a preceding read_waveform_header() (0A) call has established device waveform
context for the current key. Call 0A before every event in the loop, not just the first.
Calling 1F cold (after only 1E, with no 0A) returns the null sentinel regardless of how
many events are stored.
1F response layout: The next event's key IS at data_rsp.data[11:15] (= payload[16:20]).
Confirmed from 4-3-26 browse-mode S3 captures:
1F after 0A(key0=01110000): data[11:15]=0111245a data[15:19]=00001e36 ← valid
1F after 0A(key1=0111245a): data[11:15]=01114290 data[15:19]=00000046 ← valid
1F null sentinel: data[11:15]=00000000 data[15:19]=00000000 ← done
Null sentinel: data8[4:8] == b"\x00\x00\x00\x00" (= data_rsp.data[15:19])
works for BOTH 1E trailing (offset to next event key) and 1F response (null key
echo) — in both cases, all zeros means "no more events."
1E response layout: data_rsp.data[11:15] = event 0's actual key; data_rsp.data[15:19]
= sample-count offset to the next event key (key1 = key0 + this offset). If offset == 0,
there is only one event.
Correct iteration pattern (confirmed empirically with live device, 2+ events):
count_events (browse mode only, no 5A):
1E(all zeros) → key0, trailing0 ← trailing0 non-zero if event 1 exists
0A(key0) ← REQUIRED: establishes device context
1F(all zeros / browse=True) → key1 ← use all-zero params
0A(key1) ← REQUIRED before each advance
1F(all zeros) → null ← done
get_events (download mode, with 5A):
1E(all zeros) → key0, trailing0 ← trailing0 non-zero if event 1 exists
0A(key0) ← REQUIRED: establishes device context
1E(token=0xFE) ← REQUIRED: arms device for 5A; CONFIRMED 4-2-26 + 4-3-26
0C(key0) ← read waveform record
1F(token=0xFE) → [discard key] ← REQUIRED: arms 5A bulk stream state machine
POLL × 3 ← REQUIRED: 3 full POLL cycles before 5A (BW frames 68-73)
5A(key0) ← bulk stream; key0 used even though 1F already advanced
1F(all zeros / browse=True) → key1 ← USE THIS for loop iteration (browse=True returns correct key)
0A(key1)
1E(token=0xFE) ← re-arm for next event's 5A
0C(key1)
1F(token=0xFE) → [discard key] ← arm 5A
POLL × 3
5A(key1)
1F(browse=True) → null ← done
IMPORTANT — conditional browse 1F (UPDATED 2026-04-06):
1F(token=0xFE) (browse=False) BEFORE POLL+5A arms the device's bulk stream state machine.
Its returned key is cached as arm_key4 in get_events().
1F(browse=True) AFTER 5A is ONLY sent when 5A succeeded. If 5A timed out or failed,
sending browse 1F disrupts the device's internal state — subsequent 5A probes for the next
event get no response (confirmed empirically: calling browse 1F after a failed 5A causes the
next event's 5A probe to also time out with 0 bytes received).
In the failure path, arm_key4 from 1F(download) is used as a best-effort next-key hint:
- If
arm_key4 != cur_key: use it to advance the loop without any 1F call - If
arm_key4 == cur_key(device stuck, typical for second+ events when 5A fails): abort
The diagnostic bytes_fed counter on S3FrameParser (incremented in every feed() call,
reset by reset()) makes it possible to distinguish "no bytes at all" from "bytes received
but no complete frame assembled" in 5A probe timeouts — both show up as 120s timeouts in
the log but have very different root causes.
The 1E(token=0xFE) arm step is required (FIXED 2026-04-06): The device silently ignores all 5A probe frames unless a second SUB 1E with token=0xFE has been issued between 0A and 0C. This step is present in EVERY download cycle in both the 4-2-26 and 4-3-26 BW TX captures.
1F must come BEFORE 5A (FIXED 2026-04-06): BW always calls 1F (advance event) before starting the 5A bulk stream. 5A still uses the pre-advance key — the device streams the waveform for the key that was set up with 0A+1E-arm+0C even after 1F has moved the internal pointer to the next event.
POLL × 3 required before 5A (FIXED 2026-04-06):
BW sends exactly 3 complete POLL (SUB 5B) probe+data cycles between the last 1F and the
first 5A probe frame. Confirmed from 4-2-26 BW TX capture frames 68-73. Without these
POLLs the device does not respond to the 5A probe. Use proto.poll() (not startup() —
startup() drains the boot string, which is only needed on initial connect).
advance_event(browse=True) sends all-zero params; advance_event() default (browse=False)
sends token=0xFE and is NOT used by any caller.
advance_event() returns (key4, event_data8).
Callers (count_events, get_events) loop while data8[4:8] != b"\x00\x00\x00\x00".
SUB 1A — compliance config — orphaned send bug (FIXED, do not re-introduce)
read_compliance_config() sends a 4-frame sequence (A, B, C, D) where:
- Frame A is a probe (no
recv_oneneeded — device ACKs but returns no data page) - Frames B, C, D each need a
recv_oneto collect the response
There must be NO extra self._send(...) call before the B/C/D recv loop without a
matching recv_one(). An orphaned send shifts all receives one step behind, leaving
frame D's channel block (trigger_level_geo, alarm_level_geo, max_range_geo) unread and
producing only ~1071 bytes instead of ~2126.
SUB 1A — anchor search range
_decode_compliance_config_into() locates fields via the 6-byte stable anchor
b'\xbe\x80\x00\x00\x00\x00'. Search range is cfg[0:150].
IMPORTANT — the "10-byte anchor" \x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00 is NOT fully constant.
The first 2 bytes (\x01\x2c = 300) are the histogram_interval_sec field (uint16 BE, seconds) —
the value 300 is just the 5-minute default. When histogram interval is set to a different value
(e.g. 15min = 0x0384 = \x03\x84), those bytes change. Only the 6-byte suffix
\xbe\x80\x00\x00\x00\x00 is truly constant. The code already uses the 6-byte anchor.
Do not narrow the search range to cfg[40:100] — the old range was only accidentally correct because
the orphaned-send bug was prepending a 44-byte spurious header, pushing the anchor from
its real position (cfg[11]) into the 40–100 window.
Sample rate and DLE jitter in cfg data
Sample rate 4096 (0x1000) causes DLE jitter: the frame carries 10 10 00 on the wire,
which unstuffs to 10 00 — 2 bytes instead of 3. This makes frame C 1 byte shorter and
shifts all subsequent absolute offsets by −1. The anchor approach is immune to this.
Do NOT use fixed absolute offsets for sample_rate or record_time.
TCP / cellular transport
- Protocol bytes over TCP are bit-for-bit identical to RS-232. No wrapping.
- The modem (RV50/RV55) forwards bytes with up to ~1s buffering.
TcpTransportusesread_until_idle(idle_gap=1.5s)to drain the buffer completely before parsing. - Cold-boot: unit sends the 16-byte ASCII string
"Operating System"before entering DLE-framed mode. The parser discards it (scans for DLE+STX). - RV50/RV55 sends
\r\nRING\r\n\r\nCONNECT\r\nover TCP to the caller even with Quiet Mode enabled. Parser handles this — do not strip it manually before feeding toS3FrameParser.
Required ACEmanager settings (Sierra Wireless RV50/RV55)
| Setting | Value | Why |
|---|---|---|
| Configure Serial Port | 38400,8N1 |
Must match MiniMate baud |
| Flow Control | None |
Hardware FC blocks TX if pins unconnected |
| Quiet Mode | Enable | Critical. Disabled injects RING/CONNECT onto serial, corrupting S3 handshake |
| Data Forwarding Timeout | 1 (= 0.1 s) |
Lower latency |
| TCP Connect Response Delay | 0 |
Non-zero silently drops first POLL frame |
| TCP Idle Timeout | 2 (minutes) |
Prevents premature disconnect |
| DB9 Serial Echo | Disable |
Echo corrupts the data stream |
Key confirmed field locations
SUB FE — Full Config (166 destuffed bytes)
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0x34 | firmware version string | ASCII | e.g. "S338.17" |
| 0x56–0x57 | calibration year | uint16 BE | 0x07E9 = 2025 |
| 0x0109 | aux trigger enabled | uint8 | 0x00 = off, 0x01 = on |
SUB 1A — Compliance Config (~2126 bytes total after 4-frame sequence)
| Field | How to find it |
|---|---|
| recording_mode | uint8 at anchor − 3 (write) / anchor − 4 (read) ✅ confirmed 2026-04-20 |
| sample_rate | uint16 BE at anchor − 2 |
| histogram_interval_sec | uint16 BE at anchor − 4 (seconds); same offset in read & write ✅ confirmed 2026-04-20 |
| record_time | float32 BE at anchor + 10 |
| trigger_level_geo | float32 BE, located in channel block |
| alarm_level_geo | float32 BE, adjacent to trigger_level_geo |
| geo_hardware_constant (adc_scale_factor) | float32 BE at channel_label+28 in both read (E5) and write (SUB 71) payloads — reads 6.206053 on BOTH tested units (BE11529 and BE18189); identical across all geo channels (Tran/Vert/Long) and all captures. Confirmed 2026-04-17 from Interface Handbook §4.5: this is the ADC-to-velocity scale factor = 1/sensitivity = (in/s per V). Firmware uses it as: PPV (in/s) = ADC_voltage × 6.206053. Cross-check: 1.61133 V (ADC full-scale) × 6.206053 = 10.000 in/s (Normal range ✅). Do NOT write this field — it is a hardware/firmware constant. |
| geo_range (sensitivity selector) | uint8 at channel_label+33 in both read (E5) and write (SUB 71) payloads — CONFIRMED 2026-04-20 from 4-20-26 geo sensitivity captures: 0x00 = Normal 10.000 in/s (standard gain), 0x01 = Sensitive 1.250 in/s (high gain). Present in all three geo channel blocks (Tran, Vert, Long). NOTE: channel_label+20 reads 0x01 on ALL captures regardless of range setting — it is NOT this field. Note: the "SUB 71 write offset = +29" that appears in earlier analysis was an artifact of incorrect BW-style destuffing applied to write frame data — write frame data is RAW, so the literal 0x10 bytes in the channel block header are preserved, and the offset is the same as in the E5 read payload. |
| setup_name | ASCII, null-padded, in cfg body |
| project / client / operator / sensor_location | ASCII, label-value pairs |
True stable anchor: b'\xbe\x80\x00\x00\x00\x00' (6-byte suffix), search cfg[0:150].
The old "10-byte anchor" b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00' is partially variable:
bytes \x01\x2c = 300 (5-minute default histogram interval); changes when interval changes.
Field layout relative to the 6-byte anchor (write payload / E5 read — noted where different):
| Offset | Field | Format | Notes |
|---|---|---|---|
| anchor − 9 | mode_prefix | uint8 | 0x00 for Single Shot / Continuous; 0x10 for Histogram (DLE prefix in E5 encoding) and Histogram+Continuous (actual config byte). See "compliance_raw DLE encoding" note below. |
| anchor − 8 | recording_mode | uint8 | Same offset for both read and write — confirmed 2026-04-21. _encode_compliance_config writes buf[anc-8]. NOTE: for Histogram (0x03), E5 encodes the value as 0x10 0x03 so compliance_raw[anc-9]=0x10, compliance_raw[anc-8]=0x03. |
| anchor − 7 | constant | 0x10 |
Always 0x10 in both E5 read and BW write payloads (not a DLE marker — it is part of the sample_rate field area). Do NOT overwrite. |
| anchor − 6 | sample_rate | uint16 BE | same in read & write |
| anchor − 4 | histogram_interval_sec | uint16 BE | seconds; same in read & write ✅ 2026-04-20 |
| anchor − 2 | 0x00 0x00 |
padding | |
| anchor | \xbe\x80\x00\x00\x00\x00 |
anchor | |
| anchor + 6 | record_time | float32 BE | same in read & write |
recording_mode enum (confirmed 2026-04-20 from 4-20-26 captures):
| Value | Mode | anchor-9 in compliance_raw |
|---|---|---|
0x00 |
Single Shot | 0x00 |
0x01 |
Continuous | 0x00 |
0x02 |
❓ not observed | ❓ |
0x03 |
Histogram | 0x10 (DLE prefix from E5 wire encoding of 0x03) |
0x04 |
Histogram + Continuous | 0x10 (actual config byte for this mode) |
compliance_raw DLE encoding — IMPORTANT (confirmed 2026-04-21 from 4-20-26 captures):
compliance_raw (returned by read_compliance_config()) is NOT purely logical bytes — it is
the wire-encoded representation where 0x03 bytes in the config are preceded by a 0x10 DLE
prefix (because S3FrameParser preserves DLE+ETX inner-frame pairs as two literal bytes).
Consequences:
- When recording_mode =
0x03(Histogram),compliance_raw[anc-9] = 0x10(DLE prefix) andcompliance_raw[anc-8] = 0x03(the value). The anchor position is +1 compared to modes without0x03bytes before the anchor. - For Histogram+Continuous (
0x04),compliance_raw[anc-9] = 0x10for a different reason: it is an actual stored config byte, not a DLE prefix. - The anchor search (
buf.find(b'\xbe\x80\x00\x00\x00\x00', 0, 150)) correctly locates the anchor regardless of these mode-dependent shifts. - When SFM writes recording_mode and round-trips the rest verbatim, the byte at
anc-9is preserved from the previous read. This means transitioning Histogram→other modes via SFM leaves a0x10atanc-9. The device stores it as a literal byte; it does not affect recording mode operation (which is atanc-8), but differs from what BW writes. This is a known minor discrepancy that does not impact device behavior. - Histogram recording mode (0x03) write via SFM: untested. When starting from a mode with
anc-9 = 0x00, SFM writes bare0x03at anc-8. BW would write0x10 0x03. Device likely accepts both (write frames probably use offset/length for framing, not ETX scanning).
DLE escaping in write frames — confirmed 2026-04-20: Blastware escapes 0x03 bytes in
write frame data as 0x10 0x03 on the wire (defensive ETX escaping). Our build_bw_write_frame
does NOT do this escaping — it sends data bytes raw. Device acceptance of bare 0x03 bytes
in write frame data is confirmed for the tested modes (Single Shot, Continuous, Histogram+Continuous
where 0x10 0x03 already appears from round-tripping). Histogram mode (bare 0x03 write from
non-Histogram starting state) has not been directly tested.
SUB 0C — Waveform Record (210 bytes = data[11:11+0xD2])
sub_code=0x10 (Waveform single-shot) — 9-byte timestamp header:
| Offset | Field | Type |
|---|---|---|
| 0 | day | uint8 |
| 1 | sub_code | uint8 (0x10) |
| 2 | month | uint8 |
| 3–4 | year | uint16 BE |
| 5 | unknown | uint8 (always 0) |
| 6 | hour | uint8 |
| 7 | minute | uint8 |
| 8 | second | uint8 |
sub_code=0x03 (Waveform continuous) — 10-byte timestamp header (1-byte shift):
Confirmed 2026-04-03 against Blastware event report (15:20:17 Apr 3 2026).
Raw wire bytes: 10 03 10 04 07 ea 00 0f 14 11
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0 | unknown_a | uint8 | 0x10 observed |
| 1 | day | uint8 | doubles as sub_code position in 0x10 layout |
| 2 | unknown_b | uint8 | 0x10 observed |
| 3 | month | uint8 | |
| 4–5 | year | uint16 BE | |
| 6 | unknown | uint8 | |
| 7 | hour | uint8 | |
| 8 | minute | uint8 | |
| 9 | second | uint8 |
Peak values (both record types):
| Location | Field | Type |
|---|---|---|
tran_pos - 12 |
peak_vector_sum | float32 BE — label-relative, NOT fixed offset |
label + 6 |
PPV per channel | float32 BE (search for "Tran", "Vert", "Long", "MicL") |
PPV labels are NOT 4-byte aligned. The label-relative approach is the only reliable method.
peak_vector_sum is exactly 12 bytes before the "Tran" label — confirmed for both
sub_code=0x10 and sub_code=0x03. Do NOT use fixed offset 87 (only incidentally correct
for 0x10 records).
SFM REST API (sfm/server.py)
Live device endpoints (connect to device per-request)
GET /device/info?port=COM5 ← serial
GET /device/info?host=1.2.3.4&tcp_port=9034 ← cellular
GET /device/events?host=1.2.3.4&tcp_port=9034&baud=38400
GET /device/event?host=1.2.3.4&tcp_port=9034&index=0
GET /device/monitor/status?host=1.2.3.4&tcp_port=9034 ← battery, memory, mode
POST /device/monitor/start?host=1.2.3.4&tcp_port=9034 ← start recording
POST /device/monitor/stop?host=1.2.3.4&tcp_port=9034 ← stop recording
Server retries once on ProtocolError for TCP connections (handles cold-boot timing).
DB read endpoints (query seismo_relay.db written by ach_server.py)
GET /db/units ← all known serials + summary stats
GET /db/events?serial=BE11529&from_dt=&to_dt=&limit= ← triggered events, newest first
GET /db/monitor_log?serial=BE11529&from_dt=&to_dt= ← monitoring intervals, newest first
GET /db/sessions?serial=BE11529&limit=50 ← ACH call-home sessions, newest first
PATCH /db/events/{id}/false_trigger?value=true ← flag/unflag false triggers
DB file: bridges/captures/seismo_relay.db (default; override with --db-path at startup).
All DB endpoints are read-only except PATCH /db/events/{id}/false_trigger.
Key wire captures (reference material)
| Capture | Location | Contents |
|---|---|---|
| 1-2-26 | bridges/captures/1-2-26/ |
SUB 5A BW TX frames — confirmed 5A frame format, 11-byte params, DLE-aware checksum |
| 3-11-26 | bridges/captures/3-11-26/ |
Full compliance setup write, Aux Trigger capture |
| 3-31-26 | bridges/captures/3-31-26/ |
Complete event download cycle (148 BW / 147 S3 frames) — confirmed 1E/0A/0C/1F sequence; only 1 event stored so token=0xFE appeared to work |
| 4-3-26 | bridges/captures/4-3-26/ |
Browse-mode S3 capture with 2+ events — confirmed all-zero params for 1F, 1F response layout, null sentinel, 0A context requirement |
Write commands (SUBs 68–83) — confirmed 2026-04-07
All confirmed from 3-11-26 BW TX capture (raw_bw_20260311_170151.bin, frames 102–112).
Write frame format — CRITICAL: minimal DLE stuffing
Write frames do NOT use the same DLE stuffing as read frames. Only the BW_CMD byte (0x10 at payload position [0]) is doubled on the wire. All other bytes — flags, sub, offset, params, data, and checksum — are written RAW without stuffing.
Confirmed from all 11 write frames in the 3-11-26/170151 BW capture. ✅ 2026-04-07
Do NOT use dle_stuff() or build_bw_frame() for write commands. Use build_bw_write_frame().
Actual wire layout:
[41] ACK
[02] STX
[10 10] BW_CMD doubled (ONLY DLE stuffing applied)
[00] flags
[sub] write command byte (0x68–0x83)
[00] always zero
[hi][lo] offset uint16 BE — RAW (not stuffed even if hi=0x10)
[params] 10 bytes — RAW
[data] variable-length write payload — RAW (0x10 bytes not stuffed)
[chk] checksum — RAW (not stuffed even if 0x10)
[03] ETX
Total wire length = 2 (ACK+STX) + 2 (doubled BW_CMD) + 15 (raw header) + len(data) + 1 (chk) + 1 (ETX)
= 21 + len(data)
De-stuffed payload (logical; used for checksum computation only):
[0] BW_CMD 0x10
[1] flags 0x00
[2] SUB write command byte (0x68–0x83)
[3] 0x00 always zero
[4] offset_hi
[5] offset_lo
[6:16] params 10-byte field (see per-SUB notes below)
[16:] data write payload (variable length; absent for confirm frames)
[-1] chk large-frame DLE-aware checksum (see below)
Write SUBs = Read SUB + 0x60. Response SUB follows the standard 0xFF − Request SUB rule.
Write frame checksum
All write frames (data frames AND confirm frames) use the large-frame DLE-aware checksum:
chk = (sum(b for b in payload[2:] if b != 0x10) + 0x10) & 0xFF
This is identical to the SUB 5A DLE-aware checksum. Confirmed against all 11 write frames in the 3-11-26/170151 capture. ✅ 2026-04-07
Note: confirm frames contain no embedded 0x10 bytes, so both the standard SUM8 and the
DLE-aware formula produce the same result for them — but build_bw_write_frame always uses
the DLE-aware formula for consistency.
Write ack responses
All device acks for write commands are 17-byte zero-data S3 frames:
[DLE=0x10][STX=0x02][stuffed(header + chk)][bare ETX=0x03]
The data section carries zeros; RSP_SUB = 0xFF − write_request_SUB.
Write SUB constants and sequences
| Request SUB | Function | Offset | Response SUB |
|---|---|---|---|
| 0x68 | Event index write | data[1] + 2 |
0x97 |
| 0x73 | Confirm B (follows 68) | 0 | 0x8C |
| 0x71 | Compliance write (×3 chunks) | see below | 0x8E |
| 0x72 | Confirm A (follows 71×3, 69) | 0 | 0x8D |
| 0x82 | Trigger config write | data[1] + 2 |
0x7D |
| 0x83 | Trigger confirm (follows 82) | 0 | 0x7C |
| 0x69 | Waveform data write | data[1] + 2 |
0x96 |
| 0x74 | Confirm C (follows 69) | 0 | 0x8B |
Offset formula for single-chunk writes (0x68, 0x69, 0x82): offset = data[1] + 2
The write payload always begins with a 2-byte header [0x00][length], where data[1] is
an embedded length field. The offset encodes this inner length + 2 (accounting for the
header bytes). Confirmed from all three single-chunk write frames in the 3-11-26 capture:
| SUB | data[0:4] (hex) | data[1] | offset | total data len |
|---|---|---|---|---|
| 0x68 | 00 58 09 00 |
0x58=88 | 0x5A=90 | 91 |
| 0x82 | 00 1A D5 00 |
0x1A=26 | 0x1C=28 | 29 |
| 0x69 | 00 C8 08 00 |
0xC8=200 | 0xCA=202 | 204 |
Full sequence: 68→73 | 71×3→72 | 82→83 | 69→74→72
SUB 71 — compliance write chunk parameters
The full compliance config payload (~2128 bytes) is split into exactly 3 chunks. Confirmed from 3-11-26 BW TX capture frames 104–108:
| Chunk | Size | offset |
params (10 bytes hex) |
|---|---|---|---|
| 1 (first) | 1027 bytes | 0x1004 | 00 00 00 00 00 00 00 00 00 00 |
| 2 (middle) | 1055 bytes | 0x1004 | 00 00 00 10 04 00 00 00 00 00 |
| 3 (last) | remainder | 0x002C | 00 00 08 00 00 00 00 00 00 00 |
Total: 1027 + 1055 + N = 2082 + N bytes (N ≈ 46 for a standard 2128-byte config).
After all 3 chunks are acked (SUB 0x8E each), send SUB 72 confirm → device acks 0x8D.
build_bw_write_frame() — framing.py
build_bw_write_frame(sub, data, *, offset=0, params=bytes(10)) -> bytes
Use for all write commands (SUBs 68–83) including confirm frames (data=b"").
Do NOT use build_bw_frame for write commands — it uses standard SUM8, not the
large-frame DLE-aware checksum required for writes.
push_config_raw() — client.py
client.push_config_raw(event_index_data, compliance_data, trigger_data, waveform_data)
Orchestrates the full write sequence in the confirmed order. All payloads are raw bytes
(no encoding performed at this level). A higher-level encoder that builds payloads from
a ComplianceConfig object is a future task.
Monitoring commands (SUBs 0x1C, 0x96, 0x97) — confirmed 2026-04-08
All confirmed from 4-8-26/2ndtry BW TX/S3 capture (clean start → 30s monitor → stop cycle).
SUB 0x1C — Monitor status read
Standard two-step read (probe at offset 0x00, data at offset 0x2C). Response SUB = 0xFF − 0x1C = 0xE3 (standard formula — no exception).
Payload length is 46–47 bytes IDLE, 48–49 bytes MONITORING — not a reliable sole indicator due to 1-byte jitter overlap at the boundary.
Monitoring flag (CONFIRMED 2026-04-09 — byte diff of all 144 data frames, 2ndtry capture):
section[1] == 0x00→ unit is idlesection[1] == 0x10→ unit is monitoring
This is data[12] (= frame.data[12]). The flag is 0x00 in all 36 IDLE_BEFORE frames,
0x10 in all 98 MONITORING frames, and 0x00 in all 10 IDLE_AFTER frames — 100% accurate.
HISTORY OF THIS FIELD (do not re-derive): The original implementation used section[1].
A re-analysis in the prior session incorrectly concluded section[1] is always 0x00 and
"corrected" the flag to section[6], which has non-binary values (0xea idle, 0x07 monitoring)
and is device-specific. The 2026-04-09 re-analysis confirms section[1] was right.
IMPORTANT — frame.data has checksum already stripped by S3FrameParser._finalise()
(raw_payload = body[:-1]; data = raw_payload[5:]). There is NO trailing checksum byte in
section. All relative-from-end offsets must account for this.
Battery and memory fields are present in both states:
| Offset (relative to end) | Field | Type | Notes |
|---|---|---|---|
section[-10:-8] |
battery voltage × 100 | uint16 BE | 0x02A8 = 680 → 6.80 V |
section[-8:-4] |
memory total (bytes) | uint32 BE | e.g. 983026 ≈ 960 KB |
section[-4:] |
memory free (bytes) | uint32 BE | decreases as events are stored |
SESSION_RESET signal (41 03) — required for monitoring units
Confirmed from 4-8-26 BW TX captures: Blastware sends a 2-byte 41 03 (ACK + ETX,
no STX) immediately before the first POLL probe AND between the probe and data frames.
This signal is required to wake units that are actively monitoring — without it
the unit does not respond to POLL over TCP. Harmless for idle units.
SESSION_RESET = bytes([0x41, 0x03]) is defined in framing.py and sent by
protocol.startup() before and between POLL frames.
SUB 0x96 — Start monitoring
Single write frame, no data payload (empty body). Response SUB = 0xFF − 0x96 = 0x69.
Wire bytes (confirmed frame 92 of 2ndtry BW capture):
41 02 10 10 00 96 00 00 00 00 00 00 00 00 00 00 00 00 00 a6 03
SUB 0x97 — Stop monitoring
Single write frame, no data payload (empty body). Response SUB = 0xFF − 0x97 = 0x68.
Wire bytes (confirmed frame 305 of 2ndtry BW capture):
41 02 10 10 00 97 00 00 00 00 00 00 00 00 00 00 00 00 00 a7 03
Both start and stop acks are standard 17-byte zero-data S3 frames.
On-device sensor check behavior (confirmed 2026-04-08)
Confirmed from 4-8-26/sensor-check BW+S3 capture (Blastware "Unit Channel Test" comms check issued while unit was performing its on-device sensor check).
Unit IS reachable during on-device sensor check — POLL (SUB 5B) responded normally throughout. However, the unit partially handled channel-test commands (SUB 0x0E) for channels 0–4 and then went silent for ~40 seconds while the sensor check ran, before resuming responses for channels 5–7 and the trigger test (SUB 0x98).
Key findings:
- On-device sensor check duration: approximately 40 seconds (log gap
18:40:48→18:41:28) - Unit IS reachable for POLL during the check window — SESSION_RESET + POLL works
- Partial command responses during check are possible (device may buffer some, drop others)
- The Blastware "Unit Channel Test" (remote comms check, SUBs 0x0E + 0x98) is a SEPARATE operation from the on-device check — it is a passive remote read; the unit's screen does not change during a remote comms check
SFM behavior after POST /device/monitor/start: _pollMonitorConfirm() polls
/device/monitor/status every 5 s for up to 60 s, updating the badge on each poll.
Status will show MONITORING once section[1] flips to 0x10.
SUBs known from sensor-check capture (4-8-26) — NOT YET IMPLEMENTED
| BW SUB | RSP SUB | Function | Notes |
|---|---|---|---|
| 0x15 | 0xEA | Serial number / short ID | 2-step read; data offset = 0x0A (10 bytes); confirmed serial "BE11529" at data[11+5:] |
| 0x01 | 0xFE | Device info block | 2-step read; data offset = 0x98 (152 bytes); payload includes serial + firmware + float config fields |
| 0x0E | 0xF1 | Channel sensor data | 2-step read; channel selector in params[6:8] (0x0000–0x0007); data length 0x0A per channel; used by Blastware "Unit Channel Test" — see docs/ for details |
| 0x98 | 0x67 | Trigger test | Single probe frame (params[0]=0xFF); sent twice per test cycle; all-zero data response; used after 0x0E channel scan |
Blastware's "Unit Channel Test" sequence: POLL×N → 0x15 → 0x01 → 0x08 → 0x01 → 0x0E×8 → 0x98×2 → 0x0E×8 (repeat pass with live ADC readings).
Compliance config field inventory (from Blastware UI, 2026-04-08)
Fields visible in the Blastware Compliance Setup dialog — most are NOT YET decoded to byte
offsets in the raw 1A/E5 payload. Only fields with ✅ have confirmed offsets in the code.
Recording Setup tab:
- Recording Mode: Continuous / Single Shot / Histogram / Histogram+Continuous ✅ (uint8 at anchor−3 in write, anchor−4 in read; 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous) — confirmed 2026-04-20
- Record Stop Mode: Fixed Record Time / Auto / Manual Stop (enum) ❓ (byte near recording_mode; data[40] in E5 sf1 changed 0x01→0x00 alongside Continuous→Single Shot — may be this field)
- Sample Rate: Standard 1024 / Fast 2048 / Faster 4096 sps ✅ (anchor−2)
- Record Time: float, seconds ✅ (anchor+10)
- Histogram Interval: 2s / 5s / 15s / 1m / 5m / 15m ✅ (uint16 BE seconds at anchor−4, same in read & write; mode-gated to Histogram/Histogram+Continuous) — confirmed 2026-04-20
- Storage Mode: Save All Data / Save Triggered (enum)
- Geophone Type: Standard Triaxial / 4.5 Hz (bool/enum)
- Geophone Channels: Enable all geophones (bool), Trigger Source (bool)
- Chan 1-3 Trigger Level (float, in/s) ✅ (
trigger_level_geo) - Chan 1-3 Maximum Range: Normal 10.000 / 1.25 in/s (enum) ✅ (
geo_rangeuint8; CONFIRMED 2026-04-20 from 4-20-26 geo sensitivity captures: offset =channel_label+33in both E5 read and SUB 71 write payloads (same bytes, round-tripped verbatim);0x00= Normal 10.000 in/s,0x01= Sensitive 1.250 in/s; applied to Tran/Vert/Long channel blocks). IMPORTANT:channel_label+20reads0x01on ALL captures and is NOT this field — it is a constant flag. The float32 atchannel_label+28= 6.206053 is the ADC-to-velocity scale factor (hardware constant, do NOT write). - Microphone Channels: Enable all microphones (bool), Trigger Source (bool)
- Chan 4 Trigger Level (dB or psi depending on units)
Notes tab:
- Enable User Notes (bool)
- Project, Client, User Name, Seis Loc (ASCII strings) ✅ (sourced from A5 frame 7 via 5A)
- Enable Extended Notes (bool); Extended Notes text; Extended Notes Title
- Enable Job Number (bool); Job Number (int)
- Enable Scaled Distance (bool); Distance from Blast (float); Charge Weight (float) — Scaled Distance is derived
Special Setups tab:
- Unit Timer: Timer Mode (Off/On), Start Date/Time, Stop Date/Time
- Self Check: Mode (Off/On), Time (HH:MM)
- Sensor Check: Before monitoring / After each event / Disabled ❓ (byte offset unknown)
- Measurement Units: Imperial / Metric
- Show Mic units in dB (bool)
- Time Format: 24 Hour / 12 Hour (AM/PM)
- Backlight on Time (seconds) ✅ (event index block +75)
- Power Saving Timeout (minutes) ✅ (event index block +83)
- Monitoring LCD Cycle ✅ (event index block +84:86)
- Set unit time with setup (bool)
The "Sensor Check" dropdown (Before monitoring / After each event / Disabled) has NOT
been located in the raw config bytes. The user's unit always runs with Before monitoring.
Full compliance config encoder is a future task.
Erase-all protocol (SUBs 0xA3/0xA2/0x06) — confirmed 2026-04-11
Full sequence confirmed from 4-11-26 MITM capture of a live Blastware ACH session
(bridges/captures/mitm/ach_mitm_20260411_001912/).
Wire sequence
BW → device: SUB 0xA3 params=00 00 00 00 00 00 00 FE 00 00 (begin erase)
device → BW: SUB 0x5C (ack)
BW → device: SUB 0x1C probe (offset=0x00)
device → BW: SUB 0xE3 (probe ack)
BW → device: SUB 0x1C data (offset=0x2C)
device → BW: SUB 0xE3 (monitor status response)
BW → device: SUB 0x06 probe (offset=0x00, params same)
device → BW: SUB 0xF9 (probe ack)
BW → device: SUB 0x06 data (offset=0x24)
device → BW: SUB 0xF9 (36-byte storage range response)
BW → device: SUB 0xA2 params=00 00 00 00 00 00 00 FE 00 00 (confirm erase)
device → BW: SUB 0x5D (ack — device memory is now cleared)
All frames use standard build_bw_frame (not write-format). Response SUBs follow the
standard 0xFF - SUB formula; no exceptions.
SUB 0x06 — event storage range response (36 bytes)
The 36-byte response body ends with two 4-byte event keys:
| Offset (from end) | Field | Notes |
|---|---|---|
[-8:-4] |
first stored event key | 01110000 when empty |
[-4:] |
last stored event key | 01110000 when empty |
Before erase: ends with <first_key> <last_key> (e.g. 0111ea60 0111eaa6).
After erase: both bytes read 01110000 — device's empty/reset sentinel.
Post-erase key counter reset
After a successful erase, the device resets its event counter. New events start from
key 0x01110000 again — the same key as the very first event ever recorded. This means
key-based deduplication in the ACH server must account for key reuse:
- After our own erase:
ach_state.jsondownloaded_keysandmax_downloaded_keyare cleared so the next session starts fresh. - After an external erase: the ACH server detects it by comparing
max(device_keys)tomax_downloaded_keyfrom state. If the device max has rolled back below the historical max, all current device keys are treated as new regardless ofseen_keys.
ACH server state format (v0.9.0)
bridges/captures/ach_state.json:
{
"BE11529": {
"downloaded_keys": ["01110000", "0111245a"],
"max_downloaded_key": "0111245a",
"last_seen": "2026-04-11T01:04:36",
"serial": "BE11529",
"peer": "63.43.212.232:51920"
}
}
max_downloaded_key is the high-water mark — the largest key ever downloaded from the
unit. It is NOT reset when events are erased from the device (only when our server does
the erase). Used for post-erase detection.
Monitor log entries — SUB 0x0A partial records (confirmed 2026-04-11)
Confirmed from 4-11-26 MITM capture: 12 partial records (record type 0x2C) and 7 full
event records (record type 0x46) across 19 total 0x0A responses.
Record type detection
read_waveform_header() returns (raw_data, length) where raw_data = data_rsp.data
(the full payload including prefix bytes). The record type is at raw_data[0]:
| Value | Type | How to process |
|---|---|---|
0x46 |
Full triggered event | Normal download: 0C → 5A → 1F |
0x2C |
Monitor log entry (partial) | No 0C/5A; decode inline from 0A payload |
Length heuristic: length < 0x40 (64) reliably identifies partial records across all
observed captures. Both checks (raw_data[0] == 0x2C and length < 0x40) are used.
SUB 0x0A partial record (0x2C) payload layout
All offsets are from raw_data (the full data_rsp.data array including the 11-byte
prefix before the actual header bytes start).
raw_data[0] = 0x2C ← record type (partial / monitor log)
raw_data[1:11] = prefix bytes (vary; contain key4 copy, flags, length)
raw_data[11:] = timestamp and ASCII metadata payload
Timestamp auto-detection (confirmed from 4-11-26 capture):
raw_data[11] == 0x10 → 10-byte sub_code=0x03 format (continuous mode)
raw_data[11] != 0x10 → 9-byte sub_code=0x10 format (single-shot mode)
9-byte timestamp format (sub_code=0x10):
| Byte | Field |
|---|---|
| 0 | day |
| 1 | 0x10 (sub_code marker) |
| 2 | month |
| 3–4 | year (uint16 BE) |
| 5 | unknown (0x00) |
| 6 | hour |
| 7 | minute |
| 8 | second |
10-byte timestamp format (sub_code=0x03):
| Byte | Field |
|---|---|
| 0 | 0x10 (marker) |
| 1 | day |
| 2 | 0x10 (marker) |
| 3 | month |
| 4–5 | year (uint16 BE) |
| 6 | unknown (0x00) |
| 7 | hour |
| 8 | minute |
| 9 | second |
Two timestamps: Each partial record contains two timestamps — start_time and
stop_time — stored consecutively:
ts1(start) atraw_data[ts_offset : ts_offset + ts_size]wherets_offset = 11ts2(stop) atraw_data[ts1_end : ts1_end + ts_size]
Edge case — 1-byte gap between timestamps: Occurs when ts1 and ts2 share the same
minute:second. If try_ts(raw_data[ts1_end:]) fails, try try_ts(raw_data[ts1_end+1:]).
Confirmed in frames 121, 161, 165 of the 4-11-26 MITM capture. Frame 121 still shows 0s
duration (both decode to 16:02:00) — the extra byte appears in all same-second cases.
ASCII metadata after timestamps:
<separator bytes> BE<serial>\x00Geo: <float> in/s ...
- Serial: scan for
b"BE", read untilb"\x00"(e.g."BE11529") - Geo threshold: scan for
b"Geo: ", read float until next space (e.g.0.254in/s)
A separator of variable length (4–5 bytes of \x00 + flags) sits between the two
timestamps and the ASCII region. The b"BE" anchor scan is robust to separator length
variation.
_decode_0a_partial_header(raw_data, index, key4) — client.py
Returns a MonitorLogEntry or None. Called by get_monitor_log_entries() for each
event key whose 0x0A response has raw_data[0] == 0x2C or length < 0x40.
MiniMateClient.get_monitor_log_entries(skip_keys=None) — client.py
Browse-mode walk: 1E → 0A → check type → decode if partial → 1F. No 0x0C or 5A reads
performed. Full (0x46) records are skipped without decoding. Returns list[MonitorLogEntry].
skip_keys (optional set[str]): keys in this set are still advanced through the walk
(to avoid disrupting the iteration sequence), but no MonitorLogEntry is created for them.
MonitorLogEntry model — models.py
@dataclass
class MonitorLogEntry:
index: int # 0-based position
key: str # 8-hex event key
start_time: Optional[datetime.datetime] = None
stop_time: Optional[datetime.datetime] = None
serial: Optional[str] = None
geo_threshold_ips: Optional[float] = None
raw_header: Optional[bytes] = field(default=None, repr=False)
@property
def duration_seconds(self) -> Optional[float]: ...
ACH server integration (v0.10.0)
After get_events(), the ACH server calls get_monitor_log_entries(skip_keys=seen_keys).
New entries are saved to monitor_log.json in the session directory. Monitor log keys are
included in current_keys for state persistence so they are not re-processed on the next
call-home.
Auto Call Home config (SUBs 0x2C / 0x7E / 0x7F) — confirmed 2026-04-20
Full read/write pipeline confirmed from bridges/captures/4-20-26/call home settings/
(10 BW TX write frames diffed against the S3 read response).
Accessible in Blastware: Remote Access → Setup Unit.
Protocol
SUB 0x2C — Call Home Config READ (response 0xD3)
Standard two-step read: probe offset 0x0000, data offset 0x007C (124).
Returns 125 raw bytes (one more than DATA_LENGTH) because the device encodes
num_retries value 3 as \x10\x03 on the wire — S3FrameParser preserves both
bytes literally, shifting all subsequent field positions by +1.
SUB 0x7E — Call Home Config WRITE (response 0x81)
Write format (only BW_CMD 0x10 doubled on wire; DLE-aware checksum).
Payload = 125-byte read payload + \x00\x00 = 127 bytes.
Offset = data[1] + 2 = 0x7C + 2 = 0x7E.
SUB 0x7F — Call Home WRITE CONFIRM (response 0x80)
Confirm frame, no data payload. Required after SUB 0x7E.
Field map (raw 125-byte array from data_rsp.data[11:])
| Raw Offset | Field | Notes |
|---|---|---|
[5] |
auto_call_home_enabled |
0x00=off, 0x01=on |
[6:46] |
dial_string |
40-byte null-padded ASCII |
[87] |
after_event_recorded |
bool |
[91] |
at_specified_times |
bool |
[93] |
time1_enabled |
bool |
[101] |
time1_hour |
0–23 |
[102] |
time1_min |
0–59 |
[95] |
time2_enabled |
bool |
[105] |
time2_hour |
0–23 |
[106] |
time2_min |
0–59 |
[117] |
DLE prefix 0x10 |
Part of \x10\x03 (DLE-escaped ETX encoding value 3) |
[118] |
num_retries |
Value = 3; detect via raw[117] == 0x10 |
[120] |
time_between_retries_sec |
Shifted +1 from logical 119 |
[122] |
wait_for_connection_sec |
Shifted +1 from logical 121 |
[124] |
warm_up_time_sec |
Shifted +1 from logical 123 |
DLE-escaped 0x03 at raw[117:119]: The byte value 0x03 is indistinguishable from the
frame ETX terminator, so the device encodes it as \x10\x03 (DLE + ETX inner-terminator).
S3FrameParser in STATE_AFTER_DLE on ETX appends both bytes as literal payload. The write
frame sends them verbatim — device accepts \x10\x03 and interprets it as value 3.
Unconfirmed fields: time slots 3 and 4 (offsets unknown), modem_power_relay_enabled.
CallHomeConfig model — models.py
@dataclass
class CallHomeConfig:
raw: Optional[bytes] = None # 125-byte raw read payload
auto_call_home_enabled: Optional[bool] = None # raw[5]
dial_string: Optional[str] = None # raw[6:46]
after_event_recorded: Optional[bool] = None # raw[87]
at_specified_times: Optional[bool] = None # raw[91]
time1_enabled: Optional[bool] = None # raw[93]
time1_hour: Optional[int] = None # raw[101]
time1_min: Optional[int] = None # raw[102]
time2_enabled: Optional[bool] = None # raw[95]
time2_hour: Optional[int] = None # raw[105]
time2_min: Optional[int] = None # raw[106]
num_retries: Optional[int] = None # raw[118] (DLE-prefixed)
time_between_retries_sec: Optional[int] = None # raw[120] (shifted +1)
wait_for_connection_sec: Optional[int] = None # raw[122] (shifted +1)
warm_up_time_sec: Optional[int] = None # raw[124] (shifted +1)
SFM REST API — sfm/server.py
GET /device/call_home?host=1.2.3.4&tcp_port=9034 ← read call home config
POST /device/call_home?host=1.2.3.4&tcp_port=9034 ← write call home config
POST body fields (all optional): auto_call_home_enabled, after_event_recorded,
at_specified_times, time1_enabled, time1_hour, time1_min, time2_enabled,
time2_hour, time2_min.
Note: dial_string is read-only in the current implementation (omitted from POST
body) because writing a dial string may require DLE escaping for embedded control characters.
What's next
-
Database — SQLite store for events + monitor log entries; dedup by key; queryable
-
Histograms — decode histogram-mode A5 data (noise floor tracking)
-
Blastware-compatible file output —
write_blastware_file()andwrite_mlg()implemented.blastware_filename()generates correct Blastware filenames (AB0 for direct, AB0W/AB0H for ACH). Confirmed working for Continuous mode events (2026-04-23): SFM-generated file opens in Blastware, shows correct PPV/waveform/timestamp. File is ~200 bytes shorter than BW (missing last ADC tail slice) — all measurements correct. Histogram+Continuous mode deferred (5A stream for those events embeds histogram interval records that create spurious STRT markers in the body). Extension mapping: CONFIRMED FALSE 2026-04-21 — extensions encode timestamp (AB0T for ACH, AB0 for direct), NOT recording mode. Filename format:<prefix_letter><serial3><4-char-base36-stem><ext>Serial encoding (CONFIRMED 2026-04-22):
prefix_letter = chr(ord('B') + floor(serial_numeric / 1000)),serial3 = f"{serial_numeric % 1000:03d}". Examples: BE6907→H907, BE11529→M529, BE14036→P036, BE17353→S353, BE18003→T003. The prefix letter encodes the production generation (batch of 1000 units).Stem encoding (FULLY CONFIRMED 2026-04-22): stem = 4-char base-36 of
floor(total_seconds / 1296)wheretotal_seconds = (event_local_time − 1985-01-01T00:00:00_local)in seconds. Epoch =1985-01-01 00:00:00device local time — confirmed against 3,248 files from 10-year production archive with zero errors. Decode:event_time = datetime(1985,1,1) + timedelta(seconds=stem_int*1296 + ab_int). Example: P036L318.C80H → BE14036, 2025-05-26 15:00:08, Full Histogram. -
Blastware filename extension — NEW FIRMWARE FULLY DECODED (confirmed 2026-04-21, further confirmed 2026-04-22 from 10-year production archive frequency analysis):
Extension format =
AB0T(4 chars):AB= 2-char base-36 encoding oftotal_seconds % 1296(seconds within the 21.6-min window, 0–1295);A = value // 36,B = value % 360= always literal digit zero (third character, invariant)T= event type:W= Full Waveform,H= Full Histogram
Combined with the 4-char stem, the full filename encodes a complete second-resolution timestamp. Verified against three S353L4H0.{3M0W,8S0H,9X0W} events (all match to the second) plus large-scale frequency analysis of a 10-year archive.
3-day cycle property (confirmed 2026-04-22): A unit recording at a fixed daily time cycles through exactly 3 extensions with a 3-day period. Each calendar day shifts
total_seconds % 1296by 864 (since86400 % 1296 = 864). The cycle repeats every 3 days becausegcd(1296, 864) = 432and1296 / 432 = 3. The three extension values are spaced 432 seconds apart. Confirmed from 10-year archive: the top 3 extensions overall wereCE0H(95 files),0E0H(93),OE0H(91) — all three are the 3-day cycle of a 06:00:14 daily call-in time (seconds-in-window = 14, 446, 878; all three haveEas second character because14 = Ein base-36 and adding 864 never changesvalue % 36since864 = 24 × 36).B character invariance: For a unit recording at a fixed time of day, the second character
Bof the extension (value % 36) never changes — only the first characterAcycles through 3 values. This means same-time-of-day files from different dates all share the sameBcharacter.Old firmware (S338, 3-char extensions ending in
0): encoding unknown. Extension is NOT recording mode.blastware_filename()returns.N00as a placeholder for old-firmware units.Micromate Series 4 uses a different extension format entirely (observed:
IDFH,IDFW). TheAB0Tformula applies only to MiniMate Plus / V10.72 firmware. -
Compliance config encoder — build raw write payloads from a
ComplianceConfigobject -
Test Histogram recording mode (0x03) write via SFM — confirmed working for Single Shot / Continuous / Histogram+Continuous; Histogram (0x03) needs a live test from a non-Histogram starting state (bare 0x03 in write vs BW's DLE-escaped
10 03) -
Compliance write anchor-9 cleanup — when changing recording_mode via SFM, the byte at anchor-9 is not explicitly managed. A spurious
0x10may persist after Histogram→other mode transitions. Does not affect device operation but differs from BW's byte-perfect output. -
Locate "Sensor Check" byte in compliance config (need capture with Disabled vs Before-monitoring)
-
Call Home — map time slots 3/4 offsets; add dial_string write support; confirm
modem_power_relay_enabled -
Modem manager — push RV50/RV55 configs via Sierra Wireless API
-
RV55 DCD/DTR issue — newer RV55 firmware doesn't assert DCD by default; units don't resume monitoring after call-home disconnect (
--restart-monitoringflag deferred)
BW capture reference
bridges/captures/ contains the following BW TX + S3 response captures for protocol analysis:
| Folder / File | Contents |
|---|---|
3-11-26/raw_bw_20260311_170151.bin |
Full compliance write + event download (SUBs 68→83 confirmed, frames 102–112) |
4-20-26/raw_bw_*_recording_mode_*.bin |
Recording mode changes: Continuous→Single Shot, →Histogram, →Histogram+Continuous |
4-20-26/histogram interval/ |
Histogram interval changes: 1min, 5min, 15min, 15sec |
4-20-26/geo sensitivity/ |
Geo sensitivity changes: 1.25 in/s (Sensitive), 10 in/s (Normal) |
4-20-26/call home settings/ |
Call home config read/write captures |
4-8-26/ |
Monitor status read, start/stop monitoring, SESSION_RESET signal, sensor check |
4-3-26-multi_event/ |
Browse-mode S3 capture with 2+ events (1E/0A/1F iteration confirmed) |
4-2-26/ |
Download-mode BW TX capture (5A bulk stream, POLL×3 requirement confirmed) |
3-31-26/ |
Single-event download (148 BW / 147 S3 frames) |
mitm/ach_mitm_20260411_001912/ |
Full ACH call-home MITM (erase protocol, 0xA3/0x06/0xA2 confirmed) |
To parse BW TX captures: use bridges/captures/ scripts or adapt the find_write_frames() pattern
in /tmp/analyze_write_payload.py — it correctly handles 0x10 0x03 DLE-escaped ETX bytes
inside write frame data (the naive parser terminates early at the escaped 0x03).�������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������