From a46961c124eab6c8662986c76b8e46b913d5125e Mon Sep 17 00:00:00 2001 From: Brian Harrison Date: Wed, 15 Apr 2026 16:36:41 -0400 Subject: [PATCH] fix: waveform decode improved for accuracy. feat: adds 5a diagnostic script to parse raw binary --- CLAUDE.md | 2114 +++++++++++++++++++++------------------- diagnose_5a_frames.py | 328 +++++++ minimateplus/client.py | 81 +- sfm/sfm_webapp.html | 51 +- tmp/diag_output.txt | Bin 0 -> 47544 bytes 5 files changed, 1474 insertions(+), 1100 deletions(-) create mode 100644 diagnose_5a_frames.py create mode 100644 tmp/diag_output.txt diff --git a/CLAUDE.md b/CLAUDE.md index 94f0db0..ed8d951 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,1023 +1,1091 @@ -# 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.12.0**. - ---- - -## 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.10.0) - -Full read pipeline + write pipeline + erase pipeline + monitor log 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** | ✅ new v0.6.0 | -| 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** | - -`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 `0x10` byte 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+ETX` as inner terminators. The outer parser treats `DLE+ETX` inside 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 claiming `1C` → `6E` was WRONG; `1C` → `0xE3` confirmed 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: - -1. **`offset_hi = 0x10` must NOT be DLE-stuffed.** Blastware sends the offset field raw. - `build_bw_frame` would stuff it to `10 10` on the wire — the device silently ignores - the frame. `build_5a_frame` writes it as a bare `10`. - -2. **DLE-aware checksum.** When computing the checksum, `10 XX` pairs in the stuffed - section contribute only `XX` to 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 is monotonic (CORRECTED 2026-04-06) - -**Chunk counters are `chunk_num * 0x0400` for ALL chunks including chunk 1.** - -The 4-2-26 BW TX capture showed `counter=0x1004` for chunk 1 of event key `01110000`, which -led to `_CHUNK1_COUNTER = 0x1004` being hardcoded as a special case. This was a Blastware -artifact, not a protocol requirement. Empirical test 2026-04-06: with `counter=0x1004` for -chunk 1 the device times out (120 s); with `counter=0x0400` (= `1 * 0x0400`) it responds -immediately and streams all frames correctly. - -The 4-3-26 capture confirms the pattern for a second event (key `0111245a`): -chunk 1 = `0x245A`, chunk 2 = `0x285A`, chunk 3 = `0x2C5A` (each +0x0400). Blastware's -true formula is `key4[2:4] + n * 0x0400` — but since `key4[2:4]` of the first event is -`0x0000`, `n * 0x0400` produces the right result. The device does not strictly validate the -counter and streams data for any valid 5A request; using `chunk_num * 0x0400` is correct. - -### 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 — STRT record layout and rectime_seconds (CORRECTED 2026-04-14) - -The STRT record is 21 bytes embedded at the start of A5[0] data. Offsets relative to -the `b'STRT'` magic bytes: - -``` -+0..3 b'STRT' magic -+4..5 flags 0xFF 0xFE (single-shot) or 0xFF 0xFD (continuous) -+6..9 next_key4 ← key of the NEXT stored event (NOT the current event) ← confirmed 2026-04-14 -+10..13 prev_key4 ← key of the PREVIOUS stored event ← confirmed 2026-04-14 -+14..15 UNKNOWN (values seen: 0xDA63=55907, 0xF38F=62351, 0x5685=22149) — NOT total_samples -+16..17 UNKNOWN (values seen: 0x0122=290, 0x011A=282, 0x00FA=250) — NOT pretrig_samples -+18 uint8 record-MODE byte — NOT rectime in seconds -+19..20 0x00 0x00 -``` - -**CONFIRMED field values (2026-04-14) from 3 desk-thump events, firmware S338.17:** - -| Field | What it is | Confirmed | -|---|---|---| -| +4..5 | 0xFFFE single-shot / 0xFFFD continuous | ✅ | -| +6..9 | next_event_key (NOT current) | ✅ 3 events | -| +10..13 | prev_event_key | ✅ 3 events | -| +18 | mode byte: 0x46 ('F') = single-shot, 0x0E = continuous | ✅ | - -**CONFIRMED (2026-04-14) — total_samples and pretrig_samples are NOT stored in the STRT record.** -The prior documented offsets (+14..15 for total_samples, +16..17 for pretrig_samples) were -WRONG — confirmed by cross-checking STRT-derived rectime against compliance record_time -(4-14-26): all 4 events give STRT-derived rectime of 21–61 s vs actual 3.0 s (ratio 7–20×). -Extending the STRT dump to 32 bytes confirmed that bytes 21+ are the start of the raw ADC -waveform samples, not more STRT fields. Blastware itself derives total_samples and -pretrig_samples from the compliance config — exactly what our fallback does. - -**The compliance-config fallback IS the correct permanent solution, not a workaround.** -`_decode_a5_waveform` uses: - - `pretrig_samples = round(0.25 × sample_rate)` (compliance monitoring standard) - - `total_samples = pretrig_samples + round(record_time × sample_rate)` - -**CONFIRMED (2026-04-14) — waveform starts at strt_pos + 21 (no preamble).** -The original `sp + 27` skip (STRT 21B + null-pad 2B + 0xFF-sentinel 4B) was WRONG. -The 6-byte "preamble" in the 4-2-26 blast capture (`00 00 ff ff ff ff`) was actually the -first ~0.75 sample-sets of quiet pre-trigger ADC data misread as padding. Desk-thump -events show different bytes at positions 21-26 (e.g. `00 10 02 00 ff fc`) — they are real -ADC readings, not a fixed preamble. The `sp + 27` skip discarded 6 bytes of real waveform -data and misaligned the channel decode for all subsequent frames. Fixed: `wave = w[sp+21:]`. - -The +6..9 next_key and +10..13 prev_key fields are confirmed across 4 events including the -first-event-after-erase case (prev_key = self-reference `01110000`; next_key = device -pre-allocates the predicted next slot even before any second event exists). - -**CRITICAL — strt[18] is a record-mode byte, NOT rectime_seconds (confirmed 2026-04-14):** -Analysis of 15 distinct STRT records across the 4-9-26 ACH capture shows: -- `flags=0xFFFE` (single-shot) → `strt[18] = 0x46` ('F') for EVERY event regardless of duration -- `flags=0xFFFD` (continuous) → `strt[18] = 0x0E` for EVERY event regardless of duration - -Do NOT use `strt[18]` for rectime. - -**Pre-trigger time is separate from record_time (confirmed 2026-04-14):** -Blastware documentation states: "The default Time Scale is -0.25 second to 1 second — this -negative number accounts for the pre-trigger set for compliance monitoring." Therefore: -- `record_time` (3.0 s) is POST-TRIGGER duration only -- Pre-trigger = 0.25 s = 256 samples at 1024 sps (compliance monitoring standard default) -- The pre-trigger field has NOT yet been located in the raw compliance config bytes -- `_decode_a5_waveform` falls back to pretrig = 0.25 × sr from compliance standard -- TODO: locate pretrig_time offset in ComplianceConfig — search around anchor or channel blocks - -The device bulk-streams zero-padded frames BEYOND the configured record window. The -viewer clips `displayCount = total_samples = pretrig + post_trig` to exclude this padding. - -**Validity checks in `_decode_a5_waveform`:** -Check 1: `pretrig_samples >= total_samples` → invalid (original check). -Check 2: STRT-derived rectime differs from `compliance_config.record_time` by more than 2× -→ invalid. Both failures fall back to the compliance-config derived values. -`_decode_a5_waveform` logs `raw strt[0:21]` at WARNING level on any failure. -Observed once (2026-04-14) with `strt[16:18] = 0x41 0x01` → pretrig=16641 (impossible). -Root cause not yet identified — capture the warning log hex dump to diagnose. - -### 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_one` needed — device ACKs but returns no data page) -- Frames B, C, D each need a `recv_one` to 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 sample_rate and record_time via the anchor -`b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'`. Search range is `cfg[0:150]`. - -Do not narrow this 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. `TcpTransport` uses - `read_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\n` over TCP to the caller even with - Quiet Mode enabled. Parser handles this — do not strip it manually before feeding to - `S3FrameParser`. - -### 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 | -|---|---| -| sample_rate | uint16 BE at anchor − 2 | -| 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 | -| max_range_geo | float32 BE, adjacent to alarm_level_geo | -| setup_name | ASCII, null-padded, in cfg body | -| project / client / operator / sensor_location | ASCII, label-value pairs | - -Anchor: `b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'`, search `cfg[0:150]` - -### 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**: - -```python -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 - -```python -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 - -```python -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 **idle** -- `section[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 (enum) -- Record Stop Mode: Fixed Record Time / Auto / Manual Stop (enum) -- Sample Rate: Standard 1024 / Fast 2048 / Faster 4096 sps ✅ (anchor−2) -- Record Time: float, seconds ✅ (anchor+10) -- Histogram Interval: 5 / 15 / 30 / 60 minutes (enum, mode-gated) -- 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) ✅ (`max_range_geo`) -- 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 ` ` (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.json` `downloaded_keys` and `max_downloaded_key` are - cleared so the next session starts fresh. -- After an external erase: the ACH server detects it by comparing `max(device_keys)` to - `max_downloaded_key` from state. If the device max has rolled back below the historical - max, all current device keys are treated as new regardless of `seen_keys`. - -### ACH server state format (v0.9.0) - -`bridges/captures/ach_state.json`: -```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) at `raw_data[ts_offset : ts_offset + ts_size]` where `ts_offset = 11` -- `ts2` (stop) at `raw_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:** -``` - BE\x00Geo: in/s ... -``` - -- Serial: scan for `b"BE"`, read until `b"\x00"` (e.g. `"BE11529"`) -- Geo threshold: scan for `b"Geo: "`, read float until next space (e.g. `0.254` in/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 - -```python -@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. - ---- - -## What's next - -- **Database** — SQLite store for events + monitor log entries; dedup by key; queryable -- **Histograms** — decode histogram-mode A5 data (noise floor tracking) -- Compliance config encoder — build raw write payloads from a `ComplianceConfig` object -- Locate "Sensor Check" byte in compliance config (need capture with Disabled vs Before-monitoring) -- 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-monitoring` flag deferred) +# 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.12.0**. + +--- + +## 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.10.0) + +Full read pipeline + write pipeline + erase pipeline + monitor log 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** | ✅ new v0.6.0 | +| 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** | + +`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 `0x10` byte 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+ETX` as inner terminators. The outer parser treats `DLE+ETX` inside 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 claiming `1C` → `6E` was WRONG; `1C` → `0xE3` confirmed 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: + +1. **`offset_hi = 0x10` must NOT be DLE-stuffed.** Blastware sends the offset field raw. + `build_bw_frame` would stuff it to `10 10` on the wire — the device silently ignores + the frame. `build_5a_frame` writes it as a bare `10`. + +2. **DLE-aware checksum.** When computing the checksum, `10 XX` pairs in the stuffed + section contribute only `XX` to 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 is monotonic (CORRECTED 2026-04-06) + +**Chunk counters are `chunk_num * 0x0400` for ALL chunks including chunk 1.** + +The 4-2-26 BW TX capture showed `counter=0x1004` for chunk 1 of event key `01110000`, which +led to `_CHUNK1_COUNTER = 0x1004` being hardcoded as a special case. This was a Blastware +artifact, not a protocol requirement. Empirical test 2026-04-06: with `counter=0x1004` for +chunk 1 the device times out (120 s); with `counter=0x0400` (= `1 * 0x0400`) it responds +immediately and streams all frames correctly. + +The 4-3-26 capture confirms the pattern for a second event (key `0111245a`): +chunk 1 = `0x245A`, chunk 2 = `0x285A`, chunk 3 = `0x2C5A` (each +0x0400). Blastware's +true formula is `key4[2:4] + n * 0x0400` — but since `key4[2:4]` of the first event is +`0x0000`, `n * 0x0400` produces the right result. The device does not strictly validate the +counter and streams data for any valid 5A request; using `chunk_num * 0x0400` is correct. + +### 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 — STRT record layout and rectime_seconds (CORRECTED 2026-04-14) + +The STRT record is 21 bytes embedded at the start of A5[0] data. Offsets relative to +the `b'STRT'` magic bytes: + +``` ++0..3 b'STRT' magic ++4..5 flags 0xFF 0xFE (single-shot) or 0xFF 0xFD (continuous) ++6..9 next_key4 ← key of the NEXT stored event (NOT the current event) ← confirmed 2026-04-14 ++10..13 prev_key4 ← key of the PREVIOUS stored event ← confirmed 2026-04-14 ++14..15 UNKNOWN (values seen: 0xDA63=55907, 0xF38F=62351, 0x5685=22149) — NOT total_samples ++16..17 UNKNOWN (values seen: 0x0122=290, 0x011A=282, 0x00FA=250) — NOT pretrig_samples ++18 uint8 record-MODE byte — NOT rectime in seconds ++19..20 0x00 0x00 +``` + +**CONFIRMED field values (2026-04-14) from 3 desk-thump events, firmware S338.17:** + +| Field | What it is | Confirmed | +|---|---|---| +| +4..5 | 0xFFFE single-shot / 0xFFFD continuous | ✅ | +| +6..9 | next_event_key (NOT current) | ✅ 3 events | +| +10..13 | prev_event_key | ✅ 3 events | +| +18 | mode byte: 0x46 ('F') = single-shot, 0x0E = continuous | ✅ | + +**CONFIRMED (2026-04-14) — total_samples and pretrig_samples are NOT stored in the STRT record.** +The prior documented offsets (+14..15 for total_samples, +16..17 for pretrig_samples) were +WRONG — confirmed by cross-checking STRT-derived rectime against compliance record_time +(4-14-26): all 4 events give STRT-derived rectime of 21–61 s vs actual 3.0 s (ratio 7–20×). +Extending the STRT dump to 32 bytes confirmed that bytes 21+ are the start of the raw ADC +waveform samples, not more STRT fields. Blastware itself derives total_samples and +pretrig_samples from the compliance config — exactly what our fallback does. + +**The compliance-config fallback IS the correct permanent solution, not a workaround.** +`_decode_a5_waveform` uses: + - `pretrig_samples = round(0.25 × sample_rate)` (compliance monitoring standard) + - `total_samples = pretrig_samples + round(record_time × sample_rate)` + +**CONFIRMED (2026-04-14) — waveform starts at strt_pos + 21 (no preamble).** +The original `sp + 27` skip (STRT 21B + null-pad 2B + 0xFF-sentinel 4B) was WRONG. +The 6-byte "preamble" in the 4-2-26 blast capture (`00 00 ff ff ff ff`) was actually the +first ~0.75 sample-sets of quiet pre-trigger ADC data misread as padding. Desk-thump +events show different bytes at positions 21-26 (e.g. `00 10 02 00 ff fc`) — they are real +ADC readings, not a fixed preamble. The `sp + 27` skip discarded 6 bytes of real waveform +data and misaligned the channel decode for all subsequent frames. Fixed: `wave = w[sp+21:]`. + +The +6..9 next_key and +10..13 prev_key fields are confirmed across 4 events including the +first-event-after-erase case (prev_key = self-reference `01110000`; next_key = device +pre-allocates the predicted next slot even before any second event exists). + +**CRITICAL — strt[18] is a record-mode byte, NOT rectime_seconds (confirmed 2026-04-14):** +Analysis of 15 distinct STRT records across the 4-9-26 ACH capture shows: +- `flags=0xFFFE` (single-shot) → `strt[18] = 0x46` ('F') for EVERY event regardless of duration +- `flags=0xFFFD` (continuous) → `strt[18] = 0x0E` for EVERY event regardless of duration + +Do NOT use `strt[18]` for rectime. + +**Pre-trigger time is separate from record_time (confirmed 2026-04-14):** +Blastware documentation states: "The default Time Scale is -0.25 second to 1 second — this +negative number accounts for the pre-trigger set for compliance monitoring." Therefore: +- `record_time` (3.0 s) is POST-TRIGGER duration only +- Pre-trigger = 0.25 s = 256 samples at 1024 sps (compliance monitoring standard default) +- The pre-trigger field has NOT yet been located in the raw compliance config bytes +- `_decode_a5_waveform` falls back to pretrig = 0.25 × sr from compliance standard +- TODO: locate pretrig_time offset in ComplianceConfig — search around anchor or channel blocks + +The device bulk-streams zero-padded frames BEYOND the configured record window. The +viewer clips `displayCount = total_samples = pretrig + post_trig` to exclude this padding. + +**Validity checks in `_decode_a5_waveform`:** +Check 1: `pretrig_samples >= total_samples` → invalid (original check). +Check 2: STRT-derived rectime differs from `compliance_config.record_time` by more than 2× +→ invalid. Both failures fall back to the compliance-config derived values. +`_decode_a5_waveform` logs `raw strt[0:21]` at WARNING level on any failure. +Observed once (2026-04-14) with `strt[16:18] = 0x41 0x01` → pretrig=16641 (impossible). +Root cause not yet identified — capture the warning log hex dump to diagnose. + +### 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 5A — re-probe at counter=0x1000 (DLE collision, FIXED 2026-04-15) + +**Root cause (confirmed from diagnostic output, desk-thump event key=01110000):** +`bulk_waveform_params()` sets `p[3] = (counter >> 8) & 0xFF`. For chunk 4, counter = +`4 * 0x0400 = 0x1000`, so `p[3] = 0x10` — the DLE byte. Because `build_5a_frame` writes +params RAW (no DLE stuffing), the on-wire byte sequence contains a bare `0x10`. The +device DLE-decodes its own receive buffer: `10 00` (the p[3]/p[4] pair) is collapsed to +`00`, so the counter field reads 0 — a probe request. The device re-sends the initial +probe response (containing the STRT record and first waveform bytes). + +**Effect:** In a 36-frame stream for key=01110000, fi=4 was a byte-for-byte duplicate of +fi=0 (same db=1101B, same w[0:32] bytes, STRT present at w[10]). The old code treated it +as a regular chunk, decoded the STRT bytes as int16 samples (producing T=21587="ST", +V=21586="RT"), and shifted the running byte alignment for all subsequent frames. + +**Fix (`_decode_a5_waveform`):** Check for STRT in every frame, not just fi==0. Any +non-fi=0 frame containing STRT is a re-probe — log it and skip (do NOT add to +`all_chunks`). Regular chunk path is reached only when `w.find(b"STRT") < 0`. + +**Note:** This DLE collision is key-specific. For key=01110000 (`key4[2:4]=0x0000`), +counter = `chunk_num * 0x0400`, so counter=0x1000 occurs at chunk 4 for every event with +this key. For other keys (e.g. key=0111245a, `key4[2:4]=0x245A`), the chunk counter +formula `key4[2:4] + n*0x0400` produces different values; the collision only occurs when +the high byte of any counter is 0x10. + +### SUB 5A — metadata false-positive (FIXED 2026-04-15) + +**Root cause (confirmed from diagnostic output):** +The old metadata-frame test was `b"Project:" in w` (single anchor). For the 36-frame +desk-thump stream, fi=15 had `b"Project:"` at w[93] inside live ADC data — a coincidental +4-byte pattern in the waveform. The frame (134 live sample-sets) was incorrectly skipped. + +**Fix:** Require BOTH `b"Project:" in w` AND `b"Client:" in w` to classify a frame as +metadata. The real metadata frame (fi=6 in the desk-thump stream) contains both strings +as part of the compliance-setup ASCII block; random ADC data is statistically unlikely to +contain both 8-byte sequences. + +**Updated check in `_decode_a5_waveform`:** +```python +elif b"Project:" in w and b"Client:" in w: + log.info("_decode_a5_waveform: fi=%d skipped (metadata frame)", fi) + continue +``` + +### SUB 5A — 0xFF tail frames beyond record window (confirmed 2026-04-15) + +The device bulk-streams flash pages beyond the configured record window. Un-written flash +pages read as 0xFF. Decoded as int16 LE, `0xFF 0xFF = -1`, which maps to ~0 in/s after +the geo scale factor is applied — producing a visible flat-line at the end of the waveform. + +**Confirmed from diagnostic output (desk-thump event, 1024 sps, record_time=3.0 s):** +- total_samples = 256 (pretrig) + 3072 (post) = 3328 +- samples_decoded = 4417 (36 frames) +- Excess tail: 4417 − 3328 = 1089 samples (8.7 frames of 0xFF data) +- Flat-line onset: sample 1960, t=1664ms post-trigger (within the active signal window + — earlier than expected because fi=4 re-probe and fi=15 false-positive removed ~270 + samples; once those bugs are fixed the real onset should be at or beyond total_samples) + +**Fix (two parts):** +1. `_decode_a5_waveform` (Python): already returns `total_samples` alongside + `samples_decoded`; the field is populated from compliance config: + `total_samples = pretrig_samples + round(record_time * sample_rate)`. +2. `sfm_webapp.html` (`_buildWaveformCharts`): `display = Math.min(decoded, total_samples)`; + `times` array and per-channel `samples` are both sliced to `display` length before + plotting — `(channels[ch] || []).slice(0, display)`. Without this, the chart rendered + all `decoded` samples including the 0xFF tail. + +### 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_one` needed — device ACKs but returns no data page) +- Frames B, C, D each need a `recv_one` to 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 sample_rate and record_time via the anchor +`b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'`. Search range is `cfg[0:150]`. + +Do not narrow this 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. `TcpTransport` uses + `read_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\n` over TCP to the caller even with + Quiet Mode enabled. Parser handles this — do not strip it manually before feeding to + `S3FrameParser`. + +### 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 | +|---|---| +| sample_rate | uint16 BE at anchor − 2 | +| 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 | +| max_range_geo | float32 BE, adjacent to alarm_level_geo | +| setup_name | ASCII, null-padded, in cfg body | +| project / client / operator / sensor_location | ASCII, label-value pairs | + +Anchor: `b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'`, search `cfg[0:150]` + +### 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**: + +```python +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 + +```python +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 + +```python +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 **idle** +- `section[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 (enum) +- Record Stop Mode: Fixed Record Time / Auto / Manual Stop (enum) +- Sample Rate: Standard 1024 / Fast 2048 / Faster 4096 sps ✅ (anchor−2) +- Record Time: float, seconds ✅ (anchor+10) +- Histogram Interval: 5 / 15 / 30 / 60 minutes (enum, mode-gated) +- 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) ✅ (`max_range_geo`) +- 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 ` ` (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.json` `downloaded_keys` and `max_downloaded_key` are + cleared so the next session starts fresh. +- After an external erase: the ACH server detects it by comparing `max(device_keys)` to + `max_downloaded_key` from state. If the device max has rolled back below the historical + max, all current device keys are treated as new regardless of `seen_keys`. + +### ACH server state format (v0.9.0) + +`bridges/captures/ach_state.json`: +```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) at `raw_data[ts_offset : ts_offset + ts_size]` where `ts_offset = 11` +- `ts2` (stop) at `raw_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:** +``` + BE\x00Geo: in/s ... +``` + +- Serial: scan for `b"BE"`, read until `b"\x00"` (e.g. `"BE11529"`) +- Geo threshold: scan for `b"Geo: "`, read float until next space (e.g. `0.254` in/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 + +```python +@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. + +--- + +## What's next + +- **Database** — SQLite store for events + monitor log entries; dedup by key; queryable +- **Histograms** — decode histogram-mode A5 data (noise floor tracking) +- Compliance config encoder — build raw write payloads from a `ComplianceConfig` object +- Locate "Sensor Check" byte in compliance config (need capture with Disabled vs Before-monitoring) +- 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-monitoring` flag deferred) + \ No newline at end of file diff --git a/diagnose_5a_frames.py b/diagnose_5a_frames.py new file mode 100644 index 0000000..362f44a --- /dev/null +++ b/diagnose_5a_frames.py @@ -0,0 +1,328 @@ +#!/usr/bin/env python3 +""" +diagnose_5a_frames.py -- Frame-by-frame diagnostic for SUB 5A waveform streams. + +Usage: + python diagnose_5a_frames.py [--host HOST] [--port PORT] [--event INDEX] + +Connects to the device, downloads the waveform for the specified event (default 0 = +most recently stored), and prints detailed per-frame info for every A5 response frame: + + fi=N | db=NNN B w=NNN B | "Project:" in db=[offsets] in w=[offsets] <-- METADATA if detected + w[0:32] = + w[-8:] = + [waveform bytes or ASCII snippet] + +Then shows: + - total non-metadata frames, total waveform bytes, total sample-sets decoded + - compliance-config expected vs decoded counts + - sample values at the flat-line onset region (~1700-1820) + - first near-zero run location (|T| < 20 for 10+ consecutive samples) + +Run with: python diagnose_5a_frames.py 2>&1 | tee /tmp/diag_output.txt +""" + +from __future__ import annotations + +import argparse +import logging +import struct +import sys + +# -- Setup logging ------------------------------------------------------------- +logging.basicConfig( + level=logging.WARNING, # suppress library noise; we print our own output + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + stream=sys.stderr, +) + +from minimateplus import MiniMateClient +from minimateplus.transport import TcpTransport + +log = logging.getLogger("diagnose") +log.setLevel(logging.INFO) + + +def decode_int16_sets(wave: bytes, n: int = 8) -> list[tuple[int, int, int, int]]: + """Decode up to n sample-sets from wave bytes as [T, V, L, M] int16 LE.""" + sets = [] + for i in range(min(n, len(wave) // 8)): + off = i * 8 + t = struct.unpack_from(" list[int]: + """Return all offsets where needle appears in data.""" + positions = [] + start = 0 + while True: + pos = data.find(needle, start) + if pos < 0: + break + positions.append(pos) + start = pos + 1 + return positions + + +def sep(label: str = "") -> None: + width = 80 + if label: + pad = max(0, (width - len(label) - 2) // 2) + print(f"\n{'-' * pad} {label} {'-' * max(0, width - pad - len(label) - 2)}") + else: + print("-" * width) + + +def diagnose(frames_data: list[bytes], compliance_config=None) -> None: + """Analyse all A5 frames and print diagnostic info.""" + + sep("PER-FRAME ANALYSIS") + print(f"Total A5 frames received: {len(frames_data)}") + print() + + all_chunks: list[tuple[int, bytes]] = [] # (fi, wave_bytes) + cumulative_wave_bytes = 0 + + for fi, db in enumerate(frames_data): + w = db[7:] # what _decode_a5_waveform sees (db[7:]) + + # Find "Project:" in both the full frame data and the w=db[7:] slice + proj_in_db = find_all(db, b"Project:") + proj_in_w = find_all(w, b"Project:") + + # The live detector in client.py uses: b"Project:" in w + detected_as_metadata = bool(proj_in_w) + + flag = " <-- METADATA (skipped)" if detected_as_metadata else "" + print(f"fi={fi:3d} db={len(db):5d}B w={len(w):5d}B " + f"Project: in db={proj_in_db} in w(db[7:])={proj_in_w}{flag}") + + hex_head = w[:32].hex(' ') + hex_tail = w[-8:].hex(' ') if len(w) >= 8 else w.hex(' ') + print(f" w[0:32] = {hex_head}") + print(f" w[-8:] = {hex_tail}") + + if fi == 0: + sp = w.find(b"STRT") + if sp >= 0: + strt = w[sp:sp + 21] + print(f" STRT at w[{sp}]: {strt.hex(' ')}") + wave = w[sp + 21:] + if wave: + sets = decode_int16_sets(wave, 4) + print(f" wave[sp+21:] first 4 sets (T,V,L,M): {sets}") + all_chunks.append((fi, wave)) + cumulative_wave_bytes += len(wave) + print(f" cum_bytes={cumulative_wave_bytes} cum_sets={cumulative_wave_bytes // 8}") + else: + print(f" *** STRT NOT FOUND ***") + + elif detected_as_metadata: + # Print the ASCII content to confirm this is the real metadata frame + try: + snippet = w.decode("ascii", errors="replace") + # Find the first 200 printable characters + printable = snippet[:200].replace("\x00", ".").replace("\r", "\n").replace("\n", "\n") + print(f" ASCII: {repr(printable[:140])}") + except Exception as e: + print(f" (decode error: {e})") + + else: + # Regular chunk: strip 8-byte header + if len(w) >= 8: + wave = w[8:] + all_chunks.append((fi, wave)) + cumulative_wave_bytes += len(wave) + sets = decode_int16_sets(wave, 4) + # Count near-zero Tran values + all_sets = decode_int16_sets(wave, len(wave) // 8) + nz = sum(1 for s in all_sets if abs(s[0]) < 20) + print(f" wave[8:] first 4 sets: {sets}") + print(f" cum_bytes={cumulative_wave_bytes} cum_sets={cumulative_wave_bytes // 8} " + f"near-zero(|T|<20): {nz}/{len(all_sets)}") + + print() + + # -- Waveform value analysis ------------------------------------------------ + sep("WAVEFORM DECODE") + + cc_sr = 1024 + cc_rt = None + pretrig = 256 + total_expected = 0 + + if compliance_config: + cc_sr = compliance_config.sample_rate or 1024 + cc_rt = compliance_config.record_time + pretrig = int(round(0.25 * cc_sr)) + if cc_rt: + total_expected = pretrig + int(round(cc_rt * cc_sr)) + print(f"Compliance: sr={cc_sr} sps record_time={cc_rt} s " + f"pretrig={pretrig} total_expected={total_expected}") + else: + print("No compliance config -- using defaults: sr=1024, pretrig=256") + + total_wave_bytes = sum(len(w) for _, w in all_chunks) + total_sets_raw = total_wave_bytes // 8 + print(f"Non-metadata frames: {len(all_chunks)} " + f"Total wave bytes: {total_wave_bytes} " + f"Raw sample-sets: {total_sets_raw}") + + # Alignment-corrected decode (matches _decode_a5_waveform exactly) + tran: list[int] = [] + running_offset = 0 + for fi, wave in all_chunks: + align = running_offset % 8 + skip = (8 - align) % 8 + if skip > 0 and skip < len(wave): + usable = wave[skip:] + elif align == 0: + usable = wave + else: + running_offset += len(wave) + continue + n_usable = len(usable) // 8 + for i in range(n_usable): + tran.append(struct.unpack_from("= 32: + print(f"Last 16 Tran: {tran[-16:]}") + + # -- Flat-line onset search ------------------------------------------------- + sep("FLAT-LINE ONSET (first run of 10+ consecutive |Tran| < 20)") + + run_start = None + run_len = 0 + onset_found = False + for i, v in enumerate(tran): + if abs(v) < 20: + if run_start is None: + run_start = i + run_len += 1 + else: + if run_len >= 10: + t_ms = (run_start - pretrig) * 1000.0 / cc_sr + print(f" First near-zero run: sample {run_start}-{run_start + run_len - 1} " + f"(t={t_ms:.1f}ms post-trigger) length={run_len}") + onset_found = True + break + run_start = None + run_len = 0 + else: + if run_len >= 10 and run_start is not None: + t_ms = (run_start - pretrig) * 1000.0 / cc_sr + print(f" Near-zero run at end: sample {run_start}-{n_decoded - 1} " + f"(t={t_ms:.1f}ms post-trigger) length={run_len}") + onset_found = True + + if not onset_found: + print(" No near-zero run of 10+ samples found (waveform looks active throughout)") + + # Print samples around the expected flat-line onset (~1700-1820) + if n_decoded >= 1700: + print() + print("Tran samples [1700:1820] (10 per line):") + for row_start in range(1700, min(1820, n_decoded), 10): + row = tran[row_start:row_start + 10] + t_ms_row = (row_start - pretrig) * 1000.0 / cc_sr + print(f" [{row_start:4d}] (t={t_ms_row:6.1f}ms): {row}") + else: + print(f" Only {n_decoded} samples decoded -- range 1700-1820 not available") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Diagnose A5 5A waveform frames") + parser.add_argument("--host", default="63.43.212.232", help="Device IP") + parser.add_argument("--port", type=int, default=9034, help="TCP port") + parser.add_argument("--event", type=int, default=0, help="Event index (0=first stored)") + args = parser.parse_args() + + print(f"Connecting to {args.host}:{args.port} ...") + print(f"Target event index: {args.event}") + print() + + transport = TcpTransport(args.host, port=args.port) + with MiniMateClient(transport=transport) as client: + info = client.connect() + print(f"Device: serial={info.serial} firmware={info.firmware_version}") + compliance_config = info.compliance_config + if compliance_config: + print(f"Compliance: sample_rate={compliance_config.sample_rate} " + f"record_time={compliance_config.record_time}") + print() + + proto = client._proto + assert proto is not None + + # -- Walk to the target event ------------------------------------------ + log.info("Reading first event key (SUB 1E) ...") + first_key4, first_data8 = proto.read_event_first(token=0) + print(f"First event key: {first_key4.hex()}") + + cur_key4 = first_key4 + cur_data8 = first_data8 + event_idx = 0 + + while event_idx < args.event: + # 0A required before each 1F to establish device context + proto.read_waveform_header(cur_key4) + next_key4, next_data8 = proto.advance_event(browse=True) + if next_data8[4:8] == b"\x00\x00\x00\x00": + print(f"Only {event_idx + 1} events available; cannot reach index {args.event}") + return + cur_key4 = next_key4 + cur_data8 = next_data8 + event_idx += 1 + print(f" advanced to event {event_idx}: key={cur_key4.hex()}") + + print(f"\nDownloading event {args.event}: key={cur_key4.hex()}") + + # -- Full download sequence (matches get_events download-mode) --------- + log.info("0A: read_waveform_header ...") + proto.read_waveform_header(cur_key4) + + log.info("1E(0xFE): arm device for 5A ...") + proto.read_event_first(token=0xFE) + + log.info("0C: read_waveform_record ...") + wfm_raw = proto.read_waveform_record(cur_key4) + print(f"0C waveform record: {len(wfm_raw)} bytes") + + log.info("1F(0xFE): arm 5A state machine ...") + arm_key4, _ = proto.advance_event(browse=False) + print(f"1F(arm) returned key: {arm_key4.hex()}") + + log.info("POLLx3 ...") + for i in range(3): + proto.poll() + print(f" POLL {i+1}/3 OK") + + print(f"\nStarting 5A bulk stream for key={cur_key4.hex()} ...") + frames_data = proto.read_bulk_waveform_stream( + cur_key4, + stop_after_metadata=False, + max_chunks=2048, + ) + print(f"5A complete: {len(frames_data)} A5 frames") + print() + + # -- Run the diagnostic ------------------------------------------------ + diagnose(frames_data, compliance_config) + + +if __name__ == "__main__": + main() diff --git a/minimateplus/client.py b/minimateplus/client.py index 6973214..0491850 100644 --- a/minimateplus/client.py +++ b/minimateplus/client.py @@ -1440,29 +1440,50 @@ def _decode_a5_waveform( for fi, db in enumerate(frames_data): w = db[7:] - # A5[0]: waveform begins immediately after the 21-byte STRT record. - # Confirmed 2026-04-14: there is NO preamble after STRT — bytes 21+ - # are raw ADC sample data. The earlier sp+27 skip was eating 6 bytes - # of real waveform, misaligning the channel decode for all subsequent - # frames. - if fi == 0: - sp = w.find(b"STRT") - if sp < 0: + # ── Probe frames (fi==0 AND any re-probe the device sends mid-stream) ──── + # A5[0] always contains the STRT record. For event key 0x01110000, + # chunk 4 (counter=0x1000) has 0x10 in the counter high byte; the device + # DLE-decodes the params and sees counter=0x0000 (probe), so it responds + # with a duplicate probe frame containing the same STRT. The diagnostic + # from 2026-04-15 confirmed this: fi=4 was byte-for-byte identical to fi=0 + # (same db length 1101B, same STRT at w[10], same first 32 bytes). + # + # Handling: any frame — not just fi==0 — that contains the STRT magic is + # treated as a probe frame. Waveform starts at strt_pos + 21 (no preamble). + # Re-probe frames are complete duplicates of fi=0 (device re-sends the + # beginning of the event), so their post-STRT waveform bytes are DROPPED + # to avoid injecting duplicate data into the stream. + sp = w.find(b"STRT") + if sp >= 0: + if fi == 0: + wave = w[sp + 21 :] + log.info( + "_decode_a5_waveform: A5[0] probe — STRT at w[%d], " + "waveform starts at sp+21; first 24 wave bytes: %s", + sp, wave[:24].hex(' '), + ) + else: + # Re-probe frame: device re-sent probe in response to a chunk + # request whose counter byte happened to be 0x10 (DLE). + # The post-STRT bytes are a duplicate of the initial waveform + # — drop this frame entirely to avoid double-counting data. + log.info( + "_decode_a5_waveform: fi=%d re-probe (STRT at w[%d]) — " + "skipped (duplicate probe response from device)", + fi, sp, + ) continue - wave = w[sp + 21 :] - log.info( - "_decode_a5_waveform: A5[0] waveform starts at sp+21; " - "first 24 wave bytes: %s", - wave[:24].hex(' '), - ) - # Metadata frame: contains "Project:", "Client:", etc. strings. - # Originally assumed to be always fi==7 (A5[7] in 4-2-26 blast capture), - # but confirmed variable position — it appears at whatever chunk index the - # device places it (observed at fi=6 for desk-thump events 2026-04-14). - # Skip ANY frame whose raw bytes contain b"Project:" — this is the same - # anchor used by stop_after_metadata in read_bulk_waveform_stream. - elif b"Project:" in w: + # Metadata frame: contains BOTH "Project:" and "Client:" strings. + # Requiring two compliance anchors prevents false positives where ADC + # bytes accidentally spell "Project:" (confirmed false positive at fi=15 + # in the 2026-04-15 desk-thump download — only "Project:" appeared there, + # not "Client:"). The real metadata frame always contains both. + # This is the same anchor used by stop_after_metadata in + # read_bulk_waveform_stream (which only checks "Project:" — see note + # there about the asymmetry: stopping early is fine with one anchor, + # but skipping a waveform frame requires higher confidence). + elif b"Project:" in w and b"Client:" in w: log.info("_decode_a5_waveform: fi=%d skipped (metadata frame)", fi) continue @@ -2248,20 +2269,4 @@ def _decode_monitor_status(data: bytes) -> MonitorStatus: # Payload length varies (46–49 bytes) but the battery/memory block is always # the last 10 bytes. No checksum byte — it was stripped by S3FrameParser. # - # section[-10:-8] battery × 100 uint16 BE 0x02A8 = 6.80 V - # section[-8 :-4] memory_total uint32 BE ≈ 960 KB on BE11529 - # section[-4:] memory_free uint32 BE decreases as events fill - # - # Confirmed stable across IDLE (46b), MONITORING (48-49b) variants. - if len(section) >= 10: - batt_raw = struct.unpack(">H", section[-10:-8])[0] - battery_v = batt_raw / 100.0 - memory_total = struct.unpack(">I", section[-8:-4])[0] - memory_free = struct.unpack(">I", section[-4:])[0] - - return MonitorStatus( - is_monitoring=is_monitoring, - battery_v=battery_v, - memory_total=memory_total, - memory_free=memory_free, - ) + # section[-1 \ No newline at end of file diff --git a/sfm/sfm_webapp.html b/sfm/sfm_webapp.html index 3078fa4..a27b245 100644 --- a/sfm/sfm_webapp.html +++ b/sfm/sfm_webapp.html @@ -1555,6 +1555,14 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) { const sr = data.sample_rate || 1024; const pretrig = data.pretrig_samples || 0; const decoded = data.samples_decoded || 0; + // Clip display to total_samples (pretrig + post_trig from compliance config). + // The device bulk-streams zero-padded (0xFF = -1) frames beyond the configured + // record window; without clipping these appear as a flat line at ~0 in/s past + // the end of the actual recording. Confirmed 2026-04-15: a 36-frame 5A stream + // for a 3.25s event (total_samples=3328) contained 19 trailing all-0xFF frames + // (2457 extra samples) that caused a visible flat-line in the waveform display. + const total = (data.total_samples && data.total_samples > 0) ? data.total_samples : decoded; + const display = Math.min(decoded, total); const channels = data.channels || {}; // Destroy old chart instances @@ -1574,7 +1582,7 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) { return; } - const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2)); + const times = Array.from({length: display}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2)); if (emptyEl) emptyEl.style.display = 'none'; chartsEl.style.display = 'flex'; chartsEl.style.flexDirection = 'column'; @@ -1584,8 +1592,8 @@ function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) { const micPeakPsi = data.peak_values?.micl_psi ?? null; for (const [ch, color] of Object.entries(CHANNEL_COLORS)) { - const samples = channels[ch]; - if (!samples || samples.length === 0) continue; + const samples = (channels[ch] || []).slice(0, display); + if (samples.length === 0) continue; const isGeo = ch !== 'Mic'; let plotData, peakLabel, yUnit, ttFmt, tickFmt; @@ -2118,39 +2126,4 @@ document.getElementById('api-base').value = window.location.origin; - - - -
-
Tran
-
Vert
-
Long
-
MicL
-
PVS
-
- - - - - -
-
-

Loading…

-
- -
- - - - - + font-si \ No newline at end of file diff --git a/tmp/diag_output.txt b/tmp/diag_output.txt new file mode 100644 index 0000000000000000000000000000000000000000..76aad789a1a597b04475b83084ea855748430b13 GIT binary patch literal 47544 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 Qwk-RW{2cd*@onz?AFE@DmH+?% literal 0 HcmV?d00001