13 Commits

Author SHA1 Message Date
claude ad7b064b67 fix: improve metadata frame detection and update version to v0.12.1 2026-04-15 01:42:13 -04:00
claude 3dd3c970ab fix: stack modal waveform charts vertically to match live events view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 23:59:17 -04:00
claude 6a0f0ae2e4 chore: doc/gitignore cleanup 2026-04-14 23:53:47 -04:00
claude bbd574e7d5 feat: unify DB and live waveform views with inline modal overlay
- Extract _buildWaveformCharts() shared renderer used by both live Events
  tab and new DB history modal (no duplicate chart-building code)
- Replace window.open(waveform_viewer.html) with openDbWaveformModal()
  that renders an inline overlay with full peaks bar, debug panel, and
  4-channel charts — same rendering path as the live device view
- Fix timestamp display for DB blobs (ISO string vs {display:...} object)
- Normalize old blob peak_values keys (tran/vert/long → tran_in_s etc.)
  for backward compat with pre-fix ACH blobs
- Close modal via × button, Esc key, or backdrop click; destroy Chart.js
  instances on close to free canvas memory
- Fix onclick UUID quoting in History table (UUIDs need quoted string arg)
- Fix ach_server.py peak_values key names to match viewer expectations
- Extract _fillDebugPanel() so same debug content works in both contexts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 23:36:32 -04:00
claude 727bfed5c4 fix: add debug panel for raw ADC counts and decode diagnostics 2026-04-14 21:02:40 -04:00
claude 8d0537389d fix: continue to debug and fix strt amd waveform weirdness 2026-04-14 19:44:37 -04:00
claude c5a7914032 fix: update STRT record parsing to reflect confirmed offsets and derive total/pretrig_samples from compliance config 2026-04-14 18:32:16 -04:00
claude dbb9febe2c fix: update STRT parsing to extract additional bytes for total_samples and pretrig_samples 2026-04-14 18:02:45 -04:00
claude 9ae968b108 fix: peak0c scope bug and strt cross check fix 2026-04-14 17:46:38 -04:00
claude 171dc2551c fix: add STRT invalid detction, ach server passes config for get events, 2026-04-14 17:08:27 -04:00
claude 4f4c1a8f64 debug: figuring out whats wrong with waveform viewer 2026-04-14 16:00:14 -04:00
claude 0da88ec6aa fix: redefines rectime_seconds from strt[18] byte to new computed time.
The server now re-computes rectime_seconds using the actual sample rate from the compliance config (overriding the default 1024 in the client), so if the device runs at 2048 or 4096 sps it's still correct.

Viewer — The rectime display now shows Xs (stored) / Ys (cfg) so you can compare the STRT-derived duration against the compliance config's record_time setting side-by-side. I also clamped the y-axis to ±(0C peak × 1.4) so near-saturation decode artifacts don't squash the real blast signal into a flat line.
2026-04-14 14:19:17 -04:00
claude edb4698bfb feat: add waveform download and storage. 2026-04-14 02:15:33 -04:00
21 changed files with 2844 additions and 6012 deletions
+17 -152
View File
@@ -4,161 +4,26 @@ All notable changes to seismo-relay are documented here.
---
## v0.12.5 — 2026-04-21
## v0.12.1 — 2026-04-15
### Changed
### Fixed
- **`s3_bridge.py` — raw captures always-on by default** — `--raw-bw` and `--raw-s3` now
default to `"auto"` instead of `None`. Every bridge session automatically generates
timestamped `raw_bw_<ts>.bin` and `raw_s3_<ts>.bin` files alongside the `.bin`/`.log`
session files. Pass `--raw-bw ""` (explicit empty string) to disable if needed.
- **Metadata frame not detected when compliance fields are unconfigured** — the
5A bulk waveform stream contains one metadata frame carrying ASCII compliance
strings. The decoder skipped it only if `b"Project:"` appeared in the frame
payload. On devices where Project/Client/User Name/Seis Loc are all blank, the
device omits those label strings entirely, so the check returned False and the
frame was decoded as ADC waveform samples. The serial number bytes (`"BE11529\0"`)
appeared at sample ~929 followed by `0xFF 0xFF` fill, truncating the second half
of every waveform and producing a flat-line at 0 in/s after ~660 ms.
- **`gui_bridge.py` — raw capture checkboxes pre-checked** — Both "BW→S3 raw" and
"S3→BW raw" checkboxes start checked. Path fields are empty by default (bridge auto-names
the files). Unchecking a box passes `--raw-bw ""` to explicitly disable capture.
- **`ach_server.py` — TX capture added (`raw_tx_<ts>.bin`)** — Every ACH inbound session
now saves both directions: `raw_rx_<ts>.bin` (device → us, S3 side, as before) and
`raw_tx_<ts>.bin` (us → device, BW side). Both files are usable in the Analyzer.
TX bytes are buffered in memory until startup handshake succeeds (same as RX), preventing
scanner probes from creating empty files.
---
## v0.12.4 — 2026-04-21 (protocol analysis / docs only — no code changes)
### Discovered
- **compliance_raw is wire-encoded, not logical bytes** — `read_compliance_config()` returns
bytes that include DLE prefix bytes (`0x10`) before any `0x03` values (because S3FrameParser
preserves DLE+ETX inner-frame pairs as two literal bytes). The previous CLAUDE.md claim that
"S3FrameParser handles this transparently so compliance_raw contains logical bytes" was wrong.
- **anchor-9 behavior per recording mode** (confirmed from 4-20-26 BW write captures):
- Single Shot (0x00) / Continuous (0x01): anchor-9 = `0x00`
- Histogram (0x03): anchor-9 = `0x10` — the E5 DLE prefix for the `0x03` recording_mode byte
- Histogram+Continuous (0x04): anchor-9 = `0x10` — an actual stored config byte for this mode
Anchor position shifts by ±1 when recording_mode = `0x03` due to the extra DLE byte; the
dynamic anchor search (`buf.find(ANCHOR, 0, 150)`) handles this correctly without code changes.
- **Write frame ETX escaping** — BW escapes `0x03` bytes in write frame data as `0x10 0x03`
on the wire. Our `build_bw_write_frame` sends data bytes raw without ETX escaping. Device
accepts our raw writes for all tested modes. Hypothesis: device write parser uses the
offset/length field for frame boundaries, not ETX scanning, making ETX escaping optional.
Histogram mode (recording_mode = 0x03) write via SFM from a non-Histogram starting state
not yet tested.
- **BW write payload vs E5 read payload are byte-identical** around the anchor region (confirmed
by comparing 3-11-26 BW TX and S3 captures). BW does NOT strip DLE prefix bytes before writing;
it round-trips the wire-encoded bytes verbatim with only the modified fields changed.
- **Capture folder content catalogued** — see CLAUDE.md "BW capture reference" table for a
summary of all available protocol captures and their contents.
---
## v0.12.3 — 2026-04-20
### Added
- **Auto Call Home config protocol** — Full read/write/decode/encode pipeline for the
device's Remote Access → Setup Unit ACH settings, confirmed from 4-20-26 call home
settings captures.
**Protocol (new):**
- `SUB 0x2C` — Call Home Config READ (response `0xD3`); two-step read; data offset
`0x7C` = 124; raw payload 125 bytes (1-byte longer than DATA_LENGTH due to DLE-escaped
`\x10\x03` at raw[117:119] representing num_retries = 3)
- `SUB 0x7E` — Call Home Config WRITE (response `0x81`); 127-byte payload (125-byte read
payload + `\x00\x00`); offset = `data[1]+2 = 0x7E`; write format (DLE-aware checksum)
- `SUB 0x7F` — Call Home WRITE CONFIRM (response `0x80`); no data
**Field map (confirmed from 10-frame BW TX diff):**
- `raw[5]` — auto_call_home_enabled (bool)
- `raw[6:46]` — dial_string (40-byte null-padded ASCII)
- `raw[87]` — after_event_recorded (bool)
- `raw[91]` — at_specified_times (bool)
- `raw[93]` — time1_enabled / `raw[101]` — time1_hour / `raw[102]` — time1_min
- `raw[95]` — time2_enabled / `raw[105]` — time2_hour / `raw[106]` — time2_min
- `raw[117:119]``\x10\x03` (DLE-escaped 0x03 = num_retries value 3)
- `raw[120]` — time_between_retries_sec / `raw[122]` — wait_for_connection_sec / `raw[124]` — warm_up_time_sec
**Library (`minimateplus/`):**
- `models.py``CallHomeConfig` dataclass (14 fields; `raw` bytes preserved for
round-trip writes)
- `protocol.py``SUB_CALL_HOME = 0x2C`, `SUB_CALL_HOME_WRITE = 0x7E`,
`SUB_CALL_HOME_CONFIRM = 0x7F`; `read_call_home_config()`, `write_call_home_config()`
- `client.py``get_call_home_config()`, `set_call_home_config()`,
`_decode_call_home_config()` (handles DLE prefix at raw[117]),
`_encode_call_home_config()` (patches in-place; raises `ValueError` if hour/min = 3)
**REST API (`sfm/server.py`):**
- `GET /device/call_home` — reads and decodes call home config from device
- `POST /device/call_home` — reads, patches specified fields, writes back to device
- `CallHomeConfigBody` Pydantic model with 9 optional writable fields
**Web UI (`sfm/sfm_webapp.html`):**
- New "Call Home" tab with enable flag, dial string (read-only), after-event trigger,
at-specified-times flag, two time slots (enable + HH:MM each), and read-only retry
settings (num_retries, time_between_retries_sec, wait_for_connection_sec,
warm_up_time_sec)
- "Read from Device", "Write to Device", "Clear Form" action buttons
- Client-side guard: rejects hour or minute value equal to 3 with a clear message
explaining the DLE-encoding limitation
---
## v0.12.2 — 2026-04-20
### Added / Fixed
- **Geophone sensitivity / maximum range field confirmed** — 4-20-26 geo sensitivity
captures (1.25 in/s vs 10 in/s) diffed across all three SUB 71 write chunks and both
E5 read payloads. The `geo_range` uint8 field per channel is now fully confirmed:
- E5 read offset: `channel_label + 33`; SUB 71 write offset: `channel_label + 29`
- `0x00` = Normal 10.000 in/s (standard gain); `0x01` = Sensitive 1.250 in/s (high gain)
- **Correction:** previous hypothesis (`channel_label+20`, `0x01`=Normal) was wrong.
`channel_label+20` reads `0x01` on ALL captures regardless of range — not this field.
- `_decode_compliance_config_into`: read offset corrected from `tran_pos+20``tran_pos+33`
- `_encode_compliance_config`: added `geo_range` parameter; writes to Tran/Vert/Long at `+29`
- `apply_config`: added `geo_range` parameter
- `POST /device/config`: added `geo_range` to `DeviceConfigBody`
- Web UI Config tab: added "Maximum Range — Geo" select (Normal / Sensitive)
- Web UI Device tab: added "Max Range (geo)" row to compliance table
- **`recording_mode` + `histogram_interval_sec` confirmed and implemented** (4-20-26 captures)
- `recording_mode`: uint8 at anchor8 (E5 read) / anchor7 (write); enum: 0x00=Single Shot,
0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous
- `histogram_interval_sec`: uint16 BE seconds at anchor4; same offset in read & write;
valid: 2, 5, 15, 60, 300, 900 (matching Blastware dropdown: 2s, 5s, 15s, 1m, 5m, 15m)
- Both fields added to `ComplianceConfig`, `_decode_compliance_config_into`,
`_encode_compliance_config`, `apply_config`, REST API body, and web UI
---
## v0.12.1 — 2026-04-16
### Added
- **`sfm/server.py``_LiveCache`** — in-memory live device cache that eliminates
redundant TCP round-trips between web requests. Plain Python dict +
`threading.Lock`, no extra dependencies.
Cache strategy per endpoint:
| Endpoint | Strategy |
|---|---|
| `GET /device/info` | Indefinite; invalidated by `POST /device/config` |
| `GET /device/events` | Count-probe fast path — `poll()+count_events()` (~2 s); returns cached data if event count is unchanged; full download only when new events are detected |
| `GET /device/monitor/status` | 30-second TTL; invalidated immediately on monitor start/stop |
| `GET /device/event/{idx}/waveform` | Permanent per-index (waveforms are immutable once recorded) |
- **`?force=true`** query param on all cached endpoints — bypasses cache and forces
a fresh read from the device.
- **Cache invalidation hooks** — `POST /device/config` marks device info and events
stale; `POST /device/monitor/start` and `/stop` evict the monitor status entry
immediately so the next status poll reflects the actual device state.
Fix: `_decode_a5_waveform` now checks a tuple of needles (`_METADATA_FRAME_NEEDLES`)
against the full frame bytes (`db`, not `db[7:]`): `b"Project:"`, `b"Client:"`,
`b"User Name:"`, `b"Seis Loc:"`, `b"Extended Notes"`, and `b"Geo: "`. The geo
threshold label is always present (monitoring cannot operate without a configured
geo trigger level) and is the reliable universal anchor. The same tuple is used
in `read_bulk_waveform_stream` for the `stop_after_metadata` early-exit path.
The log records which needle matched, aiding future diagnosis.
---
+102 -265
View File
@@ -2,9 +2,7 @@
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.3**.
When new information about the protocol is discovered, please update the instantel_protocol_reference.md with the findings in addition to this document
(Sierra Wireless RV50 / RV55). Current version: **v0.12.1**.
---
@@ -27,9 +25,9 @@ CHANGELOG.md ← version history
---
## Current implementation state (v0.12.3)
## Current implementation state (v0.10.0)
Full read pipeline + write pipeline + erase pipeline + monitor log + call home config working end-to-end over TCP/cellular:
Full read pipeline + write pipeline + erase pipeline + monitor log working end-to-end over TCP/cellular:
| Step | SUB | Status |
|---|---|---|
@@ -45,8 +43,7 @@ Full read pipeline + write pipeline + erase pipeline + monitor log + call home c
| Event advance / next key | 1F | ✅ |
| **Write commands (push config to device)** | **6883** | ✅ new v0.8.0 |
| **Erase all events** | **0xA3 → 0x1C → 0x06 → 0xA2** | ✅ new v0.9.0 |
| **Monitor log entries (partial 0x2C records)** | **0A browse** | ✅ new v0.10.0 |
| **Auto Call Home config (read + write)** | **2C → 7E → 7F** | ✅ **new v0.12.3** |
| **Monitor log entries (partial 0x2C records)** | **0A browse** | ✅ **new v0.10.0** |
`get_events()` sequence per event: `1E → 0A → 0C → 5A → 1F`
@@ -118,29 +115,21 @@ S3→BW (response):
Both differences confirmed by reproducing Blastware's exact wire bytes from the 1-2-26
BW TX capture. All 10 frames verified.
### SUB 5A — chunk counter formula (FINAL CORRECTION 2026-04-26)
### SUB 5A — chunk counter is monotonic (CORRECTED 2026-04-06)
**Chunk counter = `max(key4[2:4], 0x0400) + (chunk_num - 1) * 0x0400` for ALL chunks.**
**Chunk counters are `chunk_num * 0x0400` for ALL chunks including chunk 1.**
where `key4[2:4] = (key4[2] << 8) | key4[3]` is the event's circular-buffer base offset.
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 `max(..., 0x0400)` guard is critical for events at the start of the circular buffer
(key4[2:4] == 0x0000, e.g. key `01110000`). Without it, chunk 1 gets counter=0x0000, which
is the same address as the probe frame — the device re-returns the STRT record data instead
of waveform payload. With the guard, chunk 1 gets counter=0x0400, which is confirmed correct
from the empirical live-device test 2026-04-06 (`counter=0x0400 → responds immediately and
streams all frames correctly`).
The 4-3-26 capture confirms the pattern for a second event (key `0111245a`, key4[2:4]=0x245a):
chunk 1 = `0x245A`, chunk 2 = `0x285A`, chunk 3 = `0x2C5A` (each +0x0400).
`max(0x245a, 0x0400) = 0x245a` → formula works correctly for non-zero base offset too.
**History:**
- Original: `_CHUNK1_COUNTER = 0x1004` hardcoded (Blastware capture artifact — WRONG).
- 2026-04-06: Corrected to `chunk_num * 0x0400` (worked for key 01110000 only).
- 2026-04-24: Corrected to `key4[2:4] + (chunk_num-1) * 0x0400` (fixed non-zero offsets,
but accidentally broke key 01110000 — counter=0x0000 sends probe address again).
- 2026-04-26: Final formula: `max(key4[2:4], 0x0400) + (chunk_num-1) * 0x0400`.
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
@@ -174,6 +163,83 @@ record — 5A remains the sole source for those fields and they are set uncondit
`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 2161 s vs actual 3.0 s (ratio 720×).
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
@@ -316,16 +382,10 @@ producing only ~1071 bytes instead of ~2126.
### SUB 1A — anchor search range
`_decode_compliance_config_into()` locates fields via the **6-byte stable anchor**
`b'\xbe\x80\x00\x00\x00\x00'`. Search range is `cfg[0:150]`.
`_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]`.
**IMPORTANT — the "10-byte anchor" `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` is NOT fully constant.**
The first 2 bytes (`\x01\x2c` = 300) are the `histogram_interval_sec` field (uint16 BE, seconds) —
the value 300 is just the 5-minute default. When histogram interval is set to a different value
(e.g. 15min = 0x0384 = `\x03\x84`), those bytes change. Only the 6-byte suffix
`\xbe\x80\x00\x00\x00\x00` is truly constant. The code already uses the 6-byte anchor.
Do not narrow the search range to `cfg[40:100]` — the old range was only accidentally correct because
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 40100 window.
@@ -347,36 +407,6 @@ Do NOT use fixed absolute offsets for sample_rate or record_time.
Quiet Mode enabled. Parser handles this — do not strip it manually before feeding to
`S3FrameParser`.
**SUB 5A (bulk waveform) TCP frame splitting — confirmed 2026-04-27:**
Over TCP via cellular modem, each 5A chunk request that produces a single ~1100-byte
A5 response over direct RS-232 may arrive as **two separate, complete S3 frames** of
~550 bytes each ("2-frame mode"). The modem's Data Forwarding Timeout (~100-150 ms)
can split the RS-232 response into two TCP segments, each parsed as a complete S3 frame.
Under different modem/timing conditions the full ~1100-byte response arrives as **one
S3 frame** ("1-frame mode").
**Both modes require `extra_chunks_after_metadata=1`** (the extra chunk at metadata_counter
+ 0x0400). The device's waveform footer data lives at circular-buffer address 0x1C00 for
this event; the terminator frame must be sent at 0x1C00 (not 0x1800) to receive it.
Example for a 2-second Continuous event (BE11529, key=01110000) via TCP:
- **2-frame mode:** 1 probe frame (554 B) + 5 chunks × 2 frames (556-573 B) + 1 extra chunk × 2 frames + 1 terminator (208 B) = **14 A5 frames** → 6864-byte file
- **1-frame mode:** 1 probe frame (~1097 B) + 5 chunks × 1 frame (~1079-1113 B) + 1 extra chunk × 1 frame (smaller, tail of event) + 1 terminator → **8 A5 frames** → 6864-byte file
- All frames contribute body data; using all of them gives the correct file.
**Fix (confirmed 2026-04-27):** `_recv_5a_batch()` in `protocol.py` collects ALL
A5 frames per chunk request before the next request is sent, using a 0.5 s batch
timeout after the first frame to catch the ~150 ms delayed second frame. `write_blastware_file()`
includes ALL body frames without skipping — the extra chunk's frames are part of the
body data, NOT padding to be discarded.
**WRONG earlier hypothesis (do not re-introduce):** An attempt was made to auto-detect
1-frame vs 2-frame mode from the probe frame size and skip the extra chunk when
`probe_data_len >= 700`. This was wrong — the extra chunk is always needed to advance
the device's internal state to the footer address. The `_probe_is_large` branch was
removed 2026-04-27.
### Required ACEmanager settings (Sierra Wireless RV50/RV55)
| Setting | Value | Why |
@@ -405,72 +435,15 @@ removed 2026-04-27.
| Field | How to find it |
|---|---|
| **recording_mode** | **uint8 at anchor 3 (write) / anchor 4 (read)** ✅ confirmed 2026-04-20 |
| sample_rate | uint16 BE at anchor 2 |
| **histogram_interval_sec** | **uint16 BE at anchor 4 (seconds); same offset in read & write** ✅ confirmed 2026-04-20 |
| record_time | float32 BE at anchor + 10 |
| trigger_level_geo | float32 BE, located in channel block |
| alarm_level_geo | float32 BE, adjacent to trigger_level_geo |
| geo_hardware_constant (adc_scale_factor) | float32 BE at **channel_label+28** in both read (E5) and write (SUB 71) payloads — reads **6.206053** on BOTH tested units (BE11529 and BE18189); identical across all geo channels (Tran/Vert/Long) and all captures. **Confirmed 2026-04-17 from Interface Handbook §4.5**: this is the **ADC-to-velocity scale factor** = 1/sensitivity = (in/s per V). Firmware uses it as: `PPV (in/s) = ADC_voltage × 6.206053`. Cross-check: `1.61133 V (ADC full-scale) × 6.206053 = 10.000 in/s` (Normal range ✅). Do NOT write this field — it is a hardware/firmware constant. |
| geo_range (sensitivity selector) | **uint8 at channel_label+33** in both read (E5) and write (SUB 71) payloads — **CONFIRMED 2026-04-20** from 4-20-26 geo sensitivity captures: `0x00` = Normal 10.000 in/s (standard gain), `0x01` = Sensitive 1.250 in/s (high gain). Present in all three geo channel blocks (Tran, Vert, Long). **NOTE: `channel_label+20` reads `0x01` on ALL captures regardless of range setting — it is NOT this field.** Note: the "SUB 71 write offset = +29" that appears in earlier analysis was an artifact of incorrect BW-style destuffing applied to write frame data — write frame data is RAW, so the literal `0x10` bytes in the channel block header are preserved, and the offset is the same as in the E5 read payload. |
| 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 |
**True stable anchor: `b'\xbe\x80\x00\x00\x00\x00'` (6-byte suffix), search `cfg[0:150]`.**
The old "10-byte anchor" `b'\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00'` is partially variable:
bytes `\x01\x2c` = 300 (5-minute default histogram interval); changes when interval changes.
**Field layout relative to the 6-byte anchor (write payload / E5 read — noted where different):**
| Offset | Field | Format | Notes |
|---|---|---|---|
| anchor 9 | mode_prefix | uint8 | `0x00` for Single Shot / Continuous; `0x10` for Histogram (DLE prefix in E5 encoding) and Histogram+Continuous (actual config byte). See "compliance_raw DLE encoding" note below. |
| anchor 8 | recording_mode | uint8 | **Same offset for both read and write** — confirmed 2026-04-21. `_encode_compliance_config` writes `buf[anc-8]`. NOTE: for Histogram (0x03), E5 encodes the value as `0x10 0x03` so compliance_raw[anc-9]=0x10, compliance_raw[anc-8]=0x03. |
| anchor 7 | constant | `0x10` | Always `0x10` in both E5 read and BW write payloads (not a DLE marker — it is part of the sample_rate field area). Do NOT overwrite. |
| anchor 6 | sample_rate | uint16 BE | same in read & write |
| anchor 4 | histogram_interval_sec | uint16 BE | seconds; same in read & write ✅ 2026-04-20 |
| anchor 2 | `0x00 0x00` | padding | |
| anchor | `\xbe\x80\x00\x00\x00\x00` | anchor | |
| anchor + 6 | record_time | float32 BE | same in read & write |
**recording_mode enum** (confirmed 2026-04-20 from 4-20-26 captures):
| Value | Mode | anchor-9 in compliance_raw |
|---|---|---|
| `0x00` | Single Shot | `0x00` |
| `0x01` | Continuous | `0x00` |
| `0x02` | ❓ not observed | ❓ |
| `0x03` | Histogram | `0x10` (DLE prefix from E5 wire encoding of 0x03) |
| `0x04` | Histogram + Continuous | `0x10` (actual config byte for this mode) |
**compliance_raw DLE encoding — IMPORTANT (confirmed 2026-04-21 from 4-20-26 captures):**
`compliance_raw` (returned by `read_compliance_config()`) is NOT purely logical bytes — it is
the wire-encoded representation where `0x03` bytes in the config are preceded by a `0x10` DLE
prefix (because S3FrameParser preserves DLE+ETX inner-frame pairs as two literal bytes).
Consequences:
- When recording_mode = `0x03` (Histogram), `compliance_raw[anc-9] = 0x10` (DLE prefix) and
`compliance_raw[anc-8] = 0x03` (the value). The anchor position is +1 compared to modes
without `0x03` bytes before the anchor.
- For Histogram+Continuous (`0x04`), `compliance_raw[anc-9] = 0x10` for a different reason:
it is an actual stored config byte, not a DLE prefix.
- The anchor search (`buf.find(b'\xbe\x80\x00\x00\x00\x00', 0, 150)`) correctly locates
the anchor regardless of these mode-dependent shifts.
- When SFM writes recording_mode and round-trips the rest verbatim, the byte at `anc-9` is
preserved from the previous read. This means transitioning Histogram→other modes via SFM
leaves a `0x10` at `anc-9`. The device stores it as a literal byte; it does not affect
recording mode operation (which is at `anc-8`), but differs from what BW writes. This is a
known minor discrepancy that does not impact device behavior.
- **Histogram recording mode (0x03) write via SFM**: untested. When starting from a mode with
`anc-9 = 0x00`, SFM writes bare `0x03` at anc-8. BW would write `0x10 0x03`. Device likely
accepts both (write frames probably use offset/length for framing, not ETX scanning).
**DLE escaping in write frames — confirmed 2026-04-20:** Blastware escapes `0x03` bytes in
write frame data as `0x10 0x03` on the wire (defensive ETX escaping). Our `build_bw_write_frame`
does NOT do this escaping — it sends data bytes raw. Device acceptance of bare `0x03` bytes
in write frame data is confirmed for the tested modes (Single Shot, Continuous, Histogram+Continuous
where `0x10 0x03` already appears from round-tripping). Histogram mode (bare `0x03` write from
non-Histogram starting state) has not been directly tested.
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])
@@ -805,16 +778,16 @@ Fields visible in the Blastware Compliance Setup dialog — most are NOT YET dec
offsets in the raw 1A/E5 payload. Only fields with `✅` have confirmed offsets in the code.
**Recording Setup tab:**
- Recording Mode: Continuous / Single Shot / Histogram / Histogram+Continuous ✅ (uint8 at anchor3 in write, anchor4 in read; 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous) — confirmed 2026-04-20
- Record Stop Mode: Fixed Record Time / Auto / Manual Stop (enum) ❓ (byte near recording_mode; data[40] in E5 sf1 changed 0x01→0x00 alongside Continuous→Single Shot — may be this field)
- 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 ✅ (anchor2)
- Record Time: float, seconds ✅ (anchor+10)
- Histogram Interval: 2s / 5s / 15s / 1m / 5m / 15m ✅ (uint16 BE seconds at anchor4, same in read & write; mode-gated to Histogram/Histogram+Continuous) — confirmed 2026-04-20
- 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) ✅ (`geo_range` uint8; **CONFIRMED 2026-04-20** from 4-20-26 geo sensitivity captures: offset = `channel_label+33` in both E5 read and SUB 71 write payloads (same bytes, round-tripped verbatim); `0x00` = Normal 10.000 in/s, `0x01` = Sensitive 1.250 in/s; applied to Tran/Vert/Long channel blocks). **IMPORTANT: `channel_label+20` reads `0x01` on ALL captures and is NOT this field** — it is a constant flag. The float32 at `channel_label+28` = 6.206053 is the ADC-to-velocity scale factor (hardware constant, do NOT write).
- 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)
@@ -1039,148 +1012,12 @@ call-home.
---
## Auto Call Home config (SUBs 0x2C / 0x7E / 0x7F) — confirmed 2026-04-20
Full read/write pipeline confirmed from `bridges/captures/4-20-26/call home settings/`
(10 BW TX write frames diffed against the S3 read response).
Accessible in Blastware: **Remote Access → Setup Unit**.
### Protocol
**SUB 0x2C — Call Home Config READ (response 0xD3)**
Standard two-step read: probe offset `0x0000`, data offset `0x007C` (124).
Returns 125 raw bytes (one more than DATA_LENGTH) because the device encodes
num_retries value `3` as `\x10\x03` on the wire — S3FrameParser preserves both
bytes literally, shifting all subsequent field positions by +1.
**SUB 0x7E — Call Home Config WRITE (response 0x81)**
Write format (only BW_CMD `0x10` doubled on wire; DLE-aware checksum).
Payload = 125-byte read payload + `\x00\x00` = 127 bytes.
Offset = `data[1] + 2 = 0x7C + 2 = 0x7E`.
**SUB 0x7F — Call Home WRITE CONFIRM (response 0x80)**
Confirm frame, no data payload. Required after SUB 0x7E.
### Field map (raw 125-byte array from `data_rsp.data[11:]`)
| Raw Offset | Field | Notes |
|---|---|---|
| `[5]` | `auto_call_home_enabled` | `0x00`=off, `0x01`=on |
| `[6:46]` | `dial_string` | 40-byte null-padded ASCII |
| `[87]` | `after_event_recorded` | bool |
| `[91]` | `at_specified_times` | bool |
| `[93]` | `time1_enabled` | bool |
| `[101]` | `time1_hour` | 023 |
| `[102]` | `time1_min` | 059 |
| `[95]` | `time2_enabled` | bool |
| `[105]` | `time2_hour` | 023 |
| `[106]` | `time2_min` | 059 |
| `[117]` | DLE prefix `0x10` | Part of `\x10\x03` (DLE-escaped ETX encoding value 3) |
| `[118]` | `num_retries` | Value = 3; detect via `raw[117] == 0x10` |
| `[120]` | `time_between_retries_sec` | Shifted +1 from logical 119 |
| `[122]` | `wait_for_connection_sec` | Shifted +1 from logical 121 |
| `[124]` | `warm_up_time_sec` | Shifted +1 from logical 123 |
**DLE-escaped 0x03 at raw[117:119]:** The byte value `0x03` is indistinguishable from the
frame ETX terminator, so the device encodes it as `\x10\x03` (DLE + ETX inner-terminator).
S3FrameParser in `STATE_AFTER_DLE` on ETX appends both bytes as literal payload. The write
frame sends them verbatim — device accepts `\x10\x03` and interprets it as value 3.
**Unconfirmed fields:** time slots 3 and 4 (offsets unknown), `modem_power_relay_enabled`.
### `CallHomeConfig` model — models.py
```python
@dataclass
class CallHomeConfig:
raw: Optional[bytes] = None # 125-byte raw read payload
auto_call_home_enabled: Optional[bool] = None # raw[5]
dial_string: Optional[str] = None # raw[6:46]
after_event_recorded: Optional[bool] = None # raw[87]
at_specified_times: Optional[bool] = None # raw[91]
time1_enabled: Optional[bool] = None # raw[93]
time1_hour: Optional[int] = None # raw[101]
time1_min: Optional[int] = None # raw[102]
time2_enabled: Optional[bool] = None # raw[95]
time2_hour: Optional[int] = None # raw[105]
time2_min: Optional[int] = None # raw[106]
num_retries: Optional[int] = None # raw[118] (DLE-prefixed)
time_between_retries_sec: Optional[int] = None # raw[120] (shifted +1)
wait_for_connection_sec: Optional[int] = None # raw[122] (shifted +1)
warm_up_time_sec: Optional[int] = None # raw[124] (shifted +1)
```
### SFM REST API — sfm/server.py
```
GET /device/call_home?host=1.2.3.4&tcp_port=9034 ← read call home config
POST /device/call_home?host=1.2.3.4&tcp_port=9034 ← write call home config
```
POST body fields (all optional): `auto_call_home_enabled`, `after_event_recorded`,
`at_specified_times`, `time1_enabled`, `time1_hour`, `time1_min`, `time2_enabled`,
`time2_hour`, `time2_min`.
**Note:** `dial_string` is read-only in the current implementation (omitted from POST
body) because writing a dial string may require DLE escaping for embedded control characters.
---
## What's next
- **Database** — SQLite store for events + monitor log entries; dedup by key; queryable
- **Histograms** — decode histogram-mode A5 data (noise floor tracking)
- **Blastware-compatible file output** — `write_blastware_file()` and `write_mlg()` implemented. `blastware_filename()` generates correct Blastware filenames (AB0 for direct, AB0W/AB0H for ACH). **Confirmed working for Continuous mode events (2026-04-23):** SFM-generated file opens in Blastware, shows correct PPV/waveform/timestamp. File is ~200 bytes shorter than BW (missing last ADC tail slice) — all measurements correct. Histogram+Continuous mode deferred (5A stream for those events embeds histogram interval records that create spurious STRT markers in the body). Extension mapping: **CONFIRMED FALSE 2026-04-21** — extensions encode timestamp (AB0T for ACH, AB0 for direct), NOT recording mode. Filename format: `<prefix_letter><serial3><4-char-base36-stem><ext>`
**Serial encoding (CONFIRMED 2026-04-22):** `prefix_letter = chr(ord('B') + floor(serial_numeric / 1000))`, `serial3 = f"{serial_numeric % 1000:03d}"`. Examples: BE6907→H907, BE11529→M529, BE14036→P036, BE17353→S353, BE18003→T003. The prefix letter encodes the production generation (batch of 1000 units).
**Stem encoding (FULLY CONFIRMED 2026-04-22):** stem = 4-char base-36 of `floor(total_seconds / 1296)` where `total_seconds = (event_local_time 1985-01-01T00:00:00_local)` in seconds. Epoch = `1985-01-01 00:00:00` device local time — confirmed against 3,248 files from 10-year production archive with zero errors. Decode: `event_time = datetime(1985,1,1) + timedelta(seconds=stem_int*1296 + ab_int)`. Example: P036L318.C80H → BE14036, 2025-05-26 15:00:08, Full Histogram.
- **Blastware filename extension — NEW FIRMWARE FULLY DECODED (confirmed 2026-04-21, further confirmed 2026-04-22 from 10-year production archive frequency analysis):**
Extension format = `AB0T` (4 chars):
- `AB` = 2-char base-36 encoding of `total_seconds % 1296` (seconds within the 21.6-min window, 01295); `A = value // 36`, `B = value % 36`
- `0` = always literal digit zero (third character, invariant)
- `T` = event type: `W` = Full Waveform, `H` = Full Histogram
Combined with the 4-char stem, the full filename encodes a complete second-resolution timestamp. Verified against three S353L4H0.{3M0W,8S0H,9X0W} events (all match to the second) plus large-scale frequency analysis of a 10-year archive.
**3-day cycle property (confirmed 2026-04-22):** A unit recording at a fixed daily time cycles through exactly **3 extensions** with a 3-day period. Each calendar day shifts `total_seconds % 1296` by 864 (since `86400 % 1296 = 864`). The cycle repeats every 3 days because `gcd(1296, 864) = 432` and `1296 / 432 = 3`. The three extension values are spaced 432 seconds apart. Confirmed from 10-year archive: the top 3 extensions overall were `CE0H` (95 files), `0E0H` (93), `OE0H` (91) — all three are the 3-day cycle of a 06:00:14 daily call-in time (seconds-in-window = 14, 446, 878; all three have `E` as second character because `14 = E` in base-36 and adding 864 never changes `value % 36` since `864 = 24 × 36`).
**B character invariance:** For a unit recording at a fixed time of day, the second character `B` of the extension (`value % 36`) **never changes** — only the first character `A` cycles through 3 values. This means same-time-of-day files from different dates all share the same `B` character.
**Old firmware (S338, 3-char extensions ending in `0`):** encoding unknown. Extension is NOT recording mode. `blastware_filename()` returns `.N00` as a placeholder for old-firmware units.
**Micromate Series 4** uses a different extension format entirely (observed: `IDFH`, `IDFW`). The `AB0T` formula applies only to MiniMate Plus / V10.72 firmware.
- Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
- **Test Histogram recording mode (0x03) write via SFM** — confirmed working for Single Shot / Continuous / Histogram+Continuous; Histogram (0x03) needs a live test from a non-Histogram starting state (bare 0x03 in write vs BW's DLE-escaped `10 03`)
- **Compliance write anchor-9 cleanup** — when changing recording_mode via SFM, the byte at anchor-9 is not explicitly managed. A spurious `0x10` may persist after Histogram→other mode transitions. Does not affect device operation but differs from BW's byte-perfect output.
- Locate "Sensor Check" byte in compliance config (need capture with Disabled vs Before-monitoring)
- Call Home — map time slots 3/4 offsets; add dial_string write support; confirm `modem_power_relay_enabled`
- Modem manager — push RV50/RV55 configs via Sierra Wireless API
- RV55 DCD/DTR issue — newer RV55 firmware doesn't assert DCD by default; units don't
resume monitoring after call-home disconnect (`--restart-monitoring` flag deferred)
## BW capture reference
`bridges/captures/` contains the following BW TX + S3 response captures for protocol analysis:
| Folder / File | Contents |
|---|---|
| `3-11-26/raw_bw_20260311_170151.bin` | Full compliance write + event download (SUBs 68→83 confirmed, frames 102112) |
| `4-20-26/raw_bw_*_recording_mode_*.bin` | Recording mode changes: Continuous→Single Shot, →Histogram, →Histogram+Continuous |
| `4-20-26/histogram interval/` | Histogram interval changes: 1min, 5min, 15min, 15sec |
| `4-20-26/geo sensitivity/` | Geo sensitivity changes: 1.25 in/s (Sensitive), 10 in/s (Normal) |
| `4-20-26/call home settings/` | Call home config read/write captures |
| `4-8-26/` | Monitor status read, start/stop monitoring, SESSION_RESET signal, sensor check |
| `4-3-26-multi_event/` | Browse-mode S3 capture with 2+ events (1E/0A/1F iteration confirmed) |
| `4-2-26/` | Download-mode BW TX capture (5A bulk stream, POLL×3 requirement confirmed) |
| `3-31-26/` | Single-event download (148 BW / 147 S3 frames) |
| `mitm/ach_mitm_20260411_001912/` | Full ACH call-home MITM (erase protocol, 0xA3/0x06/0xA2 confirmed) |
To parse BW TX captures: use `bridges/captures/` scripts or adapt the `find_write_frames()` pattern
in `/tmp/analyze_write_payload.py` — it correctly handles `0x10 0x03` DLE-escaped ETX bytes
inside write frame data (the naive parser terminates early at the escaped `0x03`).
+266 -268
View File
@@ -1,268 +1,266 @@
# seismo-relay `v0.12.1`
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
software for managing MiniMate Plus seismographs.
Built in Python. Runs on Windows, Linux, or macOS. Connects to instruments
over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55).
> **Status:** Active development. Full read + write + erase + monitoring
> pipeline working end-to-end over TCP/cellular. ACH Auto Call Home server
> handles inbound unit connections, downloads events, and persists everything
> to a SQLite database. SFM REST API exposes device control and DB queries.
> See [CHANGELOG.md](CHANGELOG.md) for full version history.
---
## What's in here
```
seismo-relay/
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs)
├── minimateplus/ ← MiniMate Plus client library
│ ├── transport.py ← SerialTransport, TcpTransport, SocketTransport
│ ├── protocol.py ← DLE frame layer, SUB command dispatch
│ ├── client.py ← High-level client (connect, get_events, push_config, …)
│ ├── framing.py ← Frame builders, DLE codec, S3FrameParser
│ └── models.py ← DeviceInfo, Event, ComplianceConfig, MonitorLogEntry, …
├── sfm/ ← SFM REST API server (FastAPI, port 8200)
│ ├── server.py ← All device + DB endpoints
│ ├── database.py ← SeismoDb — SQLite persistence layer
│ └── sfm_webapp.html ← Embedded web UI (served at /)
├── bridges/
│ ├── ach_server.py ← Inbound ACH call-home server (main production server)
│ ├── ach_mitm.py ← Transparent MITM proxy for capturing BW sessions
│ ├── s3-bridge/ ← RS-232 serial bridge (capture tool)
│ ├── tcp_serial_bridge.py ← Local TCP↔serial bridge (bench testing)
│ ├── gui_bridge.py ← Standalone bridge GUI
│ └── raw_capture.py ← Simple raw capture tool
├── parsers/
│ ├── s3_analyzer.py ← Session parser, differ, Claude export
│ ├── gui_analyzer.py ← Standalone analyzer GUI
│ └── frame_db.py ← SQLite frame database
└── docs/
└── instantel_protocol_reference.md ← Reverse-engineered protocol spec
```
---
## Quick start
### ACH inbound server (production)
Listens for inbound unit call-homes, downloads all new events and monitor log
entries, and writes everything to `bridges/captures/seismo_relay.db`.
```bash
python bridges/ach_server.py --port 12345 --output bridges/captures/
```
Point the unit's ACEmanager **Remote Host** to this machine's IP and **Remote Port** to `12345`.
Options:
```
--port N Listen port (default 12345)
--output DIR Capture directory (default bridges/captures/)
--allow-ip IP Allowlist an IP (repeat for multiple; default: accept all)
--max-events N Safety cap for first run (default: unlimited)
--clear-after-download Erase device memory after successful download
--verbose Debug logging
```
### SFM REST server
Exposes device control and DB queries as a REST API. Proxied by terra-view.
```bash
python sfm/server.py # default: 0.0.0.0:8200
python -m uvicorn sfm.server:app --host 0.0.0.0 --port 8200 --reload
```
Open `http://localhost:8200` for the embedded web UI, or `http://localhost:8200/docs`
for the interactive API docs.
### Seismo Lab GUI
```bash
python seismo_lab.py
```
---
## SFM REST API
### Live device endpoints
Each call dials the device, does its work, and closes the connection. TCP
connections are retried once on `ProtocolError` to handle cold-boot timing.
**Caching** — frequently-polled endpoints are cached in-process to avoid
redundant TCP round-trips:
| Method | URL | Cache |
|--------|-----|-------|
| `GET` | `/device/info` | Indefinite; invalidated by `POST /device/config` |
| `GET` | `/device/events` | Count-probe fast path (~2s); full download only when new events detected |
| `GET` | `/device/event/{idx}/waveform` | Permanent per event index |
| `GET` | `/device/monitor/status` | 30-second TTL |
| `POST` | `/device/connect` | — |
| `POST` | `/device/config` | Writes compliance config; invalidates cache |
| `POST` | `/device/monitor/start` | Sends SUB 0x96 |
| `POST` | `/device/monitor/stop` | Sends SUB 0x97 |
All cached endpoints accept `?force=true` to bypass the cache.
Transport query params (supply one set):
```
Serial: ?port=COM5&baud=38400
TCP: ?host=1.2.3.4&tcp_port=12345
```
### DB read endpoints
Query the SQLite database written by `ach_server.py`. All read-only except
`PATCH /db/events/{id}/false_trigger`.
| Method | URL | Description |
|--------|-----|-------------|
| `GET` | `/db/units` | All known serials with summary stats |
| `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger) |
| `GET` | `/db/monitor_log` | Monitoring intervals |
| `GET` | `/db/sessions` | ACH call-home session history |
| `PATCH` | `/db/events/{id}/false_trigger?value=true` | Flag / unflag false triggers |
---
## minimateplus library
```python
from minimateplus import MiniMateClient
from minimateplus.transport import TcpTransport
# Serial
client = MiniMateClient(port="COM5")
# TCP (cellular modem)
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 12345), timeout=30.0)
with client:
# Read
info = client.connect() # DeviceInfo — serial, firmware, compliance config
count = client.count_events() # Number of stored events
keys = client.list_event_keys() # Fast browse walk — event keys only, no download
events = client.get_events() # Full download: headers + peaks + metadata
monitor = client.get_monitor_status() # Battery, memory, is_monitoring flag
log = client.get_monitor_log_entries() # Monitoring intervals (partial 0x2C records)
# Write
client.apply_config(
sample_rate=1024,
trigger_level_geo=0.5,
project="Bridge Inspection 2026",
client_name="City of Portland",
operator="B. Harrison",
)
# Control
client.start_monitoring() # SUB 0x96
client.stop_monitoring() # SUB 0x97
client.delete_all_events() # Erase all (SUB 0xA3 → 0x1C → 0x06 → 0xA2)
```
`get_events()` runs the full per-event sequence: `1E → 0A → 0C → 5A → 1F`.
SUB 5A bulk stream provides `client`, `operator`, and `sensor_location` as they
existed at record time — not backfilled from the current compliance config.
---
## Database
`ach_server.py` writes to `bridges/captures/seismo_relay.db` (SQLite, WAL mode).
Three tables, all unit-keyed by serial number:
| Table | Key | Contents |
|-------|-----|----------|
| `ach_sessions` | UUID | Per-call-home audit record: serial, peer IP, events_downloaded, duration |
| `events` | UUID, UNIQUE(serial, waveform_key) | Triggered events: timestamp, PPV per channel, project/client/operator strings, false_trigger flag |
| `monitor_log` | UUID, UNIQUE(serial, waveform_key) | Monitoring intervals: start/stop time, duration, geo threshold |
Deduplication is by `(serial, waveform_key)` — repeat call-homes or re-runs
never produce duplicate rows. Post-erase key reuse is handled automatically
via the high-water mark in `ach_state.json`.
---
## Connecting over cellular (RV50 / RV55)
Field units connect via Sierra Wireless RV50 or RV55 cellular modems.
### Required ACEmanager settings
| Setting | Value | Why |
|---------|-------|-----|
| Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate |
| Flow Control | `None` | Hardware FC blocks TX if pins unconnected |
| **Quiet Mode** | **Enable** | **Critical** — disabled injects `RING`/`CONNECT` onto serial, corrupting the S3 handshake |
| Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency |
| TCP Connect Response Delay | `0` | Non-zero silently drops the first POLL frame |
| TCP Idle Timeout | `2` (minutes) | Prevents premature disconnect |
| DB9 Serial Echo | `Disable` | Echo corrupts the data stream |
---
## Protocol quick-reference
| Term | Value | Meaning |
|------|-------|---------|
| DLE | `0x10` | Data Link Escape |
| STX | `0x02` | Start of frame |
| ETX | `0x03` | End of frame |
| ACK | `0x41` | Frame-start marker sent before every BW frame |
| DLE stuffing | `10 10` on wire | Literal `0x10` in payload |
**Response SUB rule:** `response_SUB = 0xFF - request_SUB` (no exceptions)
Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md)
---
## Requirements
```bash
pip install pyserial fastapi uvicorn
```
Python 3.10+. Tkinter is included with the standard Python installer on
Windows (check "tcl/tk and IDLE" during install).
---
## Virtual COM ports (bridge capture)
```
Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus
```
Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
---
## Roadmap
- [x] Full read pipeline — device info, compliance config, event download with true event-time metadata
- [x] Write commands — push compliance config, trigger thresholds, project strings to device
- [x] Erase all events — confirmed erase sequence from live MITM capture
- [x] Monitor control — start/stop monitoring, read battery/memory/status
- [x] Monitor log entries — decode partial 0x2C records (continuous monitoring intervals)
- [x] ACH inbound server — accept call-home connections, download events, dedup by key
- [x] SQLite persistence — events, monitor log, and session history in `seismo_relay.db`
- [x] SFM REST API — device control + DB query endpoints, live device cache
- [ ] Terra-view integration — seismo-relay router, unit detail page, VISON-style event listing
- [ ] Vibration summary reports — highest legit PPV per project → Word doc (false trigger filtering first)
- [ ] Compliance config encoder — build raw write payloads from a `ComplianceConfig` object
- [ ] Modem manager — push RV50/RV55 configs via Sierra Wireless API
# seismo-relay `v0.12.1`
A ground-up replacement for **Blastware** — Instantel's aging Windows-only
software for managing MiniMate Plus seismographs.
Built in Python. Runs on Windows, Linux, or macOS. Connects to instruments
over direct RS-232 or cellular modem (Sierra Wireless RV50 / RV55).
> **Status:** Active development. Full read + write + erase + monitoring
> pipeline working end-to-end over TCP/cellular. ACH Auto Call Home server
> handles inbound unit connections, downloads events, and persists everything
> to a SQLite database. SFM REST API exposes device control and DB queries.
> See [CHANGELOG.md](CHANGELOG.md) for full version history.
---
## What's in here
```
seismo-relay/
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs)
├── minimateplus/ ← MiniMate Plus client library
│ ├── transport.py ← SerialTransport, TcpTransport, SocketTransport
│ ├── protocol.py ← DLE frame layer, SUB command dispatch
│ ├── client.py ← High-level client (connect, get_events, push_config, …)
│ ├── framing.py ← Frame builders, DLE codec, S3FrameParser
│ └── models.py ← DeviceInfo, Event, ComplianceConfig, MonitorLogEntry, …
├── sfm/ ← SFM REST API server (FastAPI, port 8200)
│ ├── server.py ← All device + DB endpoints
│ ├── database.py ← SeismoDb — SQLite persistence layer
│ └── sfm_webapp.html ← Embedded web UI (served at /)
├── bridges/
│ ├── ach_server.py ← Inbound ACH call-home server (main production server)
│ ├── ach_mitm.py ← Transparent MITM proxy for capturing BW sessions
│ ├── s3-bridge/ ← RS-232 serial bridge (capture tool)
│ ├── tcp_serial_bridge.py ← Local TCP↔serial bridge (bench testing)
│ ├── gui_bridge.py ← Standalone bridge GUI
│ └── raw_capture.py ← Simple raw capture tool
├── parsers/
│ ├── s3_analyzer.py ← Session parser, differ, Claude export
│ ├── gui_analyzer.py ← Standalone analyzer GUI
│ └── frame_db.py ← SQLite frame database
└── docs/
└── instantel_protocol_reference.md ← Reverse-engineered protocol spec
```
---
## Quick start
### ACH inbound server (production)
Listens for inbound unit call-homes, downloads all new events and monitor log
entries, and writes everything to `bridges/captures/seismo_relay.db`.
```bash
python bridges/ach_server.py --port 12345 --output bridges/captures/
```
Point the unit's ACEmanager **Remote Host** to this machine's IP and **Remote Port** to `12345`.
Options:
```
--port N Listen port (default 12345)
--output DIR Capture directory (default bridges/captures/)
--allow-ip IP Allowlist an IP (repeat for multiple; default: accept all)
--max-events N Safety cap for first run (default: unlimited)
--clear-after-download Erase device memory after successful download
--verbose Debug logging
```
### SFM REST server
Exposes device control and DB queries as a REST API. Proxied by terra-view.
```bash
python sfm/server.py # default: 0.0.0.0:8200
python -m uvicorn sfm.server:app --host 0.0.0.0 --port 8200 --reload
```
Open `http://localhost:8200` for the embedded web UI, or `http://localhost:8200/docs`
for the interactive API docs.
### Seismo Lab GUI
```bash
python seismo_lab.py
```
---
## SFM REST API
### Live device endpoints
Each call dials the device, does its work, and closes the connection. TCP
connections are retried once on `ProtocolError` to handle cold-boot timing.
**Caching** — frequently-polled endpoints are cached in-process to avoid
redundant TCP round-trips:
| Method | URL | Cache |
|--------|-----|-------|
| `GET` | `/device/info` | Indefinite; invalidated by `POST /device/config` |
| `GET` | `/device/events` | Count-probe fast path (~2s); full download only when new events detected |
| `GET` | `/device/event/{idx}/waveform` | Permanent per event index |
| `GET` | `/device/monitor/status` | 30-second TTL |
| `POST` | `/device/connect` | — |
| `POST` | `/device/config` | Writes compliance config; invalidates cache |
| `POST` | `/device/monitor/start` | Sends SUB 0x96 |
| `POST` | `/device/monitor/stop` | Sends SUB 0x97 |
All cached endpoints accept `?force=true` to bypass the cache.
Transport query params (supply one set):
```
Serial: ?port=COM5&baud=38400
TCP: ?host=1.2.3.4&tcp_port=12345
```
### DB read endpoints
Query the SQLite database written by `ach_server.py`. All read-only except
`PATCH /db/events/{id}/false_trigger`.
| Method | URL | Description |
|--------|-----|-------------|
| `GET` | `/db/units` | All known serials with summary stats |
| `GET` | `/db/events` | Triggered events (filter by serial, date range, false_trigger) |
| `GET` | `/db/monitor_log` | Monitoring intervals |
| `GET` | `/db/sessions` | ACH call-home session history |
| `PATCH` | `/db/events/{id}/false_trigger?value=true` | Flag / unflag false triggers |
---
## minimateplus library
```python
from minimateplus import MiniMateClient
from minimateplus.transport import TcpTransport
# Serial
client = MiniMateClient(port="COM5")
# TCP (cellular modem)
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 12345), timeout=30.0)
with client:
# Read
info = client.connect() # DeviceInfo — serial, firmware, compliance config
count = client.count_events() # Number of stored events
keys = client.list_event_keys() # Fast browse walk — event keys only, no download
events = client.get_events() # Full download: headers + peaks + metadata
monitor = client.get_monitor_status() # Battery, memory, is_monitoring flag
log = client.get_monitor_log_entries() # Monitoring intervals (partial 0x2C records)
# Write
client.apply_config(
sample_rate=1024,
trigger_level_geo=0.5,
project="Bridge Inspection 2026",
client_name="City of Portland",
operator="B. Harrison",
)
# Control
client.start_monitoring() # SUB 0x96
client.stop_monitoring() # SUB 0x97
client.delete_all_events() # Erase all (SUB 0xA3 → 0x1C → 0x06 → 0xA2)
```
`get_events()` runs the full per-event sequence: `1E → 0A → 0C → 5A → 1F`.
SUB 5A bulk stream provides `client`, `operator`, and `sensor_location` as they
existed at record time — not backfilled from the current compliance config.
---
## Database
`ach_server.py` writes to `bridges/captures/seismo_relay.db` (SQLite, WAL mode).
Three tables, all unit-keyed by serial number:
| Table | Key | Contents |
|-------|-----|----------|
| `ach_sessions` | UUID | Per-call-home audit record: serial, peer IP, events_downloaded, duration |
| `events` | UUID, UNIQUE(serial, waveform_key) | Triggered events: timestamp, PPV per channel, project/client/operator strings, false_trigger flag |
| `monitor_log` | UUID, UNIQUE(serial, waveform_key) | Monitoring intervals: start/stop time, duration, geo threshold |
Deduplication is by `(serial, waveform_key)` — repeat call-homes or re-runs
never produce duplicate rows. Post-erase key reuse is handled automatically
via the high-water mark in `ach_state.json`.
---
## Connecting over cellular (RV50 / RV55)
Field units connect via Sierra Wireless RV50 or RV55 cellular modems.
### Required ACEmanager settings
| Setting | Value | Why |
|---------|-------|-----|
| Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate |
| Flow Control | `None` | Hardware FC blocks TX if pins unconnected |
| **Quiet Mode** | **Enable** | **Critical** — disabled injects `RING`/`CONNECT` onto serial, corrupting the S3 handshake |
| Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency |
| TCP Connect Response Delay | `0` | Non-zero silently drops the first POLL frame |
| TCP Idle Timeout | `2` (minutes) | Prevents premature disconnect |
| DB9 Serial Echo | `Disable` | Echo corrupts the data stream |
---
## Protocol quick-reference
| Term | Value | Meaning |
|------|-------|---------|
| DLE | `0x10` | Data Link Escape |
| STX | `0x02` | Start of frame |
| ETX | `0x03` | End of frame |
| ACK | `0x41` | Frame-start marker sent before every BW frame |
| DLE stuffing | `10 10` on wire | Literal `0x10` in payload |
**Response SUB rule:** `response_SUB = 0xFF - request_SUB` (no exceptions)
Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md)
---
## Requirements
```bash
pip install pyserial fastapi uvicorn
```
Python 3.10+. Tkinter is included with the standard Python installer on
Windows (check "tcl/tk and IDLE" during install).
---
## Virtual COM ports (bridge capture)
```
Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus
```
Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
---
## Roadmap
- [x] Full read pipeline — device info, compliance config, event download with true event-time metadata
- [x] Write commands — push compliance config, trigger thresholds, project strings to device
- [x] Erase all events — confirmed erase sequence from live MITM capture
- [x] Monitor control — start/stop monitoring, read battery/memory/status
- [x] Monitor log entries — decode partial 0x2C records (continuous monitoring intervals)
- [x] ACH inbound server — accept call-home connections, download events, dedup by key
- [x] SQLite persistence — events, monitor log, and session history in `seismo_relay.db`
- [x] SFM REST API — device control + DB query endpoints, live device cache
- [ ] Terra-view integration — seismo-relay router, unit detail page, VISON-style event listing
- [ ] Vibra
+838 -800
View File
File diff suppressed because it is too large Load Diff
+29 -34
View File
@@ -58,24 +58,16 @@ class BridgeGUI(tk.Tk):
tk.Entry(self, textvariable=self.logdir_var, width=24).grid(row=1, column=3, sticky="we", **pad)
tk.Button(self, text="Browse", command=self._choose_dir).grid(row=1, column=4, sticky="w", **pad)
# Row 2: Raw taps — ON by default; "auto" = timestamped name; blank checkbox = disabled
self.raw_bw_enabled = tk.IntVar(value=1)
self.raw_s3_enabled = tk.IntVar(value=1)
# Path fields: empty means "auto" (bridge picks a timestamped name)
self.raw_bw_path_var = tk.StringVar(value="")
self.raw_s3_path_var = tk.StringVar(value="")
# Row 2: Raw taps
self.raw_bw_var = tk.StringVar(value="")
self.raw_s3_var = tk.StringVar(value="")
tk.Checkbutton(self, text="Save BW->S3 raw", command=self._toggle_raw_bw, onvalue="1", offvalue="").grid(row=2, column=0, sticky="w", **pad)
tk.Entry(self, textvariable=self.raw_bw_var, width=28).grid(row=2, column=1, columnspan=3, sticky="we", **pad)
tk.Button(self, text="...", command=lambda: self._choose_file(self.raw_bw_var, "bw")).grid(row=2, column=4, **pad)
tk.Checkbutton(self, text="BW→S3 raw (auto)", variable=self.raw_bw_enabled,
command=self._toggle_raw_bw).grid(row=2, column=0, sticky="w", **pad)
tk.Entry(self, textvariable=self.raw_bw_path_var, width=28,
fg="grey").grid(row=2, column=1, columnspan=3, sticky="we", **pad)
tk.Button(self, text="...", command=lambda: self._choose_file(self.raw_bw_path_var, "bw")).grid(row=2, column=4, **pad)
tk.Checkbutton(self, text="S3→BW raw (auto)", variable=self.raw_s3_enabled,
command=self._toggle_raw_s3).grid(row=3, column=0, sticky="w", **pad)
tk.Entry(self, textvariable=self.raw_s3_path_var, width=28,
fg="grey").grid(row=3, column=1, columnspan=3, sticky="we", **pad)
tk.Button(self, text="...", command=lambda: self._choose_file(self.raw_s3_path_var, "s3")).grid(row=3, column=4, **pad)
tk.Checkbutton(self, text="Save S3->BW raw", command=self._toggle_raw_s3, onvalue="1", offvalue="").grid(row=3, column=0, sticky="w", **pad)
tk.Entry(self, textvariable=self.raw_s3_var, width=28).grid(row=3, column=1, columnspan=3, sticky="we", **pad)
tk.Button(self, text="...", command=lambda: self._choose_file(self.raw_s3_var, "s3")).grid(row=3, column=4, **pad)
# Row 4: Status + buttons
self.status_var = tk.StringVar(value="Idle")
@@ -110,11 +102,13 @@ class BridgeGUI(tk.Tk):
var.set(filename)
def _toggle_raw_bw(self) -> None:
# Checkbox toggled — no path action needed; enabled state drives the flag.
pass
if not self.raw_bw_var.get():
# default name
self.raw_bw_var.set(os.path.join(self.logdir_var.get(), "raw_bw.bin"))
def _toggle_raw_s3(self) -> None:
pass
if not self.raw_s3_var.get():
self.raw_s3_var.set(os.path.join(self.logdir_var.get(), "raw_s3.bin"))
def start_bridge(self) -> None:
if self.process and self.process.poll() is None:
@@ -132,22 +126,23 @@ class BridgeGUI(tk.Tk):
args = [sys.executable, BRIDGE_PATH, "--bw", bw, "--s3", s3, "--baud", baud, "--logdir", logdir]
# Raw tap flags.
# Checkbox on + empty path → pass "auto" (bridge generates timestamped name).
# Checkbox on + explicit path → pass that path.
# Checkbox off → pass "" to disable (overrides bridge's auto default).
raw_bw_explicit = self.raw_bw_path_var.get().strip()
raw_s3_explicit = self.raw_s3_path_var.get().strip()
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
if self.raw_bw_enabled.get():
args += ["--raw-bw", raw_bw_explicit if raw_bw_explicit else "auto"]
else:
args += ["--raw-bw", ""] # explicit disable
raw_bw = self.raw_bw_var.get().strip()
raw_s3 = self.raw_s3_var.get().strip()
if self.raw_s3_enabled.get():
args += ["--raw-s3", raw_s3_explicit if raw_s3_explicit else "auto"]
else:
args += ["--raw-s3", ""] # explicit disable
# If the user left the default generic name, replace with a timestamped one
# so each session gets its own file.
if raw_bw:
if os.path.basename(raw_bw) in ("raw_bw.bin", "raw_bw"):
raw_bw = os.path.join(os.path.dirname(raw_bw) or logdir, f"raw_bw_{ts}.bin")
self.raw_bw_var.set(raw_bw)
args += ["--raw-bw", raw_bw]
if raw_s3:
if os.path.basename(raw_s3) in ("raw_s3.bin", "raw_s3"):
raw_s3 = os.path.join(os.path.dirname(raw_s3) or logdir, f"raw_s3_{ts}.bin")
self.raw_s3_var.set(raw_s3)
args += ["--raw-s3", raw_s3]
try:
self.process = subprocess.Popen(
+13 -88
View File
@@ -93,11 +93,8 @@ class SessionLogger:
self._bin_fh = open(bin_path, "ab", buffering=0)
self._lock = threading.Lock()
# Optional pure-byte taps (no headers). BW=Blastware tx, S3=device tx.
# These can be opened/closed on demand via start_raw_capture/stop_raw_capture.
self._raw_bw = open(raw_bw_path, "ab", buffering=0) if raw_bw_path else None
self._raw_s3 = open(raw_s3_path, "ab", buffering=0) if raw_s3_path else None
self._cap_bw_path: Optional[str] = raw_bw_path
self._cap_s3_path: Optional[str] = raw_s3_path
def log_line(self, line: str) -> None:
with self._lock:
@@ -127,43 +124,6 @@ class SessionLogger:
self.log_line(f"[{ts}] [INFO] {msg}")
self.bin_write_record(REC_INFO, msg.encode("utf-8", errors="replace"))
def start_raw_capture(self, label: str, logdir: str) -> tuple:
"""Open new raw tap files for a named capture. Returns (bw_path, s3_path)."""
ts = _dt.datetime.now().strftime("%Y%m%d_%H%M%S")
safe = "".join(c if c.isalnum() or c in "-_" else "_" for c in label)[:40] if label else ""
suffix = f"_{safe}" if safe else ""
bw_path = os.path.join(logdir, f"raw_bw_{ts}{suffix}.bin")
s3_path = os.path.join(logdir, f"raw_s3_{ts}{suffix}.bin")
with self._lock:
# Close any previously open taps first
if self._raw_bw:
self._raw_bw.close()
if self._raw_s3:
self._raw_s3.close()
self._raw_bw = open(bw_path, "ab", buffering=0)
self._raw_s3 = open(s3_path, "ab", buffering=0)
self._cap_bw_path = bw_path
self._cap_s3_path = s3_path
self.log_info(f"raw capture started: label={label!r} bw={bw_path} s3={s3_path}")
return bw_path, s3_path
def stop_raw_capture(self) -> tuple:
"""Close raw tap files. Returns (bw_path, s3_path) for the capture just closed."""
with self._lock:
bw = self._cap_bw_path
s3 = self._cap_s3_path
if self._raw_bw:
self._raw_bw.close()
self._raw_bw = None
if self._raw_s3:
self._raw_s3.close()
self._raw_s3 = None
self._cap_bw_path = None
self._cap_s3_path = None
if bw:
self.log_info(f"raw capture stopped: bw={bw} s3={s3}")
return bw, s3
def close(self) -> None:
with self._lock:
try:
@@ -331,18 +291,8 @@ def forward_loop(
time.sleep(0.002)
def annotation_loop(logger: SessionLogger, logdir: str, stop: threading.Event) -> None:
"""
Reads stdin commands while the bridge runs.
Commands:
m prompt for a mark label (interactive)
CAP_START:<label> begin a raw tap capture with the given label
CAP_STOP stop the current raw tap capture
Responses (printed to stdout, parsed by the GUI):
[CAP_START] <bw_path>\\t<s3_path>
[CAP_STOP] <bw_path>\\t<s3_path>
"""
def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None:
print("[MARK] Type 'm' + Enter to annotate the capture. Ctrl+C to stop.")
while not stop.is_set():
try:
line = input()
@@ -353,21 +303,7 @@ def annotation_loop(logger: SessionLogger, logdir: str, stop: threading.Event) -
if not line:
continue
if line.startswith("CAP_START:"):
label = line[10:].strip()
bw_path, s3_path = logger.start_raw_capture(label, logdir)
print(f"[CAP_START] {bw_path}\t{s3_path}")
sys.stdout.flush()
elif line == "CAP_STOP":
bw_path, s3_path = logger.stop_raw_capture()
if bw_path:
print(f"[CAP_STOP] {bw_path}\t{s3_path}")
else:
print("[CAP_STOP] no active capture")
sys.stdout.flush()
elif line.lower() == "m":
if line.lower() == "m":
try:
sys.stdout.write(" Label: ")
sys.stdout.flush()
@@ -379,9 +315,8 @@ def annotation_loop(logger: SessionLogger, logdir: str, stop: threading.Event) -
print(f" [MARK written] {label}")
else:
print(" (empty label — mark cancelled)")
else:
print(f" (unknown command: {line!r})")
print(" (type 'm' + Enter to annotate)")
def main() -> int:
@@ -390,14 +325,8 @@ def main() -> int:
ap.add_argument("--s3", default="COM5", help="S3-side COM port (default: COM5)")
ap.add_argument("--baud", type=int, default=38400, help="Baud rate (default: 38400)")
ap.add_argument("--logdir", default=".", help="Directory to write session logs into (default: .)")
ap.add_argument("--raw-bw", default="auto",
help="File to append raw bytes sent from BW->S3 (no headers). "
"Default 'auto' generates a timestamped name in --logdir. "
"Pass an empty string to disable.")
ap.add_argument("--raw-s3", default="auto",
help="File to append raw bytes sent from S3->BW (no headers). "
"Default 'auto' generates a timestamped name in --logdir. "
"Pass an empty string to disable.")
ap.add_argument("--raw-bw", default=None, help="Optional file to append raw bytes sent from BW->S3 (no headers)")
ap.add_argument("--raw-s3", default=None, help="Optional file to append raw bytes sent from S3->BW (no headers)")
ap.add_argument("--quiet", action="store_true", help="No console heartbeat output")
ap.add_argument("--status-every", type=float, default=0.0, help="Seconds between console heartbeat lines (default: 0 = off)")
args = ap.parse_args()
@@ -420,16 +349,12 @@ def main() -> int:
# If raw tap flags were passed without a path (bare --raw-bw / --raw-s3),
# or if the sentinel value "auto" is used, generate a timestamped name.
# If a specific path was provided, use it as-is (caller's responsibility).
# Resolve raw tap paths.
# "auto" (default) → timestamped file in logdir (always captured).
# Explicit path → use verbatim.
# None or "" → disabled (pass --raw-bw "" to suppress capture).
raw_bw_path: Optional[str] = args.raw_bw if args.raw_bw else None
raw_s3_path: Optional[str] = args.raw_s3 if args.raw_s3 else None
if raw_bw_path == "auto":
raw_bw_path = os.path.join(args.logdir, f"raw_bw_{ts}.bin")
if raw_s3_path == "auto":
raw_s3_path = os.path.join(args.logdir, f"raw_s3_{ts}.bin")
raw_bw_path = args.raw_bw
raw_s3_path = args.raw_s3
if raw_bw_path in (None, "", "auto"):
raw_bw_path = os.path.join(args.logdir, f"raw_bw_{ts}.bin") if args.raw_bw is not None else None
if raw_s3_path in (None, "", "auto"):
raw_s3_path = os.path.join(args.logdir, f"raw_s3_{ts}.bin") if args.raw_s3 is not None else None
logger = SessionLogger(log_path, bin_path, raw_bw_path=raw_bw_path, raw_s3_path=raw_s3_path)
@@ -466,7 +391,7 @@ def main() -> int:
t_ann = threading.Thread(
target=annotation_loop,
name="Annotator",
args=(logger, args.logdir, stop),
args=(logger, stop),
daemon=True,
)
+29 -507
View File
@@ -36,7 +36,7 @@
| 2026-03-02 | §7.4 Event Index Block | **NEW:** `Monitoring LCD Cycle` identified at offsets +84/+85 as uint16 BE. Default value = 65500 (0xFFDC) = effectively disabled / maximum. Confirmed from operator manual §3.13.1g. |
| 2026-03-02 | §7.4 Event Index Block | **UPDATED:** Backlight confirmed as uint8 range 0255 seconds per operator manual §3.13.1e ("adjustable timer, 0 to 255 seconds"). Power save unit confirmed as minutes per operator manual §3.13.1f. |
| 2026-03-02 | Global | **NEW SOURCE:** Operator manual (716U0101 Rev 15) added as reference. Cross-referencing settings definitions, ranges, and units. Header updated. |
| 2026-03-02 | §14 Open Questions | Float 6.2061 in/s mystery: manual confirms only two geo ranges (1.25 in/s and 10.0 in/s). 6.2061 is NOT a user-selectable range → originally speculated as internal ADC full-scale constant, but was NOT confirmed at this time. Using it directly as the range produces ~9× PPV overread. Meaning unknown. Downgraded to LOW 2026-03-02, re-escalated to HIGH 2026-04-16. **RESOLVED 2026-04-17 — see §7.6.2 and changelog entry.** |
| 2026-03-02 | §14 Open Questions | Float 6.2061 in/s mystery: manual confirms only two geo ranges (1.25 in/s and 10.0 in/s). 6.2061 is NOT a user-selectable range → likely internal ADC full-scale calibration constant or hardware range ceiling. Downgraded to LOW priority. |
| 2026-03-02 | §14 Open Questions | `0x082A` hypothesis refined: 2090 decimal. At 1024 sps, 2 sec record = 2048 samples. Possible that 0x082A = total samples including 0.25s pre-trigger (256 samples) at some adjusted rate. Needs capture with different record time. |
| 2026-03-02 | §14 Open Questions | **NEW items added:** Trigger sample width (default=2), Auto Window (1-9 sec), Aux Trigger (enabled/disabled) — all confirmed settings from operator manual not yet mapped in protocol. |
| 2026-03-02 | §14 Open Questions | Monitoring LCD Cycle resolved — removed from open questions. |
@@ -92,7 +92,7 @@
| 2026-04-06 | §7.8.4 | **NEW — 5A end-of-stream signalling confirmed.** After streaming all waveform chunks, the device sends exactly **1 raw byte** in response to the next chunk request, then goes silent for the full recv timeout. This byte is NOT a complete DLE-framed A5 response — the frame parser accumulates it as `bytes_fed=1` and never assembles a frame. This is the device's natural end-of-stream signal. Handling: on TimeoutError, if `bytes_fed > 0` AND prior chunks were received, treat as graceful end and proceed to the termination frame. A `bytes_fed=0` timeout with no prior chunks is a genuine transport failure and must still raise. |
| 2026-04-06 | §7.8.4 | **NEW — 5A chunk timing and count (empirical, BE11529 at 1024 sps).** Each chunk response arrives within ~1 second over TCP/cellular. A 9,306-sample event (≈9.1 s at 1024 sps) produces **35 chunks** before end-of-stream. Chunks 116 have varying data lengths (10361123 bytes); chunks 1735 are uniformly 1036 bytes each (post-event silence, all-zero ADC samples). Safe recv timeout for chunk loop: **10 s** (10× typical response time). Default transport timeout (120 s) results in a ~2-minute stall per event at end-of-stream. |
| 2026-04-06 | §7.8.3 | **KNOWN ISSUE — `_decode_a5_waveform` hardcoded fi==9 skip.** The decoder contains `elif fi == 9: continue` which was written for the 9-frame original blast capture where frame 9 was a device terminator. For streams with >9 frames (current device produces 35+), frame index 9 is live waveform data — this skip discards ~1,070 bytes (~133 sample-sets) per event. The terminator is now detected via `page_key == 0x0000`, not by frame index. The fi==9 skip should be removed. |
| 2026-04-06 | §7.8 | **⚠ PARTIALLY INVALIDATED — ADC count-to-physical-unit conversion.** Raw waveform samples are signed 16-bit integers (counts). Conversion formula `value = counts × (range / 32767)` is believed correct, but the `range` value was UNKNOWN at time of writing. **UPDATED 2026-04-17:** `max_range_geo` = 6.206053 is confirmed as the ADC-to-velocity scale factor (inverse sensitivity, (in/s)/V). The correct conversion is therefore: `PPV (in/s) = counts × (1.61133 / 32767) × 6.206053` = `counts × 4.982e-5` in/s per count. The earlier ~9× overread from using 6.206053 directly as the range was because the range IS 1.61133 × 6.206053 = 10.000 in/s, not 6.206053. See §7.6.2 for the confirmed field layout. |
| 2026-04-06 | §7.8 | **CONFIRMED — ADC count-to-physical-unit conversion.** Raw waveform samples are signed 16-bit integers (counts). Conversion: `value = counts × (range / 32767)`. For geo channels: range = 10.000 in/s (from the device's compliance config geo range field). For the mic channel: range is in psi (device-specific). Near-full-scale counts (≈32,700) on all four channels simultaneously indicate ADC saturation (clipping) from a high-amplitude event. |
| 2026-04-08 | §5.1, §7.10, §12 | **NEW — Monitoring commands confirmed.** SUB 0x1C (monitor status), 0x96 (start monitoring), 0x97 (stop monitoring) all confirmed from 4-8-26/2ndtry capture. SESSION_RESET (`41 03`) required before POLL to wake a monitoring unit. |
| 2026-04-09 | §7.10 | **CORRECTED — monitoring flag and battery/memory offsets.** `section[1] == 0x10` is the monitoring flag (100% accurate across 144 data frames in 2ndtry capture). Previous note claiming `section[6]` was wrong — section[6] has device-specific non-binary values (0xea/0x07). Battery/memory offsets corrected: `section[-10:-8]` (battery×100), `section[-8:-4]` (memory_total), `section[-4:]` (memory_free). NOTE: `frame.data` has checksum stripped by parser — earlier offsets of `[-11:-9]`/`[-9:-5]`/`[-5:-1]` were wrong because they assumed a trailing checksum byte that isn't there. |
| 2026-04-08 | §7.10 | **NEW — SUBs 0x0E (channel sensor data) and 0x98 (trigger test) observed** in 4-8-26/sensor-check capture (Blastware "Unit Channel Test" comms check). SUB 0x0E: 2-step read with channel selector in `params[6:8]`, data length 0x0A per channel, RSP SUB = 0xF1. SUB 0x98: single probe frame with `params[0] = 0xFF`, RSP SUB = 0x67; sent twice per test cycle. Not yet implemented in SFM. |
@@ -103,13 +103,6 @@
| 2026-04-11 | §5.1 | **CONFIRMED — SUB 0x06 (CHANNEL CONFIG READ) now confirmed as event storage range.** Two-step read, data offset = 0x24 (36 bytes). Token=0xFE at params[7]. Last 8 bytes of response: first stored event key (bytes 8:4) and last stored event key (bytes 4:). Both equal `01110000` when device memory is empty. Used by Blastware to verify erase completion. |
| 2026-04-11 | §7.11 (NEW) | **NEW — §7.11 Erase-All Protocol added.** Full wire sequence, SUB 0x06 storage range payload layout, post-erase key counter reset (resets to `0x01110000`). Confirmed from 4-11-26 MITM capture of live Blastware ACH session. |
| 2026-04-11 | §14.6 | **RESOLVED — ACH Session Lifecycle is no longer "Future".** `bridges/ach_server.py` fully implements inbound ACH: POLL handshake, device info, event download. State tracked via `ach_state.json` (key-based, with `max_downloaded_key` for post-erase detection). `--clear-after-download` flag added for the standard delete-after-upload workflow. |
| 2026-04-17 | §7.6.2, §14 | **RESOLVED — Float 6.206053 at channel_label+28 is the ADC-to-velocity scale factor.** Confirmed from Series III Interface Handbook §4.5 formula: `Range (×1) = 1.61133 V / Sensitivity (V/unit)`. For the standard Instantel geophone at Normal range (10.000 in/s): Sensitivity = 1.61133 / 10 = 0.161133 V/(in/s). The stored value is the **inverse sensitivity** = 1/0.161133 = **6.206053 (in/s)/V**. Cross-check: 1.61133 V × 6.206053 = 10.000 in/s ✅. The firmware uses it as: `PPV (in/s) = ADC_voltage (V) × 6.206053`. Value is identical on all Instantel standard geophones — it is a hardware/firmware constant, NOT a user-configurable setting. Do NOT write this field. Open question §14 item "Max Geo Range float 6.2061" is now **RESOLVED**. |
| 2026-04-20 | §7.6.4 (NEW), §7.9, Appendix B | **CONFIRMED — Recording Mode byte location.** Three targeted captures (4-20-26) confirmed `recording_mode` at anchor8 in both the E5 read payload and the BW write payload (6-byte anchor `\xbe\x80\x00\x00\x00\x00`). BW write payload and E5 read payload are **byte-identical** around the anchor region — Blastware round-trips the wire-encoded E5 bytes verbatim with only the target field modified. Anchor position varies by ±1 depending on whether recording_mode = 0x03 (Histogram), because E5 wire-encodes `0x03` as the inner DLE+ETX pair `\x10\x03` (2 bytes), which S3FrameParser preserves as two literal bytes in `compliance_raw`. Enum: `0x00`=Single Shot, `0x01`=Continuous, `0x03`=Histogram, `0x04`=Histogram+Continuous. `0x02` value not yet observed. The byte at anchor9 is `0x00` for Single Shot / Continuous, and `0x10` for Histogram (DLE prefix from E5 encoding) and Histogram+Continuous (actual config byte). See §7.6.4 for full details. |
| 2026-04-21 | Appendix D (NEW) | **NEW — Blastware .N00 and .MLG file formats fully decoded.** `minimateplus/blastware_file.py` implements `write_n00()` and `write_mlg()`. N00 file format confirmed: 22B header + 21B STRT record + variable body + 26B footer. Body reconstructed from A5 bulk waveform stream frames with per-frame skip amounts (probe=7+strt_pos+21, A5[1]=13, A5[2+]=12, terminator=11) and DLE strip rule (strip `0x10` before `{0x02,0x03,0x04}`, keep following byte). Footer extracted verbatim from terminator frame's last 26 bytes. Split-pair edge case: when `frame.data[-1]==0x10` and `chk_byte∈{0x02,0x03,0x04}`, reunite both bytes before stripping and always remove trailing chk_byte (`stripped[:-1]`) — chk_byte is checksum, not payload. STRT record must be copied verbatim from A5[0]; bytes [10:20] are device-specific and cannot be reconstructed from Event fields. `write_n00` verified byte-perfect against `M529LIY6.N00` from 4-3-26-multi_event capture. MLG format: 308B header + N×292B records; CRC algorithm unknown (write as 0x0000). |
| 2026-04-21 | Appendix D §D.5 (NEW) | **NEW — Blastware filename encoding fully decoded.** Serial prefix: `chr(ord('B') + floor(serial/1000))` + last 3 digits zero-padded. Stem: 4-char base-36 of `floor(total_seconds/1296)`. Extension: `AB0` for manual/direct downloads (3 chars), `AB0W` or `AB0H` for ACH/call-home downloads (4 chars), where `AB` = 2-char base-36 of `total_seconds % 1296` and W/H = waveform/histogram. Epoch = 1985-01-01 00:00:00 device local time. Confirmed against 3,248 files from 10-year production archive with zero errors. 3-day cycle property: same daily recording time cycles through 3 extensions (864s/day shift, period=3 days). `blastware_filename(event, serial, ach=False)` implements full formula. |
| 2026-04-21 | §7.6.2, §5.3 | **CORRECTED — compliance_raw contains wire-encoded bytes, NOT logical bytes.** S3FrameParser appends DLE+ETX inner-frame pairs as two literal bytes to the frame body. Any `0x03` values in the compliance config appear in `compliance_raw` as `\x10\x03` (two bytes), not as a single `0x03`. The previous claim "S3FrameParser handles this transparently so compliance_raw contains logical (destuffed) bytes" was wrong. Consequence: `compliance_raw` is the wire-encoded E5 payload; anchor-relative reads work correctly because the anchor position automatically accounts for any DLE-encoded bytes before it. For write-back, round-tripping `compliance_raw` verbatim sends the correct wire bytes to the device. **DLE ETX escaping in write frames:** Blastware escapes `0x03` bytes in write frame data as `\x10\x03` on wire; our `build_bw_write_frame` does not (writes data raw). Device is confirmed to accept raw writes for all tested modes — likely uses the offset/length field for write frame framing, not ETX scanning. |
| 2026-04-20 | §7.6.2, §7.9, Appendix B | **CONFIRMED — Geophone maximum range / sensitivity selector byte location.** Two targeted captures (4-20-26, geo sensitivity folder): one at Normal 10.000 in/s, one at Sensitive 1.250 in/s. E5 read payload diff: exactly 3 bytes differ at channel_label+33 for Tran/Vert/Long. Values: `0x00`=Normal 10.000 in/s, `0x01`=Sensitive 1.250 in/s. Same offset applies to the SUB 71 write payload (which is the same 2126-byte E5-format buffer round-tripped verbatim). **`channel_label+20` reads `0x01` in ALL captures regardless of range setting — it is NOT this field.** Previous hypothesis (uint8 at Tran+20, 0x01=Normal) was WRONG. Stored as `geo_range` in `ComplianceConfig`. Encoded to all three geo channel blocks (Tran/Vert/Long) at label+33. |
| 2026-04-20 | §5.1, §5.3, §7.12 (NEW) | **NEW — Auto Call Home config protocol confirmed from 4-20-26 call home settings captures.** SUB 0x2C (Call Home Config READ, response 0xD3, data offset 0x7C=124) and SUB 0x7E/0x7F (WRITE + CONFIRM, response 0x81/0x80) confirmed. Write payload = read payload (125 bytes) + `\x00\x00` (127 bytes total). **DLE-escaped ETX at raw[117:119]:** the device returns logical value 0x03 (num_retries=3) as `\x10\x03` on the wire — S3FrameParser preserves both bytes as two literals, causing a +1 byte shift for all subsequent fields. Write frame sends these bytes verbatim (device interprets `\x10\x03` as literal value 3). Field map confirmed from 10-frame BW TX diff. See §7.12 for full layout. |
---
@@ -264,14 +257,13 @@ Step 4 — Device sends actual data payload:
| `24` | **WAVEFORM PAGE A?** | Paged waveform read, possibly channel group A. | 🔶 INFERRED |
| `25` | **WAVEFORM PAGE B?** | Paged waveform read, possibly channel group B. | 🔶 INFERRED |
| `09` | **UNKNOWN READ A** | Read command, response (`F6`) returns 0xCA (202) bytes. Purpose unknown. | 🔶 INFERRED |
| `1A` | **COMPLIANCE CONFIG READ** | Multi-step sequence (A+B+C+D frames). Response (E5) carries recording_mode (uint8 at anchor4 in E5 sf1), sample_rate (uint16 BE at anchor2), record_time (float32 BE at anchor+10), trigger/alarm/max_range floats, and project strings. Anchor: `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00`, search cfg[0:150]. Total ~2126 cfg bytes. See §7.6.4 for recording_mode enum. | ✅ CONFIRMED 2026-04-02; recording_mode added 2026-04-20 |
| `1A` | **COMPLIANCE CONFIG READ** | Multi-step sequence (A+B+C+D frames). Response (E5) carries sample_rate (uint16 BE at anchor2), record_time (float32 BE at anchor+10), trigger/alarm/max_range floats, and project strings. Anchor: `\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00`, search cfg[0:150]. Total ~2126 cfg bytes. | ✅ CONFIRMED 2026-04-02 |
| `2E` | **UNKNOWN READ B** | Read command, response (`D1`) returns 0x1A (26) bytes. Purpose unknown. | 🔶 INFERRED |
| `0E` | **CHANNEL SENSOR DATA** | Real-time sensor reading for one channel. Two-step read, data length 0x0A (10 bytes). Channel selector in params[6:8] (0x00000x0007 for 8 channels). Response (F1) carries amplitude, frequency, overswing data for that channel. Used by Blastware "Unit Channel Test" comms check. | ✅ CONFIRMED 2026-04-08 |
| `98` | **TRIGGER TEST** | Trigger-test command. Single probe frame; `params[0] = 0xFF`. Response (0x67) is all-zero data. Sent twice per Blastware comms-check cycle. Not a full POLL, no monitor state change. | ✅ CONFIRMED 2026-04-08 |
| `1C` | **MONITOR STATUS READ** | Two-step read, data offset 0x2C (44 bytes). `section[1] == 0x10` → monitoring; `0x00` → idle (CONFIRMED 2026-04-09, 100% accuracy on 144 frames). Payload length: 4647 bytes IDLE, 4849 bytes MONITORING. `frame.data` has checksum stripped — no trailing byte to skip. Battery/memory at end: `section[-10:-8]` = battery×100 (uint16 BE), `section[-8:-4]` = memory_total (uint32 BE), `section[-4:]` = memory_free (uint32 BE). | ✅ CONFIRMED 2026-04-09 |
| `96` | **START MONITORING** | Single write frame, no data payload. Transitions unit from idle to monitoring mode (after optional on-device sensor check ~40 s). | ✅ CONFIRMED 2026-04-08 |
| `97` | **STOP MONITORING** | Single write frame, no data payload. Stops monitoring, unit returns to idle. | ✅ CONFIRMED 2026-04-08 |
| `2C` | **CALL HOME CONFIG READ** | Two-step read, data offset 0x7C (124 bytes + 1-byte DLE artefact = 125 raw bytes). Returns Auto Call Home configuration: enable flag, dial string, scheduled call times, retry settings, modem timing. Response SUB = 0xD3. **DLE note:** logical value 0x03 (num_retries) is returned as `\x10\x03` on the wire, which S3FrameParser preserves as two literal bytes — this shifts all subsequent field positions by +1. See §7.12 for full field map. | ✅ CONFIRMED 2026-04-20 |
| `A3` | **ERASE ALL BEGIN** | Single frame, token=0xFE at params[7]. Initiates device memory erase. Must be followed by 0x1C probe+data + 0x06 probe+data + 0xA2 to complete. Standard `build_bw_frame` (not write-format). Response ack SUB = 0x5C. | ✅ CONFIRMED 2026-04-11 |
| `A2` | **ERASE ALL CONFIRM** | Single frame, token=0xFE at params[7]. Commits the erase initiated by 0xA3. After this ack (SUB 0x5D), device memory is cleared and the event counter resets to `0x01110000`. | ✅ CONFIRMED 2026-04-11 |
@@ -301,7 +293,6 @@ All requests use CMD byte `0x02`. All responses use CMD byte `0x10 0x02` (which,
| `98` | `67` | ✅ CONFIRMED 2026-04-08 |
| `96` | `69` | ✅ CONFIRMED 2026-04-08 |
| `97` | `68` | ✅ CONFIRMED 2026-04-08 |
| `2C` | `D3` | ✅ CONFIRMED 2026-04-20 |
| `A3` | `5C` | ✅ CONFIRMED 2026-04-11 |
| `A2` | `5D` | ✅ CONFIRMED 2026-04-11 |
@@ -323,8 +314,6 @@ Write commands are initiated by Blastware (`BW->S3`) and use SUB bytes in the `0
| `72` | **WRITE CONFIRM A** | Short frame, no data. Likely commit/confirm step after `71`. | `8D` | ✅ CONFIRMED |
| `73` | **WRITE CONFIRM B** | Short frame, no data. | `8C` | ✅ CONFIRMED |
| `74` | **WRITE CONFIRM C** | Short frame, no data. | `8B` | ✅ CONFIRMED |
| `7E` | **CALL HOME CONFIG WRITE** | Writes Auto Call Home configuration (127 bytes: 125-byte read payload + `\x00\x00`). Offset = data[1]+2 = 0x7E. Write format (DLE-aware checksum, only BW_CMD `0x10` doubled on wire). Response SUB = 0x81. Must be followed by SUB 0x7F confirm. | `81` | ✅ CONFIRMED 2026-04-20 |
| `7F` | **CALL HOME WRITE CONFIRM** | Short frame, no data. Commits call home config write from SUB 0x7E. Response SUB = 0x80. | `80` | ✅ CONFIRMED 2026-04-20 |
| `82` | **TRIGGER CONFIG WRITE** | Writes trigger config block (0x1C bytes, mirrors SUB `1C` read). | `7D` | ✅ CONFIRMED |
| `83` | **TRIGGER WRITE CONFIRM** | Short frame, no data. Likely commit step after `82`. | `7C` | ✅ CONFIRMED |
@@ -338,8 +327,6 @@ Write commands are initiated by Blastware (`BW->S3`) and use SUB bytes in the `0
| `72` | `8D` |
| `73` | `8C` |
| `74` | `8B` |
| `7E` | `81` |
| `7F` | `80` |
| `82` | `7D` |
| `83` | `7C` |
@@ -541,7 +528,7 @@ The SUB `1A` read response (`E5`) and SUB `71` write block contain per-channel t
| Field | Example bytes | Decoded | Certainty |
|---|---|---|---|
| `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED |
| ADC scale factor | `40 C6 97 FD` | **6.206053 (in/s)/V — CONFIRMED 2026-04-17.** This is the inverse sensitivity of the standard Instantel geophone = 1/0.161133. Interface Handbook §4.5: `Range = 1.61133 V × 6.206053 = 10.000 in/s`. Used by firmware: `PPV (in/s) = ADC_voltage × 6.206053`. Hardware constant — do NOT write. | ✅ CONFIRMED |
| Max range float | `40 C6 97 FD` | 6.206 — full-scale range in in/s | 🔶 INFERRED |
| `[00 00]` | `00 00` | Separator / padding | 🔶 INFERRED |
| **Trigger level** | `3F 19 99 9A` | **0.600 in/s** — IEEE 754 BE float | ✅ CONFIRMED |
| Unit string | `69 6E 2E 00` | `"in.\0"` | ✅ CONFIRMED |
@@ -633,53 +620,6 @@ The sample rate bytes sit immediately before a `0x10` (DLE) prefix byte in the r
---
### 7.6.4 Recording Mode
> ✅ **CONFIRMED — 2026-04-20** (BE11529 / firmware S338.17). Three targeted captures in a single Blastware session (4-20-26 directory), changing Recording Mode only between each write.
Recording mode is stored as a **uint8** with different anchor-relative positions depending on whether you are reading from a device response or constructing a write payload.
**In the SUB 71 write payload (3-chunk compliance write, `cfg[5]`):**
| Enum | Mode |
|---|---|
| `0x00` | Single Shot |
| `0x01` | Continuous |
| `0x02` | Unknown (not yet observed) |
| `0x03` | Histogram |
| `0x04` | Histogram + Continuous (combined mode) |
Anchor-relative position: **anchor 3** (3 bytes before the 10-byte anchor in the write payload). The write payload layout in the region around the anchor:
```
cfg[anchor - 3] = recording_mode (uint8)
cfg[anchor - 2] = sample_rate_hi (uint8, MSB of uint16 BE)
cfg[anchor - 1] = sample_rate_lo (uint8, LSB of uint16 BE)
cfg[anchor:anchor+10] = \x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00 ← anchor
cfg[anchor + 10:anchor + 14] = record_time (float32 BE)
```
**In the E5 read response (sub-frame 1, page=`0x0010`, `data[17]`):**
The anchor appears at `data[21]` in this sub-frame. Recording mode is at `data[17]` = **anchor 4** (one position earlier than in the write payload). This is because an extra `0x10` byte is present at `data[18]` in the read format (between recording_mode and sample_rate), which is NOT present in the write payload. The read-format layout:
```
data[17] = recording_mode (uint8)
data[18] = 0x10 ← extra byte present in E5 read only; absent in SUB 71 write
data[19] = sample_rate_hi (uint8, MSB of uint16 BE)
data[20] = sample_rate_lo (uint8, LSB of uint16 BE)
data[21:31] = anchor (\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00)
data[31:35] = record_time (float32 BE)
```
**Chunk checksum at `cfg[1024]`:** The first of the three SUB 71 write chunks (1027 bytes) contains a running checksum byte at `cfg[1024]` whose delta exactly equals the delta of `cfg[5]` (recording_mode). This byte reflects the cumulative change from `recording_mode` through to its position and should not be mistaken for a second copy of the recording_mode field.
**Decode path (`_decode_compliance_config_into`):** use `data[anchor_pos - 4]` where `anchor_pos` is the index of the first byte of the anchor in the assembled E5 cfg bytes.
**Encode path (`_encode_compliance_config`):** use `cfg[anchor_pos - 3]` = recording_mode value (write-payload offset; no extra `0x10` byte).
---
### 7.7 Blastware `.set` File Format
> 🔶 **INFERRED — 2026-03-01** from `Standard_Recording_Setup.set` cross-referenced against known wire payloads.
@@ -715,7 +655,7 @@ offset size type value (Tran example) meaning
+10 2 uint16 0x0015 = 21 unknown
+12 4 bytes 03 02 04 01 flags (recording mode etc.)
+16 4 uint32 0x00000003 record time in seconds ✅ CONFIRMED
+1A 4 float32 6.206053 ✅ CONFIRMED 2026-04-17 — ADC-to-velocity scale factor (= 1/sensitivity = (in/s)/V). Interface Handbook §4.5: Range = 1.61133 V × 6.206053 = 10.000 in/s (Normal range). Firmware uses: PPV (in/s) = ADC_voltage × 6.206053. Hardware constant — identical on all tested units. Do NOT write.
+1A 4 float32 6.2061 max range (in/s for geo, psi for mic)
+1E 2 00 00 padding
+20 4 float32 0.6000 trigger level ✅ CONFIRMED
+24 4 char[4] "in.\0" / "psi\0" unit string (geo vs mic)
@@ -1231,52 +1171,26 @@ Two critical differences from `build_bw_frame`:
| Frame | offset_word | counter | params | Purpose |
|---|---|---|---|---|
| Probe | `0x1004` | `0x0000` | 10 bytes (`bulk_waveform_params(0)`) | Initiate transfer |
| Chunk 1 | `0x1004` | `max(key4[2:4], 0x0400)` | 11 bytes | First data chunk |
| Chunk 2 | `0x1004` | `max(key4[2:4], 0x0400) + 0x0400` | 11 bytes | Second chunk |
| Chunk N | `0x1004` | `max(key4[2:4], 0x0400) + (N-1) * 0x0400` | 11 bytes | Nth chunk |
| Chunk 1 | `0x1004` | `0x0400` | 11 bytes | First data chunk |
| Chunk 2 | `0x1004` | `0x0800` | 11 bytes | Second chunk |
| Chunk N | `0x1004` | `N * 0x0400` | 11 bytes | Nth chunk |
| … | … | … | … | … |
| Termination | `0x005A` | `max(key4[2:4], 0x0400) + N * 0x0400` | 10 bytes | End transfer |
| Termination | `0x005A` | `last + 0x0400` | 10 bytes | End transfer |
> ⚠️ **2026-04-06 CORRECTED — chunk counter is `key4[2:4] + (N-1) * 0x0400`.**
> The 4-2-26 BW TX capture showed counter=0x1004 for chunk 1 of key `01110000`, leading to
> an interim "monotonic n * 0x0400" formula. This was accidentally correct because
> `key4[2:4] == 0x0000` for that event.
>
> **2026-04-24 CORRECTION:** The counter is an absolute circular-buffer address.
> BW's true formula is `key4[2:4] + (chunk_num - 1) * 0x0400` where `key4[2:4]` is the
> event's storage base offset (`(key4[2]<<8) | key4[3]`). For keys where
> `key4[2:4] != 0x0000` (e.g. key `01111884`), using `n * 0x0400` sends requests into the
> wrong buffer region — the device returns data from a completely different event.
>
> **2026-04-26 FINAL CORRECTION:** The formula `key4[2:4] + (N-1) * 0x0400` is wrong when
> `key4[2:4] == 0x0000` (e.g. event key `01110000`, the very first event after a device erase).
> Counter=0x0000 for chunk 1 is the same address as the probe frame — the device re-returns
> the STRT record data instead of waveform payload (frame 1 has len=1097, same as probe, and
> contains `b"STRT\xff\xfe"`, contributing zero waveform bytes).
> Final formula: `max(key4[2:4], 0x0400) + (chunk_num - 1) * 0x0400`.
> For key `01110000`: chunk 1 = 0x0400 (confirmed working, empirical test 2026-04-06).
> For key `0111245a`: chunk 1 = 0x245a (unchanged, confirmed from 4-3-26 capture).
> ⚠️ **2026-04-06 CORRECTED — chunk counter is monotonic for ALL chunks.**
> The 4-2-26 BW TX capture showed counter=0x1004 for chunk 1, which was hardcoded as a
> special case. This was a Blastware artifact. Empirically confirmed: counter=0x0400 for
> chunk 1 works correctly; counter=0x1004 causes the device to time out. The device does
> NOT strictly validate the counter value — it streams data for any valid 5A request for
> the given key. Use `chunk_num * 0x0400` (monotonic) for all chunks.
> BW's true internal formula is `key4[2:4] + n * 0x0400`. For event 1 (key `01110000`)
> this equals `n * 0x0400` since `key4[2:4] = 0x0000`. The monotonic formula is correct
> for all keys encountered on this device.
The `stop_after_metadata=True` flag causes the loop to stop as soon as `b"Project:"` is
found in the accumulated A5 frame data, typically after 49 chunks. A termination frame
found in the accumulated A5 frame data, typically after 79 chunks. A termination frame
is always sent before returning.
**IMPORTANT — one extra chunk required after "Project:" for valid file footer (confirmed 2026-04-23):**
When writing a Blastware-compatible waveform file, stopping immediately at "Project:" and
sending termination produces an empty termination response with no footer bytes (`0e 08`
marker missing). Blastware downloads exactly **one more chunk** after finding "Project:"
before sending termination — that extra chunk primes the device to return valid footer
bytes (monitoring start/stop timestamps) in the termination response.
`read_bulk_waveform_stream(stop_after_metadata=True)` implements this: after the "Project:"
chunk is received, one additional chunk is requested before breaking. The termination
response (`include_terminator=True`) then contains the correct `0e 08` footer.
**do NOT use `full_waveform=True` for Blastware file writing** — for events with long
post-event silence (35 chunks), the silence chunks contain embedded device-internal
pointer structures that produce spurious STRT markers in the file body. Blastware only
downloads 45 chunks (metadata + one signal chunk) regardless of event length.
#### 7.8.3 A5 Frame Layout
Each A5 response frame contains a chunk of raw bulk data. Frame 7 of the stream carries the
@@ -1321,19 +1235,13 @@ TimeoutError caught:
Chunks with uniform 1,036-byte payload (chunks 1735 in the observed event) contain all-zero ADC samples — the device continues recording silence until the configured record time expires before terminating the stream.
**ADC count-to-physical conversion — ✅ CONFIRMED 2026-04-17:**
Raw samples are signed 16-bit integers (32,768 to +32,767). Source: Interface Handbook §4.5.
**CONFIRMED 2026-04-17** — The `max_range_geo` field (float32 = 6.206053, bytes `40 C6 97 FD`) is the **ADC-to-velocity scale factor** (inverse sensitivity, (in/s)/V) for the standard Instantel geophone, confirmed from Interface Handbook §4.5. The correct conversion formula is:
**ADC count-to-physical conversion:**
Raw samples are signed 16-bit integers (32,768 to +32,767). To convert to physical units:
```
PPV (in/s) = ADC_voltage (V) × 6.206053
= counts × (1.61133 / 32767) × 6.206053
= counts × 4.982e-5 (in/s per count at full scale)
value_in_s (in/s) = counts × (geo_range / 32767)
```
where `geo_range = 1.61133 V × 6.206053 = 10.000 in/s` is the Normal (Gain=1) full-scale range. The earlier ~9× overread was caused by mistakenly using 6.206053 as the range directly — it is actually the scale factor, and the range itself is `ADC_fullscale × scale_factor = 1.61133 × 6.206053 = 10.000 in/s`. Mic channel uses psi units with its own range (still unresolved).
where `geo_range` is from the compliance config (typically 10.000 in/s). Mic channel uses psi units with its own range. Near-full-scale values on all channels simultaneously indicate ADC saturation (clipping).
**Known decoder issue — fi==9 hardcoded skip:**
@@ -1349,8 +1257,8 @@ Fields visible in the Blastware "Compliance Setup" dialog. ✅ = byte offset co
| Field | Values / Type | Status |
|---|---|---|
| Recording Mode | Single Shot (`0x00`) / Continuous (`0x01`) / Histogram (`0x03`) / Histogram+Continuous (`0x04`) | ✅ `recording_mode` — write: `cfg[anchor3]`; read E5 sf1: `data[anchor4]` — confirmed 2026-04-20 |
| Record Stop Mode | Fixed Record Time / Auto / Manual Stop | ❓ Hint: `data[40]` in E5 sf1 changed `01 7F``00 00` alongside Continuous → Single Shot; may be related but unconfirmed independently |
| Recording Mode | Continuous / Single Shot / Histogram | ❓ |
| Record Stop Mode | Fixed Record Time / Auto / Manual Stop | ❓ |
| Sample Rate | Standard 1024 / Fast 2048 / Faster 4096 sps | ✅ `sample_rate` (anchor2) |
| Record Time | float, seconds (3, 5, 8, 10, 13…) | ✅ `record_time` (anchor+10) |
| Histogram Interval | 5 / 15 / 30 / 60 min (mode-gated behind Histogram mode) | ❓ |
@@ -1359,8 +1267,7 @@ Fields visible in the Blastware "Compliance Setup" dialog. ✅ = byte offset co
| Geophone — Enable all | bool | ❓ |
| Geophone — Trigger Source | bool | ❓ |
| Chan 1-3 Trigger Level | float, in/s | ✅ `trigger_level_geo` |
| Chan 1-3 Maximum Range (range selector) | Normal 10.000 / 1.25 in/s | ✅ `geo_range` uint8 — **CONFIRMED 2026-04-20.** Offset = Tran+33 (same in E5 read and SUB 71 write — 2126-byte buffer is round-tripped verbatim). `0x00`=Normal 10 in/s, `0x01`=Sensitive 1.25 in/s. Applied to Tran/Vert/Long. **`Tran+20` is NOT this field** (constant 0x01 on all captures). |
| Chan 1-3 ADC Scale Factor | 6.206053 (in/s)/V | ✅ `geo_adc_scale` float32 — **CONFIRMED 2026-04-17.** Offset = Tran+28 (same in E5 read and SUB 71 write). Inverse sensitivity = 1/0.161133. Interface Handbook §4.5: 1.61133 V × 6.206053 = 10.000 in/s. Hardware constant — do NOT write. |
| Chan 1-3 Maximum Range | Normal 10.000 / 1.25 in/s | ✅ `max_range_geo` |
| Microphone — Enable all | bool | ❓ |
| Microphone — Trigger Source | bool | ❓ |
| Chan 4 Trigger Level | float, dB or psi | ❓ |
@@ -1558,117 +1465,6 @@ and applies this heuristic on every call-home.
---
### 7.12 Auto Call Home Config (SUB 0x2C / 0x7E / 0x7F) — ✅ CONFIRMED 2026-04-20
> Confirmed from `bridges/captures/4-20-26/call home settings/` — 10 BW TX write frames
> diffed against the S3 read payload. Accessible in Blastware via Remote Access → Setup Unit.
#### 7.12.1 Read Protocol — SUB 0x2C → Response 0xD3
Standard two-step read:
| Step | Offset | Purpose |
|---|---|---|
| Probe | `0x0000` | Get ack (no data returned) |
| Data | `0x007C` (124) | Receive 125-byte raw payload |
`DATA_LENGTHS[SUB_CALL_HOME] = 0x7C`
The raw payload is accessed as `data_rsp.data[11:]` — this is 125 bytes (not 124) because
the device returns logical value 0x03 (num_retries=3) as the two-byte wire sequence
`\x10\x03`. S3FrameParser is in `STATE_IN_FRAME` when it sees `0x10`, transitions to
`STATE_AFTER_DLE`, and then on `0x03` (ETX qualifier) it would normally end the frame —
but in the `_IN_FRAME_DLE` state it instead appends **both** the `0x10` and the `0x03`
literally to the payload. The result: `raw[117] = 0x10`, `raw[118] = 0x03`, and all
subsequent fields are shifted +1 from their logical positions.
#### 7.12.2 Raw Payload Field Map (125 bytes, from `data_rsp.data[11:]`)
> All offsets are into the 125-byte raw array. Offsets ≥ 119 are shifted +1 from logical
> due to the DLE-escaped 0x03 at raw[117:119].
| Raw Offset | Field | Type | Notes |
|---|---|---|---|
| `[5]` | `auto_call_home_enabled` | uint8 | `0x00` = disabled, `0x01` = enabled |
| `[6:46]` | `dial_string` | ASCII | 40-byte null-padded, e.g. `"12345"` or phone number |
| `[87]` | `after_event_recorded` | uint8 | `0x00` = off, `0x01` = on |
| `[91]` | `at_specified_times` | uint8 | `0x00` = off, `0x01` = on |
| `[93]` | `time1_enabled` | uint8 | `0x00` = off, `0x01` = on |
| `[101]` | `time1_hour` | uint8 | 023 |
| `[102]` | `time1_min` | uint8 | 059 |
| `[95]` | `time2_enabled` | uint8 | `0x00` = off, `0x01` = on |
| `[105]` | `time2_hour` | uint8 | 023 |
| `[106]` | `time2_min` | uint8 | 059 |
| `[117]` | DLE prefix `0x10` | — | Part of `\x10\x03` wire encoding for num_retries value 3 |
| `[118]` | `num_retries` (value = 3) | uint8 | Logical value 0x03; check `raw[117] == 0x10` to detect DLE prefix |
| `[120]` | `time_between_retries_sec` | uint8 | Shift +1 from logical 119 |
| `[122]` | `wait_for_connection_sec` | uint8 | Shift +1 from logical 121 |
| `[124]` | `warm_up_time_sec` | uint8 | Shift +1 from logical 123 |
**Unconfirmed fields** (offsets not yet mapped from captures):
- Time slots 3 and 4 (if they exist — Blastware UI only shows 2 time slots in observed sessions)
- `modem_power_relay_enabled` (bool)
- `storage_mode` (call home trigger on all events vs. triggered only?)
#### 7.12.3 DLE-Escaped 0x03 — Critical Detail
The `\x10\x03` sequence at raw[117:119] is **not** a DLE stuffing artifact in the usual
sense. Standard DLE stuffing escapes `\x10``\x10\x10`. But here the device is encoding
the integer value `3` in a position where the byte `\x03` would be indistinguishable from
the frame ETX terminator. The device therefore sends `\x10\x03` (DLE + ETX = "inner-frame
terminator" in S3 inner-frame syntax). S3FrameParser correctly handles this: in
`STATE_AFTER_DLE`, seeing `\x03` (ETX) while **inside** an outer frame causes it to
append both `\x10` and `\x03` as literal bytes rather than ending the frame. The outer
frame only terminates on a **bare** `\x03` (without the DLE prefix).
The write frame sends these bytes verbatim — the device accepts `\x10\x03` in the write
payload and interprets it as the value 3. No transformation is needed in
`_encode_call_home_config()`.
**Limitation:** Any field that needs to encode the value `3` (0x03) requires this DLE
prefix. The current encoder raises `ValueError` if any hour or minute field equals 3,
since the encoder does not yet implement DLE-prefixed writes for arbitrary field positions.
In practice, 3:00 AM / 3 minutes past are unlikely scheduled call times.
#### 7.12.4 Write Protocol — SUB 0x7E → 0x7F
Write format (same as other write commands — only BW_CMD `0x10` doubled on wire;
all other bytes written raw; DLE-aware checksum):
| Step | SUB | Payload | Offset | Response |
|---|---|---|---|---|
| Data write | `0x7E` | 127 bytes (125-byte read payload + `\x00\x00`) | `data[1]+2 = 0x7E` (126) | `0x81` |
| Confirm | `0x7F` | empty | `0x00` | `0x80` |
**Write payload construction:**
```python
write_payload = bytearray(raw_125_bytes)
write_payload.append(0x00)
write_payload.append(0x00)
# patch fields in-place, then pass bytes(write_payload) to build_bw_write_frame
```
**Offset formula:** `write_payload[1] = 0x7C` (same as DATA_LENGTH).
`offset = write_payload[1] + 2 = 0x7C + 2 = 0x7E = 126`.
This follows the identical pattern as SUB 0x68 (event index write) and SUB 0x69 (waveform write).
**No preceding 0x2C read required** — Blastware sends SUB 0x7E directly using cached
state. The `seismo-relay` implementation always reads first (`get_call_home_config()`)
before writing for safety.
#### 7.12.5 Implementation Notes
- `MiniMateProtocol.read_call_home_config()` — standard two-step read; returns `data_rsp.data[11:]` (125 bytes raw)
- `MiniMateProtocol.write_call_home_config(data)` — sends SUB 0x7E (127-byte payload) then SUB 0x7F confirm
- `MiniMateClient.get_call_home_config()``CallHomeConfig` dataclass
- `MiniMateClient.set_call_home_config(...)` — reads current config, patches via `_encode_call_home_config()`, writes back
- `_decode_call_home_config(raw)` — handles DLE prefix detection at raw[117]
- `_encode_call_home_config(raw, ...)` — patches in-place, appends 2 trailing zeros; raises `ValueError` if any hour/min == 3
- REST API: `GET /device/call_home` and `POST /device/call_home` in `sfm/server.py`
- Web UI: "Call Home" tab in `sfm/sfm_webapp.html`
---
## 8. Timestamp Format
Two timestamp wire formats are used:
@@ -2137,7 +1933,7 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger
| **Auxiliary Trigger read location****RESOLVED:** SUB `FE` offset `0x0109`, uint8, `0x00`=disabled, `0x01`=enabled. Confirmed 2026-03-11 via controlled toggle capture. | RESOLVED | 2026-03-02 | Resolved 2026-03-11 |
| **Auxiliary Trigger write path** — Write command not yet captured in a clean session. Inner frame handshake visible in A4 (multiple WRITE_CONFIRM_RESPONSE SUBs appear, TRIGGER_CONFIG_RESPONSE removed), but the BW→S3 write command itself was in a partial session. Likely SUB `15` or similar. Deferred for clean capture. | LOW | 2026-03-11 | NEW |
| ~~**SUB `6E` response to SUB `1C`**~~~~RESOLVED 2026-04-08: This was a misidentification.~~ The `1C → 6E` "exception" was misread — likely an inner A4 sub-frame. Confirmed from 4-8-26 capture (338 frames): SUB 0x1C always → 0xE3. No exceptions to the `0xFF SUB` rule are known. | RESOLVED | 2026-04-08 | CLOSED |
| ~~**Max Geo Range float 6.2061**~~**RESOLVED 2026-04-17.** Confirmed as the **ADC-to-velocity scale factor** = inverse sensitivity = 1/0.161133 = **6.206053 (in/s)/V**. Source: Interface Handbook §4.5 formula `Range = 1.61133 V / Sensitivity`. For standard Instantel geo at Normal (Gain=1) range: Sensitivity = 1.61133/10 = 0.161133 V/(in/s), scale = 6.206053. Firmware: `PPV (in/s) = ADC_voltage × 6.206053`. The earlier ~9× overread was from using 6.206053 directly as range instead of as scale factor (range = 1.61133 V × 6.206053 = 10.000 in/s). Hardware constant — do NOT write. | RESOLVED | 2026-02-26 | Resolved 2026-04-17 |
| **Max Geo Range float 6.2061 in/s** — NOT a user-selectable range (manual only shows 1.25 and 10.0 in/s). Likely internal ADC full-scale constant or hardware range ceiling. Not worth capturing. | LOW | 2026-02-26 | Downgraded 2026-03-02 |
| MicL channel units — **RESOLVED: psi**, confirmed from `.set` file unit string `"psi\0"` | RESOLVED | 2026-03-01 | |
| Backlight offset — **RESOLVED: +4B in event index data**, uint8, seconds | RESOLVED | 2026-03-02 | |
| Power save offset — **RESOLVED: +53 in event index data**, uint8, minutes | RESOLVED | 2026-03-02 | |
@@ -2166,11 +1962,10 @@ The `.bin` files produced by `s3_bridge` are **not raw wire bytes**. The logger
| Trigger Level (Mic) | §3.8.6 | Channel block, float | float32 BE | 100148 dB in 1 dB steps |
| Alarm Level (Mic) | §3.9.10 | Channel block, float | float32 BE | higher than mic trigger |
| Record Time | §3.8.9 | cfg anchor+10, float32 BE (wire); `.set` +16, uint32 LE (file) | float32 BE (wire) | 1105 s; confirmed 3→`40400000`, 5→`40A00000`, 8→`41000000`, 13→`41500000`. Use anchor §7.6.1/§7.6.3 — NOT fixed offset. |
| ADC Scale Factor (geo_adc_scale) | §3.8.4 / Interface Handbook §4.5 | Channel block, Tran+28 (same in E5 read and SUB 71 write), float32 BE | float32 BE = 6.206053 | ✅ CONFIRMED 2026-04-17 — inverse sensitivity (in/s)/V. `Range = 1.61133 V × 6.206053 = 10.000 in/s`. Firmware: `PPV (in/s) = ADC_voltage × 6.206053`. Hardware constant, identical on all units. Do NOT write. |
| Max Geo Range (geo_range) | §3.8.4 | Channel block, Tran+33 (same in E5 read and SUB 71 write), uint8; applied to Tran/Vert/Long | uint8 | ✅ CONFIRMED 2026-04-20 — `0x00`=Normal 10.000 in/s, `0x01`=Sensitive 1.250 in/s. **NOTE: `Tran+20` reads `0x01` on ALL captures regardless of range — it is NOT this field.** |
| Max Geo Range | §3.8.4 | Channel block, float | float32 BE | 1.25 or 10.0 in/s (user); 6.2061 in protocol = internal constant |
| Microphone Units | §3.9.7 | Inline unit string | char[4] | `"psi\0"`, `"pa.\0"`, `"dB\0\0"` |
| Sample Rate | §3.8.2 | cfg anchor2, uint16 BE — anchor=`\x01\x2c\x00\x00\xbe\x80\x00\x00\x00\x00` in cfg[40:100] | uint16 BE | Normal=1024, Fast=2048, Faster=4096 ✅ CONFIRMED 2026-04-01 (BE11529 S338.17). Anchor required — see §7.6.3 DLE jitter. |
| Record Mode | §3.8.1 | Write: `cfg[anchor3]`, uint8. Read (E5 sf1): `data[anchor4]`, uint8. Note: extra `0x10` byte at read `data[anchor3]` shifts offset by 1 vs write. | uint8 | `0x00`=Single Shot, `0x01`=Continuous, `0x02`=unknown, `0x03`=Histogram, `0x04`=Histogram+Continuous. ✅ CONFIRMED 2026-04-20 |
| Record Mode | §3.8.1 | Unknown | — | Single Shot, Continuous, Manual, Histogram, Histogram Combo |
| Trigger Sample Width | §3.13.1h | BW→S3 SUB `0x82` write frame, destuffed `[22]`, uint8 | uint8 | Default=2; confirmed 4=`0x04`, 3=`0x03`. **BW-side write only** — not visible in S3 compliance reads. Mode-gated: only sent in Compliance/Single-Shot/Fixed mode. |
| Auto Window | §3.13.1b | **Mode-gated — NOT YET MAPPED** | uint8? | 19 seconds; only active when Record Stop Mode = Auto. Capture in Fixed mode produced no wire change. |
| Auxiliary Trigger | §3.13.1d | SUB `FE` (FULL_CONFIG_RESPONSE) offset `0x0109` (read); write path not yet isolated | uint8 (bool) | `0x00`=disabled, `0x01`=enabled; confirmed 2026-03-11 |
@@ -2273,279 +2068,6 @@ Semantic Interpretation <- settings, events, responses
---
---
## Appendix D — Blastware Binary File Formats (.N00 / .MLG / others)
> ✅ CONFIRMED 2026-04-21 — all fields verified by binary diff of reconstructed vs reference
> files from the 4-3-26-multi_event capture (M529LIY6.N00, BE11529.MLG).
>
> ⚠️ EXTENSION MAPPING REFUTED 2026-04-21 — earlier assumption that extension encodes
> recording mode is **FALSE**. A continuous-mode event produced `.EI0`, not `.9T0`.
> Extension encoding algorithm is unknown. Do not use extension to infer recording mode.
### D.1 Common File Header (22 bytes)
All Blastware files (regardless of type) share an 18-byte prefix followed by a 4-byte type tag.
| Offset | Length | Value | Description |
|---|---|---|---|
| 0x00 | 6 | `10 00 01 80 00 00` | Fixed prefix |
| 0x06 | 10 | `Instantel\x00` | ASCII string |
| 0x10 | 2 | `07 2c` | Fixed suffix |
| 0x12 | 4 | varies | File type tag (see below) |
**Total header: 22 bytes.**
**Type tags:**
| Extension | Type tag | Description |
|---|---|---|
| `.N00` | `00 12 03 00` | Waveform event (confirmed) |
| `.9T0` | `00 12 03 00` | Waveform event — same type tag as .N00 (assumed; not independently confirmed) |
| `.EI0` | `00 12 03 00` | Waveform event — same type tag (assumed; continuous-mode event observed 2026-04-21) |
| `.MLG` | `22 01 0e a0` | Monitor log |
**Extension encoding — new firmware (V10.72+) FULLY DECODED (confirmed 2026-04-22):**
The extension differs depending on how the file was saved:
| Download method | Extension format | Example |
|---|---|---|
| Manual / direct (Blastware connected to unit) | `AB0` (3 chars) | `.CE0` |
| Call-home / ACH | `AB0W` or `AB0H` (4 chars) | `.CE0H` |
Where:
- `AB` = 2-char base-36 of `total_seconds % 1296`; `A = value // 36`, `B = value % 36`
- `total_seconds = (event_local_time 1985-01-01T00:00:00_local)` in seconds
- `0` = always literal digit zero
- `W` = Full Waveform, `H` = Full Histogram (ACH only)
Base-36 alphabet: `09` = 09, `AZ` = 1035.
The 10-year production archive contains only ACH files (all end in W or H). Manual Blastware downloads produce the same `AB0` prefix but without the trailing type character.
**3-day cycle property (confirmed 2026-04-22):** A unit recording at a fixed daily time cycles through exactly **3 different extensions** with a 3-day period. Each calendar day shifts `total_seconds % 1296` by 864 (since `86400 % 1296 = 864`). The cycle repeats every 3 days because `gcd(1296, 864) = 432`. Confirmed from archive: top 3 extensions `CE0H` (95), `0E0H` (93), `OE0H` (91) are the 3-day cycle of a 06:00:14 daily call-in (seconds-in-window = 446, 14, 878).
**B character invariance:** `864 = 24 × 36`, so adding one day never changes `value % 36` — the second extension character is invariant for a fixed daily recording time. Only the first character cycles through 3 values.
**Old firmware (S338):** 3-char extensions observed (`.N00`, `.EI0`, etc.) — may simply be manual downloads under the same AB0 scheme, or a different encoding. Not yet confirmed.
**Micromate Series 4** uses a different extension format (observed: `IDFH`, `IDFW`). This formula does NOT apply to Micromate units.
All waveform files share the same `00 12 03 00` type tag regardless of extension. Blastware identifies file type by extension, not by type tag alone.
### D.2 Timestamp Encoding (Blastware files)
All timestamps in N00 and MLG files use an **8-byte big-endian format**:
| Byte | Field |
|---|---|
| 0 | day (uint8) |
| 1 | month (uint8) |
| 23 | year (uint16 BE) |
| 4 | `0x00` (reserved) |
| 5 | hour (uint8) |
| 6 | minute (uint8) |
| 7 | second (uint8) |
Example: `01 04 07 ea 00 00 1c 08` → April 1, 2026, 00:28:08.
Note: this differs from the 8-byte protocol timestamp (`[day][sub_code][month][year_HI][year_LO][0x00][hour][min][sec]` = 9 bytes) used in the device's on-wire 0C waveform records. The file format uses a compact 8-byte layout without the `sub_code` byte.
### D.3 N00 File Format — Single-Shot Waveform Event
**File layout:** `[22B header] [21B STRT record] [body bytes] [26B footer]`
#### D.3.1 STRT Record (21 bytes)
The STRT record immediately follows the 22-byte header.
| Offset | Length | Field | Notes |
|---|---|---|---|
| 0 | 4 | `STRT` | ASCII literal |
| 4 | 2 | `ff fe` | Fixed |
| 6 | 4 | event key (key4) | 4-byte waveform key |
| 10 | 4 | device-specific | NOT a repeat of key4 — device-internal field |
| 14 | 6 | device-specific | NOT zero-padded — device-internal fields |
| 20 | 1 | rectime | uint8 seconds |
**Critical:** The STRT record must be copied verbatim from A5[0].data[7+strt_pos:] — bytes [10:20] contain device-specific values that cannot be reconstructed from protocol-level Event fields alone.
#### D.3.2 Body Bytes (variable)
The body is reconstructed from the raw A5 bulk waveform stream frames by stripping DLE framing markers and taking the appropriate slice of each frame's data section.
**Per-frame contribution (from `frame.data`):**
| Frame | Skip amount | Notes |
|---|---|---|
| A5[0] (probe) | `7 + strt_pos_in_w0 + 21` | Skip frame.data prefix + STRT record |
| A5[1] | 13 | 7-byte prefix + 6-byte first-chunk header |
| A5[2..N] | 12 | 7-byte prefix + 5-byte chunk header |
| Terminator (page_key=0x0000) | 11 | 7-byte prefix + 4-byte terminator header |
**DLE strip rule:** For each frame's contribution (`frame.data[skip:]`), strip any `0x10` byte immediately followed by `0x02`, `0x03`, or `0x04`. Only the `0x10` is stripped; the following byte is kept as payload.
**Split-pair edge case:** When `frame.data[-1] == 0x10` AND `frame.chk_byte ∈ {0x02, 0x03, 0x04}`, the S3FrameParser split a DLE+XX pair at the payload/checksum boundary. Reunite the bytes before stripping (`relevant + bytes([chk_byte])`), then always remove the trailing chk_byte from the result (`stripped[:-1]`) — chk_byte is the wire checksum, never payload.
**Body/footer split:** Accumulate all frame contributions (data frames + terminator) into `all_bytes`. Then:
- `body = all_bytes[:-26]` (variable length)
- `footer = all_bytes[-26:]` (always 26 bytes — extracted from terminator content)
#### D.3.3 Footer (26 bytes)
The footer terminates the N00 file. Its bytes come directly from the terminator A5 frame's inner content — do NOT reconstruct from event metadata.
| Offset | Length | Field | Notes |
|---|---|---|---|
| 0 | 2 | `0e 08` | Fixed marker |
| 2 | 8 | ts1 | Start timestamp (8B big-endian) |
| 10 | 8 | ts2 | Stop timestamp (8B big-endian) |
| 18 | 6 | `00 01 00 02 00 00` | Fixed |
| 24 | 2 | CRC | 2-byte CRC — algorithm unconfirmed |
**CRC:** The 2-byte CRC at footer[24:26] has an unconfirmed algorithm. In M529LIY6.N00 it reads `fe da`. Attempts to match CRC-16/CCITT, CRC-16/IBM, CRC-32 (truncated), and 40+ polynomial/init combinations all failed. The writer copies it verbatim from the terminator frame.
### D.4 MLG File Format — Monitor Log
**File layout:** `[308B header] [N × 292B records]`
#### D.4.1 MLG Header (308 bytes)
| Offset | Length | Field | Notes |
|---|---|---|---|
| 0x00 | 22 | common header | prefix + `22 01 0e a0` type tag |
| 0x16 | 16 | unknown | observed as zeros in BE11529.MLG |
| 0x2A | 8 | serial number | null-padded ASCII (e.g. `"BE11529"`) |
| 0x32 | remainder | zero pad | pads to 308 bytes total |
#### D.4.2 MLG Record (292 bytes each)
| Offset | Length | Field | Notes |
|---|---|---|---|
| 0 | 2 | CRC | 2-byte CRC — algorithm unconfirmed; write as `00 00` |
| 2 | 4 | `22 01 0e 80` | Record marker |
| 6 | 8 | ts1 | Start timestamp (8B big-endian) |
| 14 | 8 | ts2 | Stop timestamp (8B big-endian); zeros if no stop |
| 22 | 4 | flags | Record type flags (see below) |
| 26 | 10 | serial | Null-padded ASCII serial number |
| 36 | variable | text | Type-dependent content |
| — | remainder | zero pad | pads to 292 bytes total |
**Record flags:**
| Value | Meaning |
|---|---|
| `ff ff 00 00` | Monitoring start with no stop recorded |
| `01 00 02 00` | Triggered event (has ts1 + ts2) |
| `02 00 00 00` | Monitoring interval (has ts1 + ts2) |
**Text content for triggered events (`flags = 01 00 02 00`):**
| Byte | Field |
|---|---|
| 0 | `0x08` |
| 18 | ts1 copy (8B big-endian) |
| 9+ | `"Geo: X.XXX in/s\x00"` ASCII geo threshold |
#### D.4.3 MLG CRC
The 2-byte CRC at record[0:2] uses an unconfirmed algorithm. Tested against CRC-16/CCITT, CRC-16/IBM, CRC-32 (truncated), word sums, XOR variants, and 40+ polynomial/init combinations — none matched. The writer emits `00 00`. Blastware may reject files with incorrect CRCs (impact on import unknown — TODO: test).
### D.5 Filename Encoding ✅ PARTIALLY CONFIRMED 2026-04-22
Blastware assigns waveform filenames of the form `<prefix_letter><serial3><stem><ext>`, where:
#### D.5.1 Serial Prefix ✅ CONFIRMED 2026-04-22
The first 4 characters of the filename encode the full device serial number:
```
prefix_letter = chr(ord('B') + floor(serial_numeric / 1000))
serial3 = f"{serial_numeric % 1000:03d}" (last 3 digits, zero-padded)
```
Where `serial_numeric` is the integer after the "BE" device-type prefix.
Examples (all confirmed from archive):
| Serial | serial_numeric / 1000 | prefix_letter | serial3 | Filename prefix |
|--------|----------------------|---------------|---------|-----------------|
| BE6907 | 6 | H | 907 | H907 |
| BE7145 | 7 | I | 145 | I145 |
| BE11529 | 11 | M | 529 | M529 |
| BE14036 | 14 | P | 036 | P036 |
| BE17353 | 17 | S | 353 | S353 |
| BE18003 | 18 | T | 003 | T003 |
| BE18191 | 18 | T | 191 | T191 |
| BE18676 | 18 | T | 676 | T676 |
**Interpretation:** The prefix letter encodes the production generation (batch of 1000 units). B=generation 0 (serials 0999), C=generation 1 (10001999), etc. No units with prefix A have been observed — the earliest known units start around serial 2000+ (prefix D).
**Note:** The "BE" device-type prefix is implicit. The filename only encodes the numeric part of the serial. Other Instantel device types (Micromate, Blastmate) may use a different scheme.
#### D.5.2 Stem + Extension — full timestamp encoding ✅ FULLY CONFIRMED 2026-04-22
The stem (4 chars) and AB extension (2 chars) together form a 6-digit base-36 number encoding a complete second-resolution timestamp:
```python
total_seconds = stem_int * 1296 + ab_int
event_local_time = datetime(1985, 1, 1) + timedelta(seconds=total_seconds)
```
- **Epoch:** `1985-01-01 00:00:00` **device local time** ✅ CONFIRMED — verified against 3,248 files from a 10-year production archive; zero errors (only 2 mismatches were Micromate `IDFH`/`IDFW` files which use a completely different naming scheme)
- **Unit:** 1296 seconds = 36² ≈ 21.6 minutes per stem increment
- **Alphabet:** `"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"` (digits then uppercase letters)
- **Collision:** Events within the same 21.6-minute window share a stem; extension distinguishes them
**Decoding example — `P036L318.C80H` (BE14036, Full Histogram):**
```
stem L318 = 21×36³ + 3×36² + 1×36 + 8 = 983,708
AB C8 = 12×36 + 8 = 440
total_sec = 983,708 × 1296 + 440 = 1,274,886,008
event_time = 1985-01-01 + 1,274,886,008s = 2025-05-26 15:00:08 local
```
**Note on local time:** The device's onboard clock is set to the local timezone of the deployment site. The epoch and all timestamps are in that same local time — there is no UTC conversion. Files moved between timezones will decode to the original deployment timezone.
#### D.5.3 Extension taxonomy
Third character of extension is always `'0'`. File type is identified by extension, not by the type tag in the header (all waveform extensions share type tag `00 12 03 00`).
| Extension | Recording mode | Sample rate | Status |
|---|---|---|---|
| `.N00` | Single Shot (0x00) | 1024 sps | ✅ CONFIRMED |
| `.9T0` | Continuous (0x01) | 1024 sps | ✅ CONFIRMED |
| `.490` | ? | ? | ❓ observed from M529LJ8V.490 |
| `.5K0` | ? | ? | ❓ observed from M529LJDY.5K0 |
| `.980` | ? | ? | ❓ observed from M529LJDY.980 |
| `.ML0` | ? | ? | ❓ observed from M529LJDY.ML0 (167s duration; possibly Histogram) |
**Why 5 extensions for "Continuous"?** Binary analysis of all 6 example files shows that `.9T0`, `.490`, `.5K0`, `.980`, `.ML0` are byte-for-byte identical in all metadata regions (compliance anchor block, channel descriptor blocks `Tran/Vert/Long/MicL`). The A5 frame 7 body reflects the **session-start** compliance config, not the per-event capture config. All 5 files show recording_mode=0x01 and sample_rate=1024 in the body. The extension must therefore encode the **capture-time** compliance state — likely a combination of recording mode, sample rate, and possibly mic units or other options. This cannot be determined from file body alone without capture-time compliance data from the 0C record sub_code and the actual waveform sample count.
**DLE-shift offset note for reading recording_mode from N00/9T0 body:**
The compliance block in the file body has been through `_strip_inner_frame_dles`. The 0x10 constant at logical `anchor7` (between recording_mode and sample_rate_HI) gets stripped when sample_rate_HI = `0x04` (1024 sps), because `0x10` precedes `0x04 ∈ {0x02,0x03,0x04}`. After stripping, the anchor shifts left by 1, so:
| 1024 sps (strip occurs) | 2048 or 4096 sps (no strip) |
|---|---|
| `file[anc7]` = recording_mode | `file[anc8]` = recording_mode |
| `file[anc6:anc4]` = sample_rate | `file[anc6:anc4]` = sample_rate |
For 1024 sps files, the expected file bytes around the anchor are:
```
file[anc9]: mode_prefix (0x00 for Single Shot/Continuous; 0x10 for Histogram)
file[anc8]: 0x00 (was recording_mode, but shifted away — now reads 0x00 for mode_prefix)
file[anc7]: recording_mode (0x00=Single Shot, 0x01=Continuous, etc.)
file[anc6]: 0x04 (sample_rate_HI for 1024 sps)
file[anc5]: 0x00 (sample_rate_LO)
file[anc4]: histogram_interval_HI
file[anc3]: histogram_interval_LO
```
---
*All findings reverse-engineered from live RS-232 bridge captures.*
*Cross-referenced from 2026-03-02 with Instantel MiniMate Plus Operator Manual (716U0101 Rev 15).*
*This is a living document — append changelog entries and timestamps as new findings are confirmed or corrected.*
-949
View File
@@ -1,949 +0,0 @@
"""
blastware_file.py Blastware binary file codec for bidirectional interoperability.
Reads and writes the proprietary Instantel/Blastware file formats:
Waveform events (.CE0W, .VM0H, .440, .7M0, etc.) (extension encoding UNKNOWN see below)
.MLG Monitor log (monitoring session history)
All waveform formats share a common 22-byte file header prefix and identical
internal binary structure (same type tag 00 12 03 00, same STRT record layout).
Blastware identifies the file type by extension, not by a magic marker.
EXTENSION ENCODING V10.72 firmware FULLY CONFIRMED 2026-04-22:
Direct / manual download: AB0 (3-char, no type character)
Call-home (ACH) download: AB0W or AB0H (4-char, W=waveform H=histogram)
AB = 2-char base-36 of (total_seconds % 1296), where
total_seconds = (event_local_time 1985-01-01T00:00:00_local).
0 = always literal digit zero.
Verified against 3,248 call-home files from a 10-year production archive.
The 10-year archive contains only ACH files (all end in W or H).
Manual Blastware downloads produce 3-char AB0 extensions same encoding
but without the trailing type character.
Old firmware (S338, 3-char extensions): encoding unknown / same as manual?
Micromate Series 4 uses a different scheme (literal datetime in filename).
File structure overview
Waveform file structure (confirmed from example-events/4-3-26-multi/M529LIY6 (example event)):
[22B header] [21B STRT record] [body bytes] [26B footer]
Header (22 bytes):
10 00 01 80 00 00 fixed prefix
49 6e 73 74 61 6e 74 65 6c 00 b'Instantel\x00'
07 2c fixed
00 12 03 00 waveform file type tag (shared by all waveform extensions)
STRT record (21 bytes, immediately follows header):
53 54 52 54 b'STRT'
ff fe fixed (2 bytes)
[key4] 4-byte waveform event key
[key4] 4-byte waveform event key (repeated)
[zeros] 7 bytes padding
[rectime] uint8 record time in seconds
Body (variable reconstructed from A5 frame data):
The body bytes are derived from the raw A5 frame wire content, specifically
from the DLE-decoded representation of each frame's contribution. See the
_frame_body_bytes() helper for the exact algorithm.
Footer (26 bytes):
0e 08
[ts1: 8B big-endian timestamp] start timestamp
[ts2: 8B big-endian timestamp] stop timestamp
00 01 00 02 00 00
[crc: 2B] CRC (algorithm unconfirmed; written as 0x00 0x00 placeholder)
Timestamp format (big-endian, 8 bytes):
[day] [month] [year_HI] [year_LO] [0x00] [hour] [min] [sec]
MLG (monitor log, confirmed from example-events/4-3-26-multi/BE11529.MLG):
[308B header] [N × 292B records]
Header (308 bytes):
Offset 0x00: 10 00 01 80 00 00 Instantel\x00 07 2c 22 01 0e a0 fixed (16B)
Offset 0x10: ... (unknown structure, written as zeros + serial)
Offset 0x2A: serial number (8 bytes, null-padded ASCII, e.g. "BE11529")
... zero-padded to 308 bytes total
Record (292 bytes each):
[2B CRC] unknown algorithm; written as 0x00 0x00
22 01 0e 80 record marker
[ts1: 8B big-endian timestamp] start time
[ts2: 8B big-endian timestamp] stop time (zeros if no stop)
[4B flags] see MLG_FLAGS_* constants below
[10B serial] null-padded serial number ASCII
[text] for trigger records: [0x08][8B ts1_copy] then ASCII "Geo: X.XXX in/s"
for monitoring records: b'' (or minimal separator)
[zero-padded to 292 bytes]
Critical implementation notes
Waveform body reconstruction algorithm (confirmed 2026-04-21 from verification against
M529LIY6 (example event) using raw_s3_20260403_153508.bin capture):
The waveform body bytes come from the A5 frame content, stripped of DLE-framing
artifacts. Each A5 frame contributes a different slice of its data section,
with DLE+{0x02,0x03,0x04} byte pairs stripped.
Skip amounts per frame index (offsets into frame.data):
A5[0] (probe): data[strt_pos + 21 + 7] (skip header + STRT record)
strt_pos found by searching frame.data[7:] for b'STRT';
the contribution starts at strt_pos + 21 within data[7:]
which equals strt_pos + 21 + 7 within frame.data.
A5[1]: data[13] (skip 7-byte frame.data prefix + 6 header bytes)
A5[2..N]: data[12] (skip 7-byte frame.data prefix + 5 header bytes)
Terminator A5: data[11] (1 byte less than chunk frames; terminator inner header
is 4 bytes instead of 5 confirmed 2026-04-21)
DLE strip rule (applied AFTER slicing):
Strip any 0x10 byte that is immediately followed by 0x02, 0x03, or 0x04.
This undoes the DLE-escape that S3FrameParser preserves as literal pairs.
Applied to frame.data[skip:] + bytes([frame.chk_byte]) together, then
conditionally exclude the trailing chk_byte from the output.
chk_byte absorption:
When frame.data[-1] == 0x10 AND frame.chk_byte {0x02, 0x03, 0x04},
the last byte of frame.data is the DLE prefix of a split DLE+chk pair.
Including chk_byte in the strip buffer allows the pair to be stripped as
a unit. After stripping, the trailing chk_byte is ALWAYS removed because
_strip_inner_frame_dles keeps the byte after the DLE (the chk_byte value),
and that value is the checksum, never payload. This applies to all three
cases (chk {0x02, 0x03, 0x04}) identically.
MLG CRC:
The algorithm that produces the 2-byte CRC at the start of each MLG record
is unknown. All examined records use non-zero values that do not match
CRC-16/CCITT, CRC-16/IBM, CRC-32 (truncated), word sums, XOR variants, or
any of the 40+ polynomial/init combinations tested. The writer emits 0x0000.
This produces files that Blastware may reject or display without the CRC check
the exact impact on BW import is unknown (TODO: test).
Public API
blastware_filename(event, serial)
Return the correct Blastware filename for an event (e.g. "M529LIY6.CE0W").
Full AB0T extension encoding confirmed 2026-04-22 against 3,248 archive files.
Extension matches what Blastware itself would generate for the same event.
write_blastware_file(event, a5_frames, path)
Create a Blastware waveform file from an Event and the full A5 frame list.
All waveform extensions share the same binary format the extension is set
by blastware_filename() based on the event timestamp and type.
read_blastware_file(path) Event
Parse a Blastware waveform file into an Event object with waveform data populated.
(Not yet implemented placeholder raises NotImplementedError.)
write_mlg(entries, serial, path)
Create a .MLG file from a list of MonitorLogEntry objects.
read_mlg(path) list[MonitorLogEntry]
Parse a .MLG file into MonitorLogEntry objects.
(Not yet implemented placeholder raises NotImplementedError.)
"""
from __future__ import annotations
import datetime
import logging
import struct
from pathlib import Path
from typing import Optional, Union
from .framing import S3Frame
from .models import Event, MonitorLogEntry, Timestamp
log = logging.getLogger(__name__)
# ── File header constants ─────────────────────────────────────────────────────
# Common 16-byte prefix shared by waveform files and MLG (confirmed from binary inspection).
_FILE_HEADER_PREFIX = bytes.fromhex("1000018000004973") + b"tantel\x00\x07\x2c"
# = 10 00 01 80 00 00 49 73 74 61 6e 74 65 6c 00 07 2c (17 bytes)
# Confirmed breakdown: 10 00 01 80 00 00 = fixed; "Instantel\x00" = 10B; 07 2c = fixed
# Simpler construction:
_FILE_HEADER_PREFIX = b"\x10\x00\x01\x80\x00\x00Instantel\x00\x07\x2c" # 17 bytes
# Waveform file type tag (4 bytes after common prefix) — shared by ALL waveform extensions
_WAVEFORM_TYPE_TAG = b"\x00\x12\x03\x00" # confirmed from M529LIY6 (example event) — same tag for .CE0W, .VM0H, etc.
# MLG type tag (4 bytes after common prefix)
_MLG_TYPE_TAG = b"\x22\x01\x0e\xa0" # confirmed from BE11529.MLG offset 0x11..0x14
# Total header sizes
_WAVEFORM_HEADER_SIZE = 22 # 17 + 4 = 21... wait. Let me recalculate.
# From binary: first 22 bytes = header, then STRT at byte 22.
# 17-byte common prefix + 4-byte type tag = 21 bytes. But observed header is 22B.
# Checking: 6 fixed + 10 "Instantel\x00" + 2 "07 2c" = 18B prefix, then 4B type tag = 22B.
# Re-count: b"\x10\x00\x01\x80\x00\x00" = 6B + b"Instantel\x00" = 10B + b"\x07\x2c" = 2B = 18B prefix.
_FILE_HEADER_PREFIX = b"\x10\x00\x01\x80\x00\x00Instantel\x00\x07\x2c" # 18 bytes
_WAVEFORM_HEADER_SIZE = 22 # 18 + 4 = 22 bytes ✅
_MLG_HEADER_SIZE = 308 # confirmed from BE11529.MLG
# MLG record marker (4 bytes after 2-byte CRC at start of each record)
_MLG_RECORD_MARKER = b"\x22\x01\x0e\x80"
_MLG_RECORD_SIZE = 292 # bytes per record (confirmed from BE11529.MLG)
# MLG record flags (4 bytes at record[22:26])
# Confirmed from BE11529.MLG binary inspection:
MLG_FLAGS_START_ONLY = b"\xff\xff\x00\x00" # monitoring start with no stop
MLG_FLAGS_TRIGGER = b"\x01\x00\x02\x00" # triggered event (has ts1 + ts2)
MLG_FLAGS_INTERVAL = b"\x02\x00\x00\x00" # monitoring interval (has ts1 + ts2)
# ── Timestamp helpers ─────────────────────────────────────────────────────────
def _encode_ts_be(ts: Optional[datetime.datetime]) -> bytes:
"""
Encode a datetime as an 8-byte big-endian Blastware timestamp.
Format (waveform file and MLG record timestamps):
[day][month][year_HI][year_LO][0x00][hour][min][sec]
Big-endian year confirmed from M529LIY6 (example event) footer:
footer bytes [2..9] = 01 04 07 ea 00 00 1c 08
day=1 month=4 year=0x07ea=2026 hour=0 min=28 sec=8
Returns 8 zero bytes if ts is None.
"""
if ts is None:
return bytes(8)
return bytes([
ts.day,
ts.month,
(ts.year >> 8) & 0xFF,
ts.year & 0xFF,
0x00,
ts.hour,
ts.minute,
ts.second,
])
def _decode_ts_be(raw: bytes) -> Optional[datetime.datetime]:
"""
Decode an 8-byte big-endian Blastware timestamp.
Returns None if the bytes are all zero or structurally invalid.
"""
if len(raw) < 8 or raw == bytes(8):
return None
day = raw[0]
month = raw[1]
year = (raw[2] << 8) | raw[3]
hour = raw[5]
minute = raw[6]
sec = raw[7]
try:
return datetime.datetime(year, month, day, hour, minute, sec)
except ValueError:
return None
def _ts_from_model(ts: Optional[Timestamp]) -> Optional[datetime.datetime]:
"""Convert a models.Timestamp to datetime.datetime, or None."""
if ts is None:
return None
try:
return datetime.datetime(ts.year, ts.month, ts.day, ts.hour, ts.minute, ts.second)
except (ValueError, TypeError):
return None
# ── DLE strip helper ──────────────────────────────────────────────────────────
def _strip_inner_frame_dles(data: bytes) -> bytes:
"""
Strip DLE (0x10) framing markers from A5 inner-frame content.
The A5 (bulk waveform stream) response body contains DLE-encoded sub-frame
structure. S3FrameParser preserves DLE+XX pairs as two literal bytes in
frame.data. Only the DLE marker byte needs to be removed; the following
byte is actual payload content.
Rule: when 0x10 is immediately followed by {0x02, 0x03, 0x04}, strip the
0x10 (DLE marker) and keep the following byte as payload.
Lone 0x10 bytes not followed by {0x02, 0x03, 0x04} are kept as-is.
Confirmed correct by verifying reconstructed waveform body against M529LIY6 (example event):
- 0x10 0x02 in terminator 0x02 kept
- 0x10 0x04 in terminator (month byte) 0x04 kept
"""
out = bytearray()
i = 0
while i < len(data):
b = data[i]
if b == 0x10 and i + 1 < len(data) and data[i + 1] in {0x02, 0x03, 0x04}:
# Strip the DLE marker; the next byte is payload and will be appended
# in the next loop iteration.
i += 1
continue
out.append(b)
i += 1
return bytes(out)
def _frame_body_bytes(frame: S3Frame, skip: int) -> bytes:
"""
Extract the waveform body contribution from one A5 S3Frame.
The contribution is frame.data[skip:] with inner-frame DLE pairs stripped
per _strip_inner_frame_dles(). The chk_byte is temporarily appended before
stripping to handle the split-pair edge case where a DLE at the end of
frame.data is paired with chk_byte.
Split-pair edge case (confirmed for A5[8] of M529LIY6 (example event), 2026-04-21):
S3FrameParser appends DLE+XX pairs as two literal bytes when XX {DLE, ETX}.
When the LAST occurrence of such a pair straddles the payload/checksum boundary
(i.e., DLE is the last byte of raw_payload and XX is the checksum), the parser
splits them:
- DLE ends up as the last byte of frame.data (frame.data[-1] == 0x10)
- XX is stored as frame.chk_byte
To strip the pair correctly, we reunite the bytes before calling the strip
function. Since chk_byte is the checksum (not payload data), it is excluded
from the final output regardless of whether it was part of a pair.
Post-strip chk_byte removal (ALL cases):
_strip_inner_frame_dles strips the 0x10 and KEEPS chk_byte in all cases.
Chk_byte is always the checksum (not payload), so always strip it off.
Args:
frame: S3Frame with frame.data and frame.chk_byte populated.
skip: Number of leading bytes in frame.data to exclude (frame header).
Returns:
bytes the waveform body contribution for this frame.
"""
if skip >= len(frame.data):
return b""
relevant = frame.data[skip:]
# Detect split DLE+chk pair at the frame boundary.
has_split_pair = (
len(relevant) > 0
and relevant[-1] == 0x10
and frame.chk_byte in {0x02, 0x03, 0x04}
)
if has_split_pair:
# Reunite the split pair so the strip function sees both bytes together.
buf = relevant + bytes([frame.chk_byte])
stripped = _strip_inner_frame_dles(buf)
# _strip_inner_frame_dles strips the DLE (0x10) and KEEPS chk_byte.
# chk_byte is the received checksum — never payload — so remove it.
# This is correct for all values in {0x02, 0x03, 0x04}.
if stripped:
stripped = stripped[:-1]
return stripped
else:
return _strip_inner_frame_dles(relevant)
# ── Filename helper ───────────────────────────────────────────────────────────
_INSTANTEL_EPOCH = datetime.datetime(1985, 1, 1, 0, 0, 0)
"""
Instantel timestamp epoch January 1, 1985, 00:00:00 local time.
Confirmed 2026-04-21: stem values for 6 independent events (April 19, 2026)
all converge to this epoch when decoded as floor(seconds_since_epoch / 1296).
1985 is the year Instantel was founded.
"""
_STEM_UNIT_SEC = 1296 # = 36^2 seconds ≈ 21.6 minutes per stem unit
_STEM_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
# ── Waveform file extension encoding ─────────────────────────────────────────
#
# NEW FIRMWARE (V10.72+) — FULLY DECODED (confirmed 2026-04-21, 10-year archive):
#
# Extension format: AB0T (4 characters)
# AB = 2-char base-36 encoding of (seconds_since_epoch % 1296)
# i.e. the number of seconds into the current 21.6-minute stem window
# Range: 0 ("00") to 1295 ("ZZ")
# 0 = always literal '0'
# T = event type: 'W' = Full Waveform, 'H' = Full Histogram
#
# Combined with the 4-char stem (which encodes seconds_since_epoch // 1296),
# the FULL filename gives a second-resolution timestamp:
# total_seconds = stem_val * 1296 + ab_val
# timestamp = EPOCH + timedelta(seconds=total_seconds)
#
# Verified against three S353L4H0 events (all three match to the second):
# S353L4H0.3M0W Full Waveform 2025-06-23 13:57:22 AB=3M=130 ✓
# S353L4H0.8S0H Full Histogram 2025-06-23 14:00:28 AB=8S=316 ✓
# S353L4H0.9X0W Full Waveform 2025-06-23 14:01:09 AB=9X=357 ✓
#
# OLD FIRMWARE (S338, 3-char extensions ending in '0') — UNKNOWN:
# Observed (old firmware / manual downloads): .440, .470, .7M0, .9T0, .EI0, etc.
# The V10.72 formula does NOT apply to these.
# Extension is NOT recording mode (refuted 2026-04-21: continuous → .EI0, not .9T0).
# blastware_filename() computes the correct AB0 extension for V10.72 firmware.
#
# WRONG earlier assumption (do not re-introduce):
# Extension was believed to encode recording mode × sample rate.
# Refuted by continuous-mode event producing .EI0 instead of .9T0.
def _make_stem(ts_local: datetime.datetime) -> str:
"""
Encode a local timestamp as a 4-character uppercase base-36 stem.
Algorithm (confirmed 2026-04-21 from 6 known file/timestamp pairs):
stem_int = floor((ts_local - Jan_1_1985_midnight_local) / 1296_seconds)
stem = 4-char uppercase base-36 encoding of stem_int
Unit = 36² = 1296 seconds 21.6 minutes. Events within the same 1296-second
window receive the same stem; their extension distinguishes them.
"""
delta_sec = int((ts_local - _INSTANTEL_EPOCH).total_seconds())
n = delta_sec // _STEM_UNIT_SEC
s = ""
for _ in range(4):
s = _STEM_CHARS[n % 36] + s
n //= 36
return s
def blastware_filename(event: Event, serial: str, ach: bool = False) -> str:
"""
Return the correct Blastware filename for an event.
CONFIRMED 2026-04-22 verified against 3,248 files from a 10-year archive.
Filename format: <prefix_letter><serial3><stem><AB>0[T]
where:
prefix_letter = chr(ord('B') + floor(serial_numeric / 1000))
encodes the production generation (batch of 1000 units)
e.g. BE6907H, BE11529M, BE14036P, BE18003T
serial3 = f"{serial_numeric % 1000:03d}"
last 3 digits of numeric serial, zero-padded
stem = 4-char base-36 of floor(total_seconds / 1296)
encodes which 21.6-minute window the event fell in
AB = 2-char base-36 of (total_seconds % 1296)
encodes seconds within the window (01295)
0 = always literal digit zero
T = 'W' or 'H' ONLY appended for call-home (ACH) downloads (ach=True).
Manual / direct downloads produce a 3-char extension (AB0) with no type char.
Call-home downloads produce a 4-char extension (AB0W or AB0H).
total_seconds = (event_local_time 1985-01-01T00:00:00_local) in seconds
The 10-year production archive contains only call-home files (all end in W or H).
Manual Blastware downloads produce 3-char extensions the same AB0 prefix but
without the trailing type character.
Micromate Series 4 uses a completely different naming scheme (literal datetime
in filename); this function does not apply to Micromate units.
Args:
event: Event object with timestamp set.
serial: Device serial number string (e.g. "BE11529").
ach: If True, append W/H type character (call-home style).
If False (default), omit type character (direct download style).
Returns:
Filename string, e.g. "M529LIY6.CE0" (direct) or "M529LIY6.CE0H" (ACH).
"""
# ── Serial prefix ──────────────────────────────────────────────────────────
serial_digits = "".join(c for c in serial if c.isdigit())
if len(serial_digits) >= 1:
serial_numeric = int(serial_digits)
generation = serial_numeric // 1000
prefix_letter = chr(ord('B') + generation)
serial3 = f"{serial_numeric % 1000:03d}"
else:
prefix_letter = "M" # fallback
serial3 = "000"
prefix = prefix_letter + serial3
# ── Stem + AB extension from timestamp ────────────────────────────────────
if event.timestamp is not None:
try:
ts_local = datetime.datetime(
event.timestamp.year, event.timestamp.month, event.timestamp.day,
event.timestamp.hour, event.timestamp.minute, event.timestamp.second,
)
delta_sec = int((ts_local - _INSTANTEL_EPOCH).total_seconds())
stem = _make_stem(ts_local)
ab_val = delta_sec % _STEM_UNIT_SEC
ab_str = _STEM_CHARS[ab_val // 36] + _STEM_CHARS[ab_val % 36]
except (ValueError, TypeError, AttributeError):
stem = "0000"
ab_str = "00"
else:
stem = "0000"
ab_str = "00"
# ── Type character (ACH only) ─────────────────────────────────────────────
if ach:
if getattr(event, 'recording_mode', None) in (3, 4): # Histogram / Hist+Cont
type_char = 'H'
else:
type_char = 'W'
ext = f".{ab_str}0{type_char}"
else:
ext = f".{ab_str}0"
return prefix + stem + ext
# ── A5 frame classifier ───────────────────────────────────────────────────────────
# ASCII markers that identify a compliance-config / metadata frame.
# These strings appear in the A5 bulk stream as part of the device's
# compliance setup payload. They should NEVER appear in raw ADC waveform
# frames (which are binary-heavy, < 20 % printable ASCII).
_METADATA_FRAME_MARKERS = (
b"Project:",
b"Client:",
b"Standard Recording Setup",
b"Extended Notes",
b"User Name:",
b"Seis Loc:",
)
def classify_frame(frame: S3Frame) -> str:
"""
Classify an A5 bulk waveform stream frame by its content.
Returns one of:
"terminator" page_key == 0x0000
"probe_or_strt" data contains b"STRT\xff\xfe" (the initial probe response)
"metadata" data contains ASCII compliance-config markers
"waveform" predominantly binary (< 20 % printable ASCII)
"unknown" none of the above criteria matched
Used by write_blastware_file() to filter non-waveform frames out of
the reconstructed body so that metadata blocks (Project:, Client:, )
and spurious STRT records do not corrupt the output file.
"""
if frame.page_key == 0x0000:
return "terminator"
data = bytes(frame.data)
if b"STRT\xff\xfe" in data:
return "probe_or_strt"
if any(m in data for m in _METADATA_FRAME_MARKERS):
return "metadata"
if len(data) > 0:
printable = sum(1 for b in data if 32 <= b < 127)
if printable / len(data) < 0.20:
return "waveform"
return "unknown"
# ── Waveform file writer ───────────────────────────────────────────────────────────
def write_blastware_file(
event: Event,
a5_frames: list[S3Frame],
path: Union[str, Path],
) -> None:
"""
Write a Blastware waveform file from a downloaded event.
Args:
event: Event object (populated by get_events() or download_waveform()).
Used for the STRT record (key, rectime) and footer timestamps.
a5_frames: Complete A5 frame list INCLUDING the terminator frame
(page_key=0x0000). Pass include_terminator=True to
read_bulk_waveform_stream() when collecting frames.
Must have at least 2 frames (probe + terminator).
path: Destination file path. Parent directory must exist.
Extension should be set via blastware_filename().
File layout:
[22B header] [21B STRT] [body bytes] [26B footer]
Raises:
ValueError: if a5_frames is empty or has no terminator (page_key=0).
OSError: if the file cannot be written.
Confirmed correct waveform body reconstruction against M529LIY6 (example event) (2026-04-21).
"""
if not a5_frames:
raise ValueError("a5_frames must not be empty")
path = Path(path)
# ── Extract STRT record from probe frame ────────────────────────────────
# The STRT record (21 bytes) lives verbatim inside A5[0].data[7:].
# It is stored as-is in the waveform file — do NOT reconstruct it from Event
# fields, as bytes [10:14] and [14:20] contain device-specific values
# (not simply key4 repeated or zero-padded). Confirmed 2026-04-21.
#
# STRT layout (21 bytes, observed in M529LIY6 files):
# [0:4] b'STRT'
# [4:6] 0xff 0xfe (fixed)
# [6:10] key4 (event key)
# [10:14] device-specific field (NOT a key4 repeat)
# [14:20] device-specific fields (NOT zeros)
# [20] rectime uint8 seconds
# Extract STRT from the DLE-stripped probe frame.
#
# frame.data[7:] is the raw wire representation; it may contain DLE+{02,03,04}
# inner-frame pairs that S3FrameParser preserves as two literal bytes. The
# Blastware file stores the stripped form, so we must strip before extracting.
#
# Example (M529LK0Y, 2026-04-21): STRT contains value 0x02 encoded as [10 02]
# on the wire. Without stripping, STRT is 22 raw bytes → write_blastware_file writes the
# DLE prefix into the file AND begins the body 1 byte too early (probe_skip off
# by 1). Stripping fixes both.
#
# probe_skip must be computed in the RAW frame.data domain (it is used as the
# `skip` argument to _frame_body_bytes which operates on raw frame.data).
# We walk the raw bytes counting stripped bytes until we have passed
# strt_pos + 21 stripped bytes, giving the raw offset of the first body byte.
w0_raw = bytes(a5_frames[0].data[7:])
w0_stripped = _strip_inner_frame_dles(w0_raw)
strt_pos_stripped = w0_stripped.find(b"STRT")
if strt_pos_stripped >= 0:
strt = bytes(w0_stripped[strt_pos_stripped : strt_pos_stripped + 21])
# Walk raw bytes to find the raw-domain end of the STRT (= body start).
target_stripped = strt_pos_stripped + 21
stripped_so_far = 0
raw_i = 0
while stripped_so_far < target_stripped and raw_i < len(w0_raw):
if (w0_raw[raw_i] == 0x10
and raw_i + 1 < len(w0_raw)
and w0_raw[raw_i + 1] in {0x02, 0x03, 0x04}):
raw_i += 2 # DLE pair → 1 stripped byte, 2 raw bytes
else:
raw_i += 1 # normal byte → 1 stripped byte, 1 raw byte
stripped_so_far += 1
probe_skip = 7 + raw_i # raw bytes to skip: 7 header + raw STRT length
else:
# Fallback: construct a minimal STRT if probe frame lacks it
key4 = event._waveform_key if hasattr(event, '_waveform_key') and event._waveform_key else bytes(4)
rectime = event.rectime_seconds if event.rectime_seconds is not None else 0
strt = b"STRT" + b"\xff\xfe" + key4 + bytes(14) + bytes([rectime & 0xFF])
probe_skip = 7 + 21
log.warning(
"write_blastware_file: strt_pos_stripped=%d probe_skip=%d "
"probe_data_len=%d strt_hex=%s",
strt_pos_stripped if strt_pos_stripped >= 0 else -1,
probe_skip,
len(a5_frames[0].data),
strt.hex() if len(strt) >= 4 else "(short)",
)
if len(strt) != 21:
raise ValueError(f"STRT record must be 21 bytes, got {len(strt)}")
# ── Build waveform file header ─────────────────────────────────────────────────────
header = _FILE_HEADER_PREFIX + _WAVEFORM_TYPE_TAG
assert len(header) == _WAVEFORM_HEADER_SIZE, f"Waveform header must be {_WAVEFORM_HEADER_SIZE} bytes"
# ── Build body from A5 frames ────────────────────────────────────────────
# The waveform body is reconstructed from ALL A5 frames (data + terminator).
# The terminator frame's contribution includes the 26-byte footer at its end.
#
# Reconstruction layout (confirmed from M529LIY6 captures, 2026-04-21):
# all_bytes = contributions from A5[0..N] + terminator_contribution
# body = all_bytes[:-26] (everything except the last 26 bytes)
# footer = all_bytes[-26:] (last 26 bytes = the waveform file footer)
#
# The footer bytes come directly from the terminator frame's inner content —
# using them verbatim ensures timestamps match the device's recorded values.
# Separate terminator from data frames.
# Search from the FRONT for the first terminator (page_key == 0x0000).
# Do NOT use a5_frames[-1] — if _a5_frames contains stray frames from a
# subsequent event (a known get_events side-effect), the last frame will
# not be the terminator and the footer will be mis-identified.
term_idx: Optional[int] = None
for _i, _f in enumerate(a5_frames):
if _f.page_key == 0x0000:
term_idx = _i
break
if term_idx is not None:
body_frames = a5_frames[:term_idx]
term_frame = a5_frames[term_idx]
else:
body_frames = a5_frames
term_frame = None
log.warning(
"write_blastware_file: %d body_frames term_idx=%s",
len(body_frames),
str(term_idx) if term_idx is not None else "None",
)
all_bytes = bytearray()
for fi, frame in enumerate(body_frames):
# All body frames contribute to the waveform body — no frames are skipped.
#
# Over TCP via cellular modem, _recv_5a_batch() correctly collects all
# A5 frames per chunk request (the device's ~1100-byte RS-232 response
# is forwarded as ~2 TCP segments of ~550 bytes each, each parsed as a
# separate S3 frame). ALL of these frames contain ADC body data and
# must be included in the file — confirmed from 4-27-26 TCP capture
# analysis: contributions from all 14 frames → 6821 bytes → file 6864 bytes.
#
# Skip amounts (offsets into frame.data):
# fi=0 (probe): probe_skip — skips the type_tag header + STRT record
# fi=1: 13 — 7-byte frame.data prefix + 6 inner header bytes
# fi>=2: 12 — 7-byte frame.data prefix + 5 inner header bytes
if fi == 0:
skip = probe_skip
elif fi == 1:
skip = 13
else:
skip = 12
contribution = _frame_body_bytes(frame, skip)
log.warning("write_blastware_file: fi=%d skip=%d raw_data=%d contribution=%d",
fi, skip, len(frame.data), len(contribution))
all_bytes.extend(contribution)
# Terminator contributes its content, which ends with the 26-byte footer.
# skip=11 (not 12) because the terminator's inner frame header is 4 bytes,
# one shorter than chunk frames' 5-byte inner header. Confirmed 2026-04-21.
if term_frame is not None:
term_contribution = _frame_body_bytes(term_frame, 11)
log.warning(
"write_blastware_file: term_frame data_len=%d skip=11 "
"contribution_len=%d first8=%s",
len(term_frame.data),
len(term_contribution),
term_contribution[:8].hex() if len(term_contribution) >= 8 else term_contribution.hex(),
)
all_bytes.extend(term_contribution)
log.warning(
"write_blastware_file: all_bytes total=%d last28=%s",
len(all_bytes),
bytes(all_bytes[-28:]).hex() if len(all_bytes) >= 28 else bytes(all_bytes).hex(),
)
if len(all_bytes) >= 26:
body = bytes(all_bytes[:-26])
footer = bytes(all_bytes[-26:])
else:
# Fallback: no terminator or very short stream → build footer from event metadata
body = bytes(all_bytes)
start_dt = _ts_from_model(event.timestamp)
stop_dt: Optional[datetime.datetime] = None
if start_dt is not None and event.rectime_seconds:
stop_dt = start_dt + datetime.timedelta(seconds=event.rectime_seconds)
footer = (
b"\x0e\x08"
+ _encode_ts_be(start_dt)
+ _encode_ts_be(stop_dt)
+ b"\x00\x01\x00\x02\x00\x00"
+ b"\x00\x00" # CRC placeholder
)
# ── Write file ───────────────────────────────────────────────────────────
with open(path, "wb") as f:
f.write(header)
f.write(strt)
f.write(body)
f.write(footer)
def read_blastware_file(path: Union[str, Path]) -> Event:
"""
Parse a Blastware waveform file into an Event object.
NOT YET IMPLEMENTED.
Args:
path: Path to the waveform file.
Returns:
Event object with waveform data populated.
Raises:
NotImplementedError: always (pending implementation).
"""
raise NotImplementedError("read_blastware_file() is not yet implemented")
# ── MLG file writer ───────────────────────────────────────────────────────────
def _build_mlg_header(serial: str) -> bytes:
"""
Build the 308-byte MLG file header.
Header structure (confirmed from BE11529.MLG binary inspection):
Offset 0x00: 10 00 01 80 00 00 Instantel\x00 07 2c 22 01 0e a0 (22B)
Offset 0x16: ... (16B unknown observed as zeros in BE11529.MLG)
Offset 0x2A: serial number (8 bytes, null-padded ASCII)
... rest zero-padded to 308 bytes
The serial string "BE11529" appears at offset 0x2A (42 decimal).
"""
buf = bytearray(_MLG_HEADER_SIZE)
# Common prefix + MLG type tag
prefix = _FILE_HEADER_PREFIX + _MLG_TYPE_TAG # 22 bytes
buf[0:len(prefix)] = prefix
# Serial number at offset 0x2A
serial_bytes = serial.encode("ascii", errors="replace")[:8]
serial_padded = serial_bytes.ljust(8, b"\x00")
buf[0x2A : 0x2A + 8] = serial_padded
return bytes(buf)
def _build_mlg_record(
entry: MonitorLogEntry,
serial: str,
) -> bytes:
"""
Build one 292-byte MLG record from a MonitorLogEntry.
Record layout (confirmed from BE11529.MLG binary inspection):
[0:2] CRC 2-byte CRC (algorithm unknown; written as 0x0000)
[2:6] marker 22 01 0e 80
[6:14] ts1 8B big-endian start timestamp
[14:22] ts2 8B big-endian stop timestamp
[22:26] flags 4B record flags (see MLG_FLAGS_* constants)
[26:36] serial 10B null-padded serial number
[36:] text for triggered events: [0x08][8B ts1_copy]["Geo: X.XXX in/s"]
for monitoring intervals: b"" or minimal separator
[... zero-padded to 292 bytes]
Flags based on entry type:
- MonitorLogEntry with start_time only (no stop_time): MLG_FLAGS_START_ONLY
- MonitorLogEntry with both times and geo_threshold_ips set: MLG_FLAGS_TRIGGER
- MonitorLogEntry with both times (monitoring interval): MLG_FLAGS_INTERVAL
The triggered-event text block (flags = MLG_FLAGS_TRIGGER):
[0x08] [ts1: 8B] [ASCII "Geo: X.XXX in/s\x00"]
Confirmed from BE11529.MLG records at offset 0x0134 and 0x0258.
"""
buf = bytearray(_MLG_RECORD_SIZE)
start_dt = (
datetime.datetime(
entry.start_time.year, entry.start_time.month, entry.start_time.day,
entry.start_time.hour, entry.start_time.minute, entry.start_time.second,
)
if entry.start_time else None
)
stop_dt = (
datetime.datetime(
entry.stop_time.year, entry.stop_time.month, entry.stop_time.day,
entry.stop_time.hour, entry.stop_time.minute, entry.stop_time.second,
)
if entry.stop_time else None
)
# [0:2] CRC placeholder
buf[0:2] = b"\x00\x00"
# [2:6] Record marker
buf[2:6] = _MLG_RECORD_MARKER
# [6:14] ts1
buf[6:14] = _encode_ts_be(start_dt)
# [14:22] ts2
buf[14:22] = _encode_ts_be(stop_dt)
# [22:26] flags
if stop_dt is None:
flags = MLG_FLAGS_START_ONLY
elif entry.geo_threshold_ips is not None:
flags = MLG_FLAGS_TRIGGER
else:
flags = MLG_FLAGS_INTERVAL
buf[22:26] = flags
# [26:36] serial (10B null-padded)
serial_bytes = serial.encode("ascii", errors="replace")[:10]
buf[26 : 26 + len(serial_bytes)] = serial_bytes
# [36:] text content
pos = 36
if flags == MLG_FLAGS_TRIGGER:
# Extra ts1 copy: [0x08][ts1: 8B]
buf[pos] = 0x08
pos += 1
buf[pos : pos + 8] = _encode_ts_be(start_dt)
pos += 8
if entry.geo_threshold_ips is not None:
geo_text = f"Geo: {entry.geo_threshold_ips:.3f} in/s\x00".encode("ascii")
buf[pos : pos + len(geo_text)] = geo_text
pos += len(geo_text)
return bytes(buf)
def write_mlg(
entries: list[MonitorLogEntry],
serial: str,
path: Union[str, Path],
) -> None:
"""
Write a Blastware .MLG monitor log file.
Args:
entries: List of MonitorLogEntry objects (from get_monitor_log_entries()).
Each entry produces one 292-byte record in the file.
serial: Device serial number string (e.g. "BE11529").
Written to the file header and each record.
path: Destination file path. Extension is not enforced use ".MLG".
File layout:
[308B header] [N × 292B records]
Note: The 2-byte CRC at the start of each record is written as 0x0000.
The CRC algorithm is unknown (see module docstring).
Raises:
OSError: if the file cannot be written.
"""
path = Path(path)
header = _build_mlg_header(serial)
with open(path, "wb") as f:
f.write(header)
for entry in entries:
record = _build_mlg_record(entry, serial)
f.write(record)
def read_mlg(path: Union[str, Path]) -> list[MonitorLogEntry]:
"""
Parse a Blastware .MLG file into a list of MonitorLogEntry objects.
NOT YET IMPLEMENTED.
Args:
path: Path to the .MLG file.
Returns:
List of MonitorLogEntry objects.
Raises:
NotImplementedError: always (pending implementation).
"""
raise NotImplementedError("read_mlg() is not yet implemented")
+251 -538
View File
File diff suppressed because it is too large Load Diff
+4 -10
View File
@@ -457,11 +457,6 @@ class S3Frame:
page_lo: int # PAGE_LO from header
data: bytes # payload data section (payload[5:], checksum already stripped)
checksum_valid: bool
chk_byte: int = 0 # actual checksum byte received from wire (body[-1])
# needed for waveform file reconstruction: when the last data byte
# is 0x10 and chk_byte ∈ {0x02, 0x03, 0x04}, the DLE+chk pair
# must be included in the DLE-strip operation to correctly
# reconstruct the Blastware binary body.
@property
def page_key(self) -> int:
@@ -597,10 +592,9 @@ class S3FrameParser:
return None
return S3Frame(
sub = raw_payload[2],
page_hi = raw_payload[3],
page_lo = raw_payload[4],
data = raw_payload[5:],
sub = raw_payload[2],
page_hi = raw_payload[3],
page_lo = raw_payload[4],
data = raw_payload[5:],
checksum_valid = (chk_received == chk_computed),
chk_byte = chk_received,
)
+6 -101
View File
@@ -269,7 +269,7 @@ class ChannelConfig:
label: str # e.g. "Tran", "Vert", "Long", "MicL" ✅
trigger_level: float # in/s (geo) or psi (MicL) ✅
alarm_level: float # in/s (geo) or psi (MicL) ✅
max_range: float # hardware/firmware sensitivity constant (e.g. 6.206053) ✅ confirmed same on all units
max_range: float # full-scale calibration constant (e.g. 6.206) 🔶
unit_label: str # e.g. "in./s" or "psi" ✅
@@ -338,34 +338,15 @@ class ComplianceConfig:
raw: Optional[bytes] = None # full 2090-byte payload (for debugging)
# Recording parameters (✅ CONFIRMED from §7.6)
recording_mode: Optional[int] = None # uint8: 0x00=Single Shot, 0x01=Continuous,
# 0x03=Histogram, 0x04=Histogram+Continuous ✅ confirmed 2026-04-20
# Read (E5): data[anchor_pos - 8] (6-byte anchor)
# Write (SUB 71): data[anchor_pos - 7]
sample_rate: Optional[int] = None # sps (1024, 2048, 4096)
histogram_interval_sec: Optional[int] = None # uint16 BE, seconds ✅ confirmed 2026-04-20
# anchor_pos - 4 (same offset in read & write)
# Valid values: 2, 5, 15, 60, 300, 900
# Mode-gated: only active in Histogram/Histogram+Continuous
record_time: Optional[float] = None # seconds (e.g. 3.0, 5.0, 8.0, 10.0)
record_time: Optional[float] = None # seconds (7.0, 10.0, 13.0, etc.)
sample_rate: Optional[int] = None # sps (1024, 2048, 4096, etc.) — NOT YET FOUND ❓
# Trigger/alarm levels (✅ CONFIRMED per-channel at §7.6)
# For now we store the first geo channel (Transverse) as representatives;
# full per-channel data would require structured Channel objects.
trigger_level_geo: Optional[float] = None # in/s (first geo channel)
alarm_level_geo: Optional[float] = None # in/s (first geo channel)
geo_adc_scale: Optional[float] = None # ADC-to-velocity scale factor (float32 at Tran+28) ✅
# = inverse sensitivity = 1/sensitivity (in/s per V)
# Formula (Interface Handbook §4.5): Range = 1.61133 V × scale_factor
# → 1.61133 × 6.206053 = 10.000 in/s (Normal range) ✅
# Firmware uses: PPV (in/s) = ADC_voltage (V) × 6.206053
# Identical on BE11529 and BE18189 — same Instantel geophone hardware.
# NOT a user-configurable setting. Must NOT be written.
geo_range: Optional[int] = None # range/sensitivity selector — CONFIRMED 2026-04-20
# 0x00 = Normal 10.000 in/s (standard gain)
# 0x01 = Sensitive 1.250 in/s (high gain)
# Offset: Tran+33 in both E5 read and SUB 71 write payloads
# (same 2126-byte buffer is round-tripped; applied to Tran/Vert/Long)
trigger_level_geo: Optional[float] = None # in/s (first geo channel)
alarm_level_geo: Optional[float] = None # in/s (first geo channel)
max_range_geo: Optional[float] = None # in/s full-scale range
# Project/setup strings (sourced from E5 / SUB 71 write payload)
# These are the FULL project metadata from compliance config,
@@ -378,78 +359,6 @@ class ComplianceConfig:
notes: Optional[str] = None # extended notes / additional info
# ── Call Home Config ──────────────────────────────────────────────────────────
@dataclass
class CallHomeConfig:
"""
Auto Call Home (ACH) configuration from SUB 0x2C (response 0xD3).
Read with a standard two-step protocol (probe offset=0x00, data offset=0x7C).
Written via SUB 0x7E (write, 127-byte payload) + SUB 0x7F (confirm).
Confirmed from 4-20-26 call home settings captures (11 BW + S3 capture pairs).
Raw payload layout (data[11:] from S3 response, 125 bytes):
[0] 0x00 header byte
[1] 0x7C = 124 inner length (= offset for SUB 0x7E write - 2)
[2] 0xDC constant
[3:5] 0x00 0x00 padding
[5] auto_call_home_enabled (0x00=off, 0x01=on)
[6:46] dial_string 40-byte null-padded ASCII
[46:87] auto_answer_raw AT command strings (not decoded) present
[87] after_event_recorded (0x01=on, 0x00=off)
[91] at_specified_times (0x01=on, 0x00=off)
[93] time1_enabled (0x01=on, 0x00=off)
[95] time2_enabled (0x01=on, 0x00=off)
[101] time1_hour uint8 decimal 0-23
[102] time1_min uint8 decimal 0-59
[105] time2_hour uint8 decimal 0-23
[106] time2_min uint8 decimal 0-59
[117] DLE prefix (0x10) DLE-escaped num_retries=3 (0x03)
[118] 0x03 device stores/returns 0x03 DLE-escaped
[120] time_between_retries_sec uint8 (= 0x0F = 15 s default)
[122] wait_for_connection_sec uint8 (= 0x3C = 60 s default)
[124] warm_up_time_sec uint8 (= 0x3C = 60 s default)
Write payload = raw 125 bytes + b'\\x00\\x00' (2 trailing zeros) = 127 bytes.
Offset for SUB 0x7E: data[1] + 2 = 0x7C + 2 = 0x7E = 126.
Note on DLE-escaped 0x03: The device's S3 response DLE-escapes ETX (0x03)
bytes as \\x10\\x03. The S3FrameParser preserves both bytes in frame.data.
Subsequent fields after offset 117 are therefore at raw_offset = logical+1.
The raw payload must be round-tripped verbatim in write; do NOT reapply DLE
destuffing or stripping.
"""
raw: Optional[bytes] = None # raw 125-byte read payload (for round-trip write)
# ── Main enable ──────────────────────────────────────────────────────────
auto_call_home_enabled: Optional[bool] = None # raw[5] ✅
# ── Dial string ──────────────────────────────────────────────────────────
dial_string: Optional[str] = None # raw[6:46] 40-byte null-padded ASCII ✅
# ── When to call ─────────────────────────────────────────────────────────
after_event_recorded: Optional[bool] = None # raw[87] ✅
at_specified_times: Optional[bool] = None # raw[91] ✅
# ── Time slot 1 ──────────────────────────────────────────────────────────
time1_enabled: Optional[bool] = None # raw[93] ✅
time1_hour: Optional[int] = None # raw[101] 0-23 ✅
time1_min: Optional[int] = None # raw[102] 0-59 ✅
# ── Time slot 2 ──────────────────────────────────────────────────────────
time2_enabled: Optional[bool] = None # raw[95] ✅
time2_hour: Optional[int] = None # raw[105] 0-23 ✅
time2_min: Optional[int] = None # raw[106] 0-59 ✅
# ── Retry / timeout settings (read-only; not writable via set_call_home_config) ──
num_retries: Optional[int] = None # raw[117:119]=10 03 → value 3 ✅
time_between_retries_sec: Optional[int] = None # raw[120] (shifted +1 by DLE) ✅
wait_for_connection_sec: Optional[int] = None # raw[122] ✅
warm_up_time_sec: Optional[int] = None # raw[124] ✅
# ── Event ─────────────────────────────────────────────────────────────────────
@dataclass
@@ -493,10 +402,6 @@ class Event:
# Set by get_events(); required by download_waveform().
_waveform_key: Optional[bytes] = field(default=None, repr=False)
# Raw A5 frames from the full bulk waveform download (full_waveform=True).
# Populated by get_events() when full_waveform=True; used by write_blastware_file().
_a5_frames: Optional[list] = field(default=None, repr=False)
def __str__(self) -> str:
ts = str(self.timestamp) if self.timestamp else "no timestamp"
ppv = ""
+52 -263
View File
@@ -65,7 +65,6 @@ SUB_WAVEFORM_HEADER = 0x0A
SUB_WAVEFORM_RECORD = 0x0C
SUB_BULK_WAVEFORM = 0x5A
SUB_COMPLIANCE = 0x1A
SUB_CALL_HOME = 0x2C # Call home config read → response 0xD3 ✅
SUB_UNKNOWN_2E = 0x2E
# Write command SUBs (= Read SUB + 0x60, confirmed from BW captures 3-11-26)
@@ -79,10 +78,6 @@ SUB_WRITE_CONFIRM_C = 0x74 # Confirm C — sent after 69 ✅
SUB_TRIGGER_CONFIG_WRITE = 0x82 # Write trigger config (0x22 + 0x60) ✅
SUB_TRIGGER_CONFIRM = 0x83 # Confirm trigger write ✅
# Call home write SUBs (confirmed from 4-20-26 call home settings captures)
SUB_CALL_HOME_WRITE = 0x7E # Write call home config → response 0x81 ✅
SUB_CALL_HOME_CONFIRM = 0x7F # Confirm call home write → response 0x80 ✅
# Monitoring control SUBs (confirmed from 4-8-26/2ndtry BW TX capture)
SUB_START_MONITORING = 0x96 # Start monitoring → response 0x69 ✅
SUB_STOP_MONITORING = 0x97 # Stop monitoring → response 0x68 ✅
@@ -114,7 +109,6 @@ DATA_LENGTHS: dict[int, int] = {
# SUB_WAVEFORM_HEADER (0x0A) is VARIABLE — length read from probe response
# data[4]. Do NOT add it here; use read_waveform_header() instead. ✅
SUB_WAVEFORM_RECORD: 0xD2, # 210-byte waveform/histogram record ✅
SUB_CALL_HOME: 0x7C, # 124-byte call home config ✅ (confirmed 4-20-26)
SUB_UNKNOWN_2E: 0x1A, # 26 bytes, purpose TBD 🔶
0x09: 0xCA, # 202 bytes, purpose TBD 🔶
# SUB_COMPLIANCE (0x1A) uses a multi-step sequence with a 2090-byte total;
@@ -126,12 +120,24 @@ DATA_LENGTHS: dict[int, int] = {
_BULK_CHUNK_OFFSET = 0x1004 # offset field for probe + all regular chunk requests ✅
_BULK_TERM_OFFSET = 0x005A # offset field for termination request ✅
_BULK_COUNTER_STEP = 0x0400 # chunk counter increment per chunk ✅
# Chunk counter formula: key4[2:4] + (chunk_num - 1) * 0x0400
# where key4[2:4] is the event's circular-buffer base offset ((key4[2]<<8)|key4[3]).
# Earlier captures showed 0x1004 for chunk 1 of key 01110000 — that was a Blastware
# artifact. For keys where key4[2:4] != 0x0000 (e.g. key 01111884) the old
# "n * 0x0400" formula sends counters from the wrong buffer region and the device
# returns data from a different event. Confirmed correct 2026-04-24.
# Chunk counter formula: chunk_num * 0x0400 for ALL chunks including chunk 1.
# Earlier captures showed 0x1004 for chunk 1 — that was a Blastware artifact, not a
# protocol requirement. Confirmed 2026-04-06: 0x0400 for chunk 1 works; 0x1004
# causes a 120-second device timeout. Formula n * 0x0400 is used for all chunks.
# Needles for identifying the 5A metadata frame.
# Devices with no Project/Client/etc. configured omit those label strings,
# so b"Geo: " (geo threshold — always present) is the universal fallback.
# Mirror of _METADATA_FRAME_NEEDLES in client.py; kept here for the
# stop_after_metadata check in read_bulk_waveform_stream.
_METADATA_FRAME_NEEDLES: tuple[bytes, ...] = (
b"Project:",
b"Client:",
b"User Name:",
b"Seis Loc:",
b"Extended Notes",
b"Geo: ",
)
# Default timeout values (seconds).
# MiniMate Plus is a slow device — keep these generous.
@@ -528,9 +534,7 @@ class MiniMateProtocol:
*,
stop_after_metadata: bool = True,
max_chunks: int = 32,
include_terminator: bool = False,
extra_chunks_after_metadata: int = 1,
) -> list[S3Frame]:
) -> list[bytes]:
"""
Download the SUB 5A (BULK_WAVEFORM_STREAM) A5 frames for one event.
@@ -546,9 +550,7 @@ class MiniMateProtocol:
4. Termination (offset=_BULK_TERM_OFFSET, counter=last+_BULK_COUNTER_STEP)
Device responds with a final A5 frame (page_key=0x0000).
By default the termination frame (page_key=0x0000) is NOT included in the
returned list. Pass include_terminator=True to append it; the blastware_file
writer needs the terminator frame's body to reconstruct the waveform file footer.
The termination frame (page_key=0x0000) is NOT included in the returned list.
Args:
key4: 4-byte waveform key from EVENT_HEADER (1E).
@@ -558,16 +560,11 @@ class MiniMateProtocol:
hundred KB). Set False to download everything.
max_chunks: Safety cap on the number of chunk requests sent
(default 32; a typical event uses 9 large frames).
include_terminator: If True, append the terminator A5 frame
(page_key=0x0000) to the returned list. The
terminator carries the waveform file footer bytes.
Default False preserves existing caller behaviour.
Returns:
List of S3Frame objects from each A5 response frame. Frame indices
match the request sequence: index 0 = probe response, index 1 = first
chunk, etc. If include_terminator=True, the last element is the
terminator frame (page_key=0x0000).
List of raw data bytes from each A5 response frame (not including
the terminator frame). Frame indices match the request sequence:
index 0 = probe response, index 1 = first chunk, etc.
Raises:
ProtocolError: on timeout, bad checksum, or unexpected SUB.
@@ -582,24 +579,16 @@ class MiniMateProtocol:
raise ValueError(f"waveform key must be 4 bytes, got {len(key4)}")
rsp_sub = _expected_rsp_sub(SUB_BULK_WAVEFORM) # 0xFF - 0x5A = 0xA5
frames_data: list[S3Frame] = []
frames_data: list[bytes] = []
counter = 0
# BW counter formula (confirmed from 4-3-26 capture for key 0111245a,
# and empirical live-device test 2026-04-06 for key 01110000):
# counter for chunk n = max(key4[2:4], 0x0400) + (n - 1) * 0x0400
# key4[2:4] is the event's circular-buffer base offset. The max() guard
# ensures chunk 1 never uses counter=0x0000 (which equals the probe address
# and causes the device to re-return STRT record data for the first chunk).
_key4_offset = (key4[2] << 8) | key4[3]
# ── Step 1: probe ────────────────────────────────────────────────────
log.debug("5A probe key=%s key4_offset=0x%04X", key4.hex(), _key4_offset)
log.debug("5A probe key=%s", key4.hex())
params = bulk_waveform_params(key4, 0, is_probe=True)
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
self._parser.reset() # reset bytes_fed counter before probe recv
try:
probe_batch = self._recv_5a_batch(rsp_sub)
rsp = self._recv_one(expected_sub=rsp_sub, reset_parser=False)
except TimeoutError:
log.warning(
"5A probe TIMED OUT for key=%s"
@@ -607,54 +596,23 @@ class MiniMateProtocol:
key4.hex(), self._parser.bytes_fed,
)
raise
frames_data.extend(probe_batch)
log.debug(
"5A probe: %d frame(s) page_keys=%s",
len(probe_batch),
[f"0x{f.page_key:04X}" for f in probe_batch],
)
# Log probe frame size for diagnostics.
# The device always needs extra_chunks_after_metadata chunks after the
# metadata frame before termination to prime the valid waveform footer.
# This holds regardless of TCP frame size (1-frame vs 2-frame mode).
_effective_extra_chunks = extra_chunks_after_metadata
log.warning(
"5A probe data_len=%d effective_extra_chunks=%d",
len(probe_batch[0].data),
_effective_extra_chunks,
)
frames_data.append(rsp.data)
log.debug("5A A5[0] page_key=0x%04X %d bytes", rsp.page_key, len(rsp.data))
# ── Step 2: chunk loop ───────────────────────────────────────────────
# Counter formula: _chunk_base + (chunk_num - 1) * 0x0400
# where _chunk_base = max(key4[2:4], 0x0400).
#
# For events with key4[2:4] != 0 (e.g. key 0111245a, offset 0x245a):
# _chunk_base = 0x245a → chunk 1=0x245a, chunk 2=0x285a, ...
# Confirmed from 4-3-26 capture.
#
# For events with key4[2:4] == 0 (e.g. key 01110000):
# _chunk_base = max(0, 0x0400) = 0x0400
# → chunk 1=0x0400, chunk 2=0x0800, ... (= old chunk_num*0x0400)
# CRITICAL: counter=0x0000 (same as the probe) causes the device to
# re-return the STRT record data for chunk 1, making frame 1 look like
# a second probe response (confirmed from server log: frame 1 len=1097,
# contains STRT\xff\xfe, contributes zero body bytes after DLE-strip).
# counter=0x0400 for chunk 1 confirmed working (empirical test 2026-04-06).
_chunk_base = max(_key4_offset, _BULK_COUNTER_STEP)
# Chunk counters are monotonic: chunk_num * 0x0400 for all chunks.
# The 4-2-26 BW TX capture showed 0x1004 for chunk 1, but this is a
# Blastware artifact — the device accepts any counter value and streams
# data regardless. Empirically confirmed 2026-04-06: 0x0400 for chunk 1
# works; 0x1004 causes the device to ignore the frame (timeout).
for chunk_num in range(1, max_chunks + 1):
counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP
counter = chunk_num * _BULK_COUNTER_STEP
params = bulk_waveform_params(key4, counter)
log.debug("5A chunk %d counter=0x%04X", chunk_num, counter)
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
self._parser.reset() # reset bytes_fed for accurate per-chunk count
try:
# Collect ALL frames from this chunk response.
# Over TCP via modem, a single large A5 device response (~1100 bytes
# RS-232) is split across ~2 TCP segments, each parsed as its own
# complete S3 frame. _recv_5a_batch gathers all of them so that
# every subsequent chunk request is paired with the correct response.
batch = self._recv_5a_batch(rsp_sub, first_timeout=10.0)
rsp = self._recv_one(expected_sub=rsp_sub, reset_parser=False, timeout=10.0)
except TimeoutError:
raw = self._parser.bytes_fed
log.warning(
@@ -673,51 +631,24 @@ class MiniMateProtocol:
break
raise
# Process all frames from this batch.
metadata_found = False
for rsp in batch:
log.warning(
"5A RX chunk=%d page_key=0x%04X data_len=%d contains_Project=%s",
chunk_num, rsp.page_key, len(rsp.data), b"Project:" in rsp.data,
)
if rsp.page_key == 0x0000:
# Device unexpectedly terminated mid-stream.
log.debug("5A page_key=0x0000 — device terminated early")
if include_terminator:
frames_data.append(rsp)
return frames_data
frames_data.append(rsp)
if stop_after_metadata and b"Project:" in rsp.data:
metadata_found = True
# Detect metadata frame using the same multi-needle set used by
# _decode_a5_waveform. Devices with no Project/Client/etc.
# configured omit those label strings; b"Geo: " is the fallback.
_is_metadata = any(n in rsp.data for n in _METADATA_FRAME_NEEDLES)
log.warning(
"5A RX chunk=%d page_key=0x%04X data_len=%d is_metadata=%s",
chunk_num, rsp.page_key, len(rsp.data), _is_metadata,
)
if metadata_found:
# Download extra_chunks_after_metadata more chunks after metadata.
# This primes the device to return the valid waveform footer in the
# termination response — without it the terminator carries too few bytes
# (confirmed 2026-04-23). The extra chunk data also belongs in the
# file body (confirmed from TCP capture analysis 2026-04-27).
log.debug("5A metadata found — fetching %d more chunk(s)",
_effective_extra_chunks)
for _extra_n in range(_effective_extra_chunks):
chunk_num += 1
counter = _chunk_base + (chunk_num - 1) * _BULK_COUNTER_STEP
params = bulk_waveform_params(key4, counter)
self._send(build_5a_frame(_BULK_CHUNK_OFFSET, params))
try:
extra_batch = self._recv_5a_batch(rsp_sub, first_timeout=10.0)
for ef in extra_batch:
log.debug(
"5A extra chunk page_key=0x%04X data_len=%d",
ef.page_key, len(ef.data),
)
if ef.page_key == 0x0000:
if include_terminator:
frames_data.append(ef)
return frames_data
frames_data.append(ef)
except TimeoutError:
log.debug("5A extra chunk %d timed out — end of stream", _extra_n + 1)
break
if rsp.page_key == 0x0000:
# Device unexpectedly terminated mid-stream (no termination needed).
log.debug("5A A5[%d] page_key=0x0000 — device terminated early", chunk_num)
return frames_data
frames_data.append(rsp.data)
if stop_after_metadata and _is_metadata:
log.debug("5A A5[%d] metadata found — stopping early", chunk_num)
break
else:
log.warning(
@@ -739,8 +670,6 @@ class MiniMateProtocol:
"5A termination response page_key=0x%04X %d bytes",
term_rsp.page_key, len(term_rsp.data),
)
if include_terminator:
frames_data.append(term_rsp)
except TimeoutError:
log.debug("5A no termination response — device may have already closed")
@@ -1176,89 +1105,6 @@ class MiniMateProtocol:
self._send(frame)
return self.recv_write_ack(expected_sub=rsp_sub)
# ── Call home config (SUBs 0x2C / 0x7E / 0x7F) ──────────────────────────
def read_call_home_config(self) -> bytes:
"""
Read the auto call home configuration (SUB 0x2C response 0xD3).
Standard two-step read: probe (offset=0x00) then data (offset=0x7C=124).
Returns the raw 125-byte payload (data[11:] of the data response).
Confirmed from 4-20-26 call home settings capture:
- Probe response: data[4]=0x7C (confirms data length = 124)
- Data response: 136 bytes total (11-byte echo header + 125 bytes payload)
- Payload[0:3] = 0x00 0x7C 0xDC (header: zero, inner-length, constant)
- Payload[5] = auto_call_home_enabled
- Payload[6:46] = dial_string (40-byte null-padded ASCII "RADIO RING")
Returns:
Raw 125-byte call home config payload (data[11:]).
Suitable for round-trip write (append \\x00\\x00 127-byte write payload).
Raises:
ProtocolError: on timeout or wrong response SUB.
"""
rsp_sub = _expected_rsp_sub(SUB_CALL_HOME) # 0xFF - 0x2C = 0xD3
length = DATA_LENGTHS[SUB_CALL_HOME] # 0x7C = 124
log.debug("read_call_home_config: 0x2C probe")
self._send(build_bw_frame(SUB_CALL_HOME, 0))
self._recv_one(expected_sub=rsp_sub)
log.debug("read_call_home_config: 0x2C data request offset=0x%02X", length)
self._send(build_bw_frame(SUB_CALL_HOME, length))
data_rsp = self._recv_one(expected_sub=rsp_sub)
payload = data_rsp.data[11:]
log.debug("read_call_home_config: received %d payload bytes", len(payload))
return payload
def write_call_home_config(self, data: bytes) -> None:
"""
Write the auto call home configuration (SUB 0x7E 0x7F confirm).
Write sequence (confirmed from 4-20-26 call home settings captures):
SUB 0x7E write 127-byte payload device acks SUB 0x81
SUB 0x7F confirm (no data) device acks SUB 0x80
The 127-byte write payload = 125-byte read payload + b'\\x00\\x00'.
The offset field = data[1] + 2 = 0x7C + 2 = 0x7E = 126.
Write frame format: build_bw_write_frame (minimal DLE stuffing only
BW_CMD is doubled; all other bytes are RAW). The \\x10\\x03 sequence
within the payload is preserved as-is (device interprets DLE+ETX as the
literal value 0x03 per the inner-frame terminator convention).
Args:
data: 127-byte write payload (read payload + \\x00\\x00 footer).
Must start with [0x00][0x7C][...] (standard header).
Raises:
ValueError: if data is not exactly 127 bytes or lacks expected header.
ProtocolError: on timeout or wrong response SUB.
"""
if len(data) < 2:
raise ValueError(f"call home write payload must be at least 2 bytes, got {len(data)}")
rsp_sub_write = _expected_rsp_sub(SUB_CALL_HOME_WRITE) # 0xFF - 0x7E = 0x81
rsp_sub_confirm = _expected_rsp_sub(SUB_CALL_HOME_CONFIRM) # 0xFF - 0x7F = 0x80
# Offset formula: data[1] + 2 (same pattern as other single-chunk writes)
offset = data[1] + 2 # 0x7C + 2 = 0x7E = 126
frame = build_bw_write_frame(SUB_CALL_HOME_WRITE, data, offset=offset)
log.debug(
"write_call_home_config: %d bytes data[1]=0x%02X offset=0x%04X",
len(data), data[1], offset,
)
self._send(frame)
self.recv_write_ack(expected_sub=rsp_sub_write)
log.debug("write_call_home_config: write acked; sending confirm 0x7F")
confirm_frame = build_bw_write_frame(SUB_CALL_HOME_CONFIRM, b"")
self._send(confirm_frame)
self.recv_write_ack(expected_sub=rsp_sub_confirm)
log.debug("write_call_home_config: confirm acked — done")
# ── Monitoring ────────────────────────────────────────────────────────────
def read_monitor_status(self) -> S3Frame:
@@ -1403,63 +1249,6 @@ class MiniMateProtocol:
log.debug("TX %d bytes: %s", len(frame), frame.hex())
self._transport.write(frame)
def _recv_5a_batch(
self,
expected_sub: int,
first_timeout: float = 10.0,
batch_timeout: float = 0.5,
) -> list[S3Frame]:
"""
Collect all S3 frames that arrive as part of one device response.
Over TCP via cellular modem, a single device A5 response (~1100 bytes of
RS-232 data) is forwarded in multiple TCP segments due to the modem's
data-forwarding timeout (~100-150 ms per segment). Each TCP segment
contains a complete, valid S3 frame (~550 bytes). Calling _recv_one()
once returns only the first segment's frame and misses the rest, causing
the chunk request/response pairing to cascade out of alignment.
This helper collects ALL frames before returning, by trying additional
short-timeout receives after the first frame arrives.
The caller must call self._parser.reset() before this method to ensure
bytes_fed is accurate; this method always uses reset_parser=False.
Args:
expected_sub: Expected SUB byte for validation.
first_timeout: Timeout for the mandatory first frame. Should be
generous (default 10 s) since the device may be slow.
batch_timeout: Short timeout for subsequent frames. Default 0.5 s
comfortably longer than the modem forwarding gap
(~150 ms) but short enough to avoid stalling when
only one frame is expected (probe, terminator).
Returns:
List of S3Frame objects in arrival order (at least one).
Raises:
TimeoutError: If no frame arrives within first_timeout.
UnexpectedResponse: If any frame has the wrong SUB byte.
"""
frames: list[S3Frame] = []
first = self._recv_one(
expected_sub=expected_sub,
reset_parser=False,
timeout=first_timeout,
)
frames.append(first)
while True:
try:
extra = self._recv_one(
expected_sub=expected_sub,
reset_parser=False,
timeout=batch_timeout,
)
frames.append(extra)
except TimeoutError:
break
return frames
def _recv_one(
self,
expected_sub: Optional[int] = None,
+10 -28
View File
@@ -33,7 +33,7 @@ STX = 0x02
ETX = 0x03
ACK = 0x41
__version__ = "0.2.3"
__version__ = "0.2.2"
@dataclass
@@ -227,32 +227,17 @@ def parse_s3(blob: bytes, trailer_len: int) -> List[Frame]:
trailer_end = trailer_start + trailer_len
trailer = blob[trailer_start:trailer_end]
chk_valid = None
chk_type = None
chk_hex = None
payload = bytes(body)
if len(body) >= 1:
received_chk = body[-1]
computed_chk = checksum8_sum(bytes(body[:-1]))
if computed_chk == received_chk:
chk_valid = True
chk_type = "SUM8"
chk_hex = f"{received_chk:02x}"
payload = bytes(body[:-1])
else:
chk_valid = False
# For S3 mode we don't assume checksum type here yet.
frames.append(Frame(
index=idx,
start_offset=start_offset,
end_offset=end_offset,
payload_raw=bytes(body),
payload=payload,
payload=bytes(body),
trailer=trailer,
checksum_valid=chk_valid,
checksum_type=chk_type,
checksum_hex=chk_hex
checksum_valid=None,
checksum_type=None,
checksum_hex=None
))
idx += 1
@@ -313,13 +298,10 @@ def parse_bw(blob: bytes, trailer_len: int, validate_checksum: bool) -> List[Fra
if b == ETX:
# Candidate end-of-frame.
# Skip any SESSION_RESET (41 03) sequences — sent before POLL to wake
# monitoring units — to find the real next frame start (ACK+STX).
j = i + 1
while j + 1 < n and blob[j] == ACK and blob[j + 1] == ETX:
j += 2
next_is_start = (j + 1 < n and blob[j] == ACK and blob[j + 1] == STX)
at_eof = (i == n - 1) or (j >= n)
# Accept ETX if the next bytes look like a real next-frame start (ACK+STX),
# or we're at EOF. This prevents chopping on in-payload 0x03.
next_is_start = (i + 2 < n and blob[i + 1] == ACK and blob[i + 2] == STX)
at_eof = (i == n - 1)
if not (next_is_start or at_eof):
# Not a real boundary -> payload byte
-1
View File
@@ -11,7 +11,6 @@ dependencies = [
"fastapi>=0.104",
"uvicorn[standard]>=0.24",
"pyserial>=3.5",
"sqlalchemy>=2.0",
]
[tool.setuptools.packages.find]
-4
View File
@@ -1,4 +0,0 @@
fastapi
uvicorn
sqlalchemy
pyserial
+34 -189
View File
@@ -97,24 +97,16 @@ class AnalyzerState:
class BridgePanel(tk.Frame):
"""
All bridge controls and live log output.
Calls on_bridge_started(struct_bin_path) when the bridge starts.
Calls on_capture_started(bw_path, s3_path, label) when a capture begins.
Calls on_capture_complete(bw_path, s3_path, label) when a capture ends.
Calls on_bridge_started(raw_bw_path, raw_s3_path) when the bridge starts
so the parent can wire up the Analyzer.
"""
def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped,
on_capture_started=None, on_capture_complete=None, **kw):
def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped, **kw):
super().__init__(parent, bg=BG2, **kw)
self._on_started = on_bridge_started # signature: (struct_bin)
self._on_stopped = on_bridge_stopped
self._on_cap_started = on_capture_started # (bw, s3, label)
self._on_cap_complete = on_capture_complete # (bw, s3, label)
self._on_started = on_bridge_started # signature: (raw_bw, raw_s3, struct_bin)
self._on_stopped = on_bridge_stopped
self.process: Optional[subprocess.Popen] = None
self._stdout_q: queue.Queue[str] = queue.Queue()
# Capture state
self._capturing = False
self._cap_label: Optional[str] = None
self._cap_history: list[dict] = [] # {label, status, bw, s3}
self._build()
self._poll_stdout()
@@ -154,7 +146,17 @@ class BridgePanel(tk.Frame):
tk.Button(cfg, text="Browse", bg=BG3, fg=FG, relief="flat", cursor="hand2",
font=MONO, command=self._choose_dir).grid(row=1, column=5, **pad)
# Row 2: buttons + status
# Row 2: raw taps (always enabled — timestamped names generated at start)
self._raw_bw_on = tk.BooleanVar(value=True)
self._raw_s3_on = tk.BooleanVar(value=True)
tk.Checkbutton(cfg, text="Capture BW->S3 raw", variable=self._raw_bw_on,
bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2,
font=MONO).grid(row=2, column=0, columnspan=2, sticky="w", **pad)
tk.Checkbutton(cfg, text="Capture S3->BW raw", variable=self._raw_s3_on,
bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2,
font=MONO).grid(row=2, column=2, columnspan=2, sticky="w", **pad)
# Row 3: buttons + status
btn_row = tk.Frame(self, bg=BG2)
btn_row.pack(side=tk.TOP, fill=tk.X, padx=4, pady=2)
@@ -168,18 +170,6 @@ class BridgePanel(tk.Frame):
command=self.stop_bridge, state="disabled")
self.stop_btn.pack(side=tk.LEFT, padx=4)
tk.Frame(btn_row, bg=BG2, width=16).pack(side=tk.LEFT) # spacer
self.cap_btn = tk.Button(btn_row, text="⬤ New Capture", bg=ORANGE, fg="#000000",
relief="flat", padx=10, cursor="hand2", font=MONO_B,
command=self._start_capture, state="disabled")
self.cap_btn.pack(side=tk.LEFT, padx=4)
self.stop_cap_btn = tk.Button(btn_row, text="■ Stop Capture", bg=BG3, fg=RED,
relief="flat", padx=10, cursor="hand2", font=MONO_B,
command=self._stop_capture, state="disabled")
self.stop_cap_btn.pack(side=tk.LEFT, padx=4)
self.mark_btn = tk.Button(btn_row, text="Add Mark", bg=BG3, fg=FG,
relief="flat", padx=10, cursor="hand2", font=MONO,
command=self.add_mark, state="disabled")
@@ -189,34 +179,9 @@ class BridgePanel(tk.Frame):
tk.Label(btn_row, textvariable=self.status_var,
bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=10)
# Capture history panel
hist_outer = tk.Frame(self, bg=BG2)
hist_outer.pack(side=tk.TOP, fill=tk.X, padx=4, pady=(2, 0))
tk.Label(hist_outer, text="Captures:", bg=BG2, fg=FG_DIM,
font=MONO_SM, anchor="w").pack(side=tk.LEFT, padx=(4, 6))
hist_inner = tk.Frame(hist_outer, bg=BG2)
hist_inner.pack(side=tk.LEFT, fill=tk.X, expand=True)
self._hist_lb = tk.Listbox(
hist_inner, bg=BG3, fg=FG, font=MONO_SM,
height=3, relief="flat", selectbackground=BG,
selectforeground=ACCENT, activestyle="none",
highlightthickness=0,
)
hist_vsb = ttk.Scrollbar(hist_inner, orient="vertical", command=self._hist_lb.yview)
self._hist_lb.configure(yscrollcommand=hist_vsb.set)
hist_vsb.pack(side=tk.RIGHT, fill=tk.Y)
self._hist_lb.pack(side=tk.LEFT, fill=tk.X, expand=True)
self._hist_lb.bind("<Double-Button-1>", self._on_hist_dblclick)
tk.Label(hist_outer, text="dbl-click to reload", bg=BG2, fg=FG_DIM,
font=MONO_SM, anchor="e").pack(side=tk.RIGHT, padx=6)
# Log output
self.log_view = scrolledtext.ScrolledText(
self, height=14, font=MONO_SM,
self, height=18, font=MONO_SM,
bg=BG, fg=FG, insertbackground=FG,
relief="flat", state="disabled",
)
@@ -256,8 +221,14 @@ class BridgePanel(tk.Frame):
args = [sys.executable, str(BRIDGE_PATH),
"--bw", bw, "--s3", s3, "--baud", baud, "--logdir", logdir]
# Raw BW/S3 taps are NOT opened at bridge start.
# Use "New Capture" to start a labeled tap on demand.
raw_bw_path = raw_s3_path = None
if self._raw_bw_on.get():
raw_bw_path = os.path.join(logdir, f"raw_bw_{ts}.bin")
args += ["--raw-bw", raw_bw_path]
if self._raw_s3_on.get():
raw_s3_path = os.path.join(logdir, f"raw_s3_{ts}.bin")
args += ["--raw-s3", raw_s3_path]
# Structured bin path — written by bridge automatically, named by ts
struct_bin_path = os.path.join(logdir, f"s3_session_{ts}.bin")
@@ -279,12 +250,11 @@ class BridgePanel(tk.Frame):
self.status_var.set(f"Running — {bw} <-> {s3}")
self.start_btn.configure(state="disabled")
self.stop_btn.configure(state="normal", bg=RED)
self.cap_btn.configure(state="normal")
self.mark_btn.configure(state="normal")
self._append_log(f"== Bridge started [{ts}] ==\n")
self._append_log(" Click 'New Capture' when ready to record a setting change.\n")
# Notify parent — no raw files yet, just the structured bin path
self._on_started(struct_bin_path)
# Notify parent so Analyzer can wire up live mode
self._on_started(raw_bw_path, raw_s3_path, struct_bin_path)
def stop_bridge(self) -> None:
if self.process and self.process.poll() is None:
@@ -300,11 +270,7 @@ class BridgePanel(tk.Frame):
self.status_var.set("Stopped")
self.start_btn.configure(state="normal")
self.stop_btn.configure(state="disabled", bg=BG3)
self.cap_btn.configure(state="disabled")
self.stop_cap_btn.configure(state="disabled", bg=BG3)
self.mark_btn.configure(state="disabled")
self._capturing = False
self._cap_label = None
self._append_log("== Bridge stopped ==\n")
def _reader_thread(self) -> None:
@@ -322,120 +288,12 @@ class BridgePanel(tk.Frame):
self._bridge_ended()
self._on_stopped()
break
stripped = line.strip()
# Handle capture lifecycle events from bridge
if stripped.startswith("[CAP_START] ") and "\t" in stripped:
parts = stripped[12:].split("\t", 1)
if len(parts) == 2:
bw_path, s3_path = parts[0].strip(), parts[1].strip()
self._on_cap_started_msg(bw_path, s3_path)
elif stripped.startswith("[CAP_STOP] ") and "\t" in stripped:
parts = stripped[11:].split("\t", 1)
if len(parts) == 2:
bw_path, s3_path = parts[0].strip(), parts[1].strip()
self._on_cap_stopped_msg(bw_path, s3_path)
self._append_log(line)
except queue.Empty:
pass
finally:
self.after(100, self._poll_stdout)
# ── capture control ───────────────────────────────────────────────────
def _start_capture(self) -> None:
"""Ask for a label and tell the bridge to start writing raw tap files."""
if not self.process or self.process.poll() is not None:
return
label = simpledialog.askstring(
"New Capture",
"Label for this capture\n(e.g. 'recording_mode_continuous').\nLeave blank for timestamp only:",
parent=self,
)
if label is None:
return # user hit Cancel
label = label.strip()
try:
self.process.stdin.write(f"CAP_START:{label}\n")
self.process.stdin.flush()
except Exception as e:
messagebox.showerror("Error", f"Failed to start capture:\n{e}")
return
self._capturing = True
self._cap_label = label or datetime.datetime.now().strftime("%H%M%S")
self.cap_btn.configure(state="disabled")
self.stop_cap_btn.configure(state="normal", bg=RED)
self.mark_btn.configure(state="normal")
self._append_log(f"[CAPTURE] Starting: {self._cap_label!r}...\n")
# Add to history as recording (paths filled in when [CAP_START] arrives)
self._cap_history.append({"label": self._cap_label, "status": "recording",
"bw": None, "s3": None})
self._refresh_hist()
def _stop_capture(self) -> None:
"""Tell the bridge to flush and close the current raw tap files."""
if not self.process or self.process.poll() is not None:
return
try:
self.process.stdin.write("CAP_STOP\n")
self.process.stdin.flush()
except Exception as e:
messagebox.showerror("Error", f"Failed to stop capture:\n{e}")
# UI is updated when [CAP_STOP] arrives in stdout
def _on_cap_started_msg(self, bw_path: str, s3_path: str) -> None:
"""Called when bridge confirms capture has started (files are open)."""
# Fill in paths for the last 'recording' history entry
for entry in reversed(self._cap_history):
if entry["status"] == "recording" and entry["bw"] is None:
entry["bw"] = bw_path
entry["s3"] = s3_path
break
if self._on_cap_started:
self._on_cap_started(bw_path, s3_path, self._cap_label or "")
def _on_cap_stopped_msg(self, bw_path: str, s3_path: str) -> None:
"""Called when bridge confirms capture has stopped (files are closed)."""
label = self._cap_label or "capture"
# Mark history entry as done
for entry in reversed(self._cap_history):
if entry["status"] == "recording":
entry["status"] = "done"
entry["bw"] = bw_path
entry["s3"] = s3_path
break
self._refresh_hist()
self._capturing = False
self._cap_label = None
self.cap_btn.configure(state="normal")
self.stop_cap_btn.configure(state="disabled", bg=BG3)
self._append_log(f"[CAPTURE] Done: {label!r} — ready in Analyzer\n")
if self._on_cap_complete:
self._on_cap_complete(bw_path, s3_path, label)
def _refresh_hist(self) -> None:
self._hist_lb.delete(0, tk.END)
for entry in self._cap_history:
icon = "🔴" if entry["status"] == "recording" else ""
label = entry["label"] or "(unlabeled)"
self._hist_lb.insert(tk.END, f" {icon} {label}")
if self._cap_history:
self._hist_lb.see(tk.END)
def _on_hist_dblclick(self, _e=None) -> None:
sel = self._hist_lb.curselection()
if not sel:
return
entry = self._cap_history[sel[0]]
if entry["status"] == "done" and entry["bw"] and entry["s3"]:
if self._on_cap_complete:
self._on_cap_complete(entry["bw"], entry["s3"], entry["label"])
# ── mark ──────────────────────────────────────────────────────────────
def add_mark(self) -> None:
if not self.process or not self.process.stdin or self.process.poll() is not None:
return
@@ -2026,8 +1884,6 @@ class SeismoLab(tk.Tk):
nb,
on_bridge_started=self._on_bridge_started,
on_bridge_stopped=self._on_bridge_stopped,
on_capture_started=self._on_capture_started,
on_capture_complete=self._on_capture_complete,
)
nb.add(self._bridge_panel, text=" Bridge ")
@@ -2049,27 +1905,16 @@ class SeismoLab(tk.Tk):
self._nb = nb
self.protocol("WM_DELETE_WINDOW", self._on_close)
def _on_bridge_started(self, struct_bin: Optional[str] = None) -> None:
"""Bridge started — stash the structured bin path; stay on Bridge tab."""
if struct_bin:
self._analyzer_panel.bin_var.set(struct_bin)
def _on_bridge_started(self, raw_bw: Optional[str], raw_s3: Optional[str],
struct_bin: Optional[str] = None) -> None:
"""Bridge started — inject paths into analyzer and start live mode."""
self._analyzer_panel.set_live_files(raw_bw, raw_s3, struct_bin)
# Switch to Analyzer tab so the user can watch it update
self._nb.select(1)
def _on_bridge_stopped(self) -> None:
self._analyzer_panel.stop_live()
def _on_capture_started(self, bw_path: str, s3_path: str, label: str) -> None:
"""A capture began — wire up live mode in the Analyzer and switch tabs."""
self._analyzer_panel.set_live_files(bw_path, s3_path)
self._nb.select(1)
def _on_capture_complete(self, bw_path: str, s3_path: str, label: str) -> None:
"""A capture stopped — stop live mode, run full analysis, switch to Analyzer."""
self._analyzer_panel.stop_live()
self._analyzer_panel.s3_var.set(s3_path)
self._analyzer_panel.bw_var.set(bw_path)
self._analyzer_panel._run_analyze()
self._nb.select(1)
def _on_console_send_to_analyzer(self, raw_s3_path: str) -> None:
"""Console captured bytes → inject into Analyzer S3 field and switch tab."""
self._analyzer_panel.s3_var.set(raw_s3_path)
-376
View File
@@ -1,376 +0,0 @@
"""
sfm/cache.py Persistent SQLite cache for SFM device data.
Caching strategy
----------------
+------------------+----------------------------------+-------------------------+
| Data | Mutability | Invalidation |
+------------------+----------------------------------+-------------------------+
| Device info | Effectively immutable (firmware, | Manual clear / force |
| (serial, model, | serial never change) | refresh query param |
| compliance cfg) | | |
+------------------+----------------------------------+-------------------------+
| Event headers | Append-only (new events added, | Fetch new ones when |
| (peaks, ts, | old never modified) | device event count > |
| project info) | | cached count |
+------------------+----------------------------------+-------------------------+
| Full waveforms | Immutable once recorded | Never (permanent cache) |
| (raw ADC samples)| | |
+------------------+----------------------------------+-------------------------+
| Monitor status | Frequently changing | TTL = 30 seconds |
| (battery, memory)| | |
+------------------+----------------------------------+-------------------------+
Keys
----
All cached rows are keyed by (host, tcp_port) for TCP connections, or (port, baud)
for serial connections. Within a device, events are keyed by index (0-based).
The device serial number is stored once we learn it, and used for display / debugging
only the network address is the primary routing key (same as how the rest of the SFM
code operates).
"""
from __future__ import annotations
import json
import logging
import time
from pathlib import Path
from typing import Optional
try:
import sqlalchemy as sa
from sqlalchemy import orm
except ImportError:
raise ImportError(
"sqlalchemy is required for the SFM cache.\n"
"Install it with: pip install sqlalchemy"
)
log = logging.getLogger("sfm.cache")
# ── Schema ────────────────────────────────────────────────────────────────────
Base = orm.declarative_base()
_MONITOR_STATUS_TTL = 30 # seconds
class CachedDevice(Base):
"""
Device identity + compliance config, keyed by connection address.
Stores the full serialised JSON blob returned by /device/info so the
endpoint can return it verbatim on a cache hit without re-connecting.
"""
__tablename__ = "cached_devices"
# Connection key — either TCP (host+port) or serial (port+baud)
conn_key = sa.Column(sa.String, primary_key=True) # e.g. "tcp:1.2.3.4:12345"
serial = sa.Column(sa.String, nullable=True) # e.g. "BE11529"
info_json = sa.Column(sa.Text, nullable=False) # full /device/info response JSON
updated_at = sa.Column(sa.Float, nullable=False) # Unix timestamp of last write
# When a config write happens we set this flag so the next /device/info call
# fetches fresh data instead of serving stale compliance config.
config_dirty = sa.Column(sa.Boolean, default=False, nullable=False)
class CachedEvent(Base):
"""
Per-event header + peak values + project info, keyed by (conn_key, index).
Events are immutable once recorded on the device; once we have an event in
the cache it never needs to be re-downloaded unless explicitly requested.
"""
__tablename__ = "cached_events"
conn_key = sa.Column(sa.String, primary_key=True)
index = sa.Column(sa.Integer, primary_key=True)
event_json = sa.Column(sa.Text, nullable=False) # serialised Event dict
cached_at = sa.Column(sa.Float, nullable=False) # Unix timestamp
class CachedWaveform(Base):
"""
Full raw ADC waveform for a single event (SUB 5A full download).
These are large (up to several MB) and expensive to fetch over cellular.
Once downloaded they are immutable and cached permanently.
"""
__tablename__ = "cached_waveforms"
conn_key = sa.Column(sa.String, primary_key=True)
index = sa.Column(sa.Integer, primary_key=True)
waveform_json = sa.Column(sa.Text, nullable=False) # full /device/event/{idx}/waveform response JSON
cached_at = sa.Column(sa.Float, nullable=False)
class CachedMonitorStatus(Base):
"""
Monitor status (battery, memory, is_monitoring) with a short TTL.
These change frequently during field operations so we keep them only for
MONITOR_STATUS_TTL seconds before re-fetching from the device.
"""
__tablename__ = "cached_monitor_status"
conn_key = sa.Column(sa.String, primary_key=True)
status_json = sa.Column(sa.Text, nullable=False)
cached_at = sa.Column(sa.Float, nullable=False)
# ── Cache store ───────────────────────────────────────────────────────────────
class SFMCache:
"""
SQLite-backed cache for SFM device data.
Usage
-----
cache = SFMCache() # stores in sfm/data/sfm_cache.db by default
cache = SFMCache(":memory:") # in-memory (tests / ephemeral mode)
All public methods accept a *conn_key* string use make_conn_key() to
build a consistent key from the transport parameters.
"""
def __init__(self, db_path: str | Path | None = None) -> None:
in_memory = (db_path == ":memory:")
if db_path is None:
# Default: alongside this file in sfm/data/
db_path = Path(__file__).parent / "data" / "sfm_cache.db"
if not in_memory:
db_path = Path(db_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
url = "sqlite:///:memory:" if in_memory else f"sqlite:///{db_path}"
engine = sa.create_engine(url, connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
self._Session = orm.sessionmaker(bind=engine)
log.info("SFM cache opened: %s", db_path)
# ── Connection key ────────────────────────────────────────────────────────
@staticmethod
def make_conn_key(
host: Optional[str],
tcp_port: int,
port: Optional[str],
baud: int,
) -> str:
"""Return a stable string key for this transport configuration."""
if host:
return f"tcp:{host}:{tcp_port}"
return f"serial:{port}:{baud}"
# ── Device info ───────────────────────────────────────────────────────────
def get_device_info(self, conn_key: str) -> Optional[dict]:
"""
Return cached device info dict, or None if not cached / config_dirty.
"""
with self._Session() as s:
row = s.get(CachedDevice, conn_key)
if row is None or row.config_dirty:
return None
return json.loads(row.info_json)
def set_device_info(self, conn_key: str, info: dict) -> None:
"""Store device info and clear any dirty flag."""
with self._Session() as s:
row = s.get(CachedDevice, conn_key)
serial = info.get("serial")
if row is None:
row = CachedDevice(
conn_key=conn_key,
serial=serial,
info_json=json.dumps(info),
updated_at=time.time(),
config_dirty=False,
)
s.add(row)
else:
row.serial = serial
row.info_json = json.dumps(info)
row.updated_at = time.time()
row.config_dirty = False
s.commit()
log.debug("cached device info for %s (serial=%s)", conn_key, serial)
def mark_config_dirty(self, conn_key: str) -> None:
"""
Called after a successful POST /device/config write.
Forces the next /device/info call to re-read compliance config from the
device instead of serving the now-stale cached version.
"""
with self._Session() as s:
row = s.get(CachedDevice, conn_key)
if row:
row.config_dirty = True
s.commit()
log.debug("marked config dirty for %s", conn_key)
# ── Events ────────────────────────────────────────────────────────────────
def get_cached_event_count(self, conn_key: str) -> int:
"""Return the number of events we have cached for this device."""
with self._Session() as s:
return s.query(CachedEvent).filter_by(conn_key=conn_key).count()
def get_all_events(self, conn_key: str) -> Optional[list[dict]]:
"""
Return all cached events as a list of dicts, sorted by index.
Returns None if nothing is cached yet.
"""
with self._Session() as s:
rows = (
s.query(CachedEvent)
.filter_by(conn_key=conn_key)
.order_by(CachedEvent.index)
.all()
)
if not rows:
return None
return [json.loads(r.event_json) for r in rows]
def get_event(self, conn_key: str, index: int) -> Optional[dict]:
"""Return a single cached event by index, or None if not cached."""
with self._Session() as s:
row = s.get(CachedEvent, (conn_key, index))
return json.loads(row.event_json) if row else None
def set_events(self, conn_key: str, events: list[dict]) -> None:
"""
Upsert a list of event dicts. Existing rows are updated; new rows are
inserted. This is used to add newly-discovered events to the cache.
"""
now = time.time()
with self._Session() as s:
for ev in events:
idx = ev["index"]
row = s.get(CachedEvent, (conn_key, idx))
if row is None:
row = CachedEvent(
conn_key=conn_key,
index=idx,
event_json=json.dumps(ev),
cached_at=now,
)
s.add(row)
log.debug("cached new event %d for %s", idx, conn_key)
else:
# Refresh in case project_info was backfilled after initial store
row.event_json = json.dumps(ev)
s.commit()
# ── Waveforms ─────────────────────────────────────────────────────────────
def get_waveform(self, conn_key: str, index: int) -> Optional[dict]:
"""Return a cached full waveform response dict, or None if not cached."""
with self._Session() as s:
row = s.get(CachedWaveform, (conn_key, index))
if row is None:
return None
log.debug("waveform cache hit: %s event %d", conn_key, index)
return json.loads(row.waveform_json)
def set_waveform(self, conn_key: str, index: int, waveform: dict) -> None:
"""Store a full waveform response dict permanently."""
with self._Session() as s:
row = s.get(CachedWaveform, (conn_key, index))
if row is None:
row = CachedWaveform(
conn_key=conn_key,
index=index,
waveform_json=json.dumps(waveform),
cached_at=time.time(),
)
s.add(row)
else:
row.waveform_json = json.dumps(waveform)
row.cached_at = time.time()
s.commit()
log.debug("cached waveform for %s event %d", conn_key, index)
# ── Monitor status ────────────────────────────────────────────────────────
def get_monitor_status(self, conn_key: str) -> Optional[dict]:
"""Return cached monitor status if it's within TTL, else None."""
with self._Session() as s:
row = s.get(CachedMonitorStatus, conn_key)
if row is None:
return None
age = time.time() - row.cached_at
if age > _MONITOR_STATUS_TTL:
log.debug("monitor status expired (age=%.1fs) for %s", age, conn_key)
return None
return json.loads(row.status_json)
def set_monitor_status(self, conn_key: str, status: dict) -> None:
"""Store monitor status."""
with self._Session() as s:
row = s.get(CachedMonitorStatus, conn_key)
if row is None:
row = CachedMonitorStatus(
conn_key=conn_key,
status_json=json.dumps(status),
cached_at=time.time(),
)
s.add(row)
else:
row.status_json = json.dumps(status)
row.cached_at = time.time()
s.commit()
def invalidate_monitor_status(self, conn_key: str) -> None:
"""
Called after start/stop monitoring so the next status poll re-reads from device.
"""
with self._Session() as s:
row = s.get(CachedMonitorStatus, conn_key)
if row:
s.delete(row)
s.commit()
# ── Cache management ──────────────────────────────────────────────────────
def clear_device(self, conn_key: str) -> dict:
"""
Remove all cached data for a device. Returns counts of deleted rows.
"""
counts = {}
with self._Session() as s:
counts["device_info"] = s.query(CachedDevice).filter_by(conn_key=conn_key).delete()
counts["events"] = s.query(CachedEvent).filter_by(conn_key=conn_key).delete()
counts["waveforms"] = s.query(CachedWaveform).filter_by(conn_key=conn_key).delete()
counts["monitor_status"] = s.query(CachedMonitorStatus).filter_by(conn_key=conn_key).delete()
s.commit()
log.info("cleared cache for %s: %s", conn_key, counts)
return counts
def stats(self) -> dict:
"""Return row counts for all cache tables (for /cache/stats endpoint)."""
with self._Session() as s:
return {
"devices": s.query(CachedDevice).count(),
"events": s.query(CachedEvent).count(),
"waveforms": s.query(CachedWaveform).count(),
"monitor_status": s.query(CachedMonitorStatus).count(),
}
# ── Module-level singleton ────────────────────────────────────────────────────
# Instantiated once when the module is imported; shared across all requests.
_cache: Optional[SFMCache] = None
def get_cache() -> SFMCache:
"""Return the module-level cache singleton, initialising it on first call."""
global _cache
if _cache is None:
_cache = SFMCache()
return _cache
+524 -486
View File
File diff suppressed because it is too large Load Diff
+141 -521
View File
@@ -59,10 +59,8 @@ except ImportError:
from minimateplus import MiniMateClient
from minimateplus.protocol import ProtocolError
from minimateplus.models import CallHomeConfig, ComplianceConfig, DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp
from minimateplus.models import ComplianceConfig, DeviceInfo, Event, PeakValues, ProjectInfo, Timestamp
from minimateplus.transport import TcpTransport, DEFAULT_TCP_PORT
from minimateplus.blastware_file import write_blastware_file, blastware_filename
from sfm.cache import SFMCache, get_cache
from sfm.database import SeismoDb
logging.basicConfig(
@@ -286,14 +284,11 @@ def _serialise_compliance_config(cc: Optional["ComplianceConfig"]) -> Optional[d
if cc is None:
return None
return {
"recording_mode": cc.recording_mode, # 0x00=Single Shot, 0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous
"sample_rate": cc.sample_rate,
"histogram_interval_sec": cc.histogram_interval_sec, # seconds; None if not Histogram mode
"record_time": cc.record_time,
"record_time": cc.record_time,
"sample_rate": cc.sample_rate,
"trigger_level_geo": cc.trigger_level_geo,
"alarm_level_geo": cc.alarm_level_geo,
"geo_adc_scale": cc.geo_adc_scale, # hw scale factor (in/s)/V — informational only, do not write
"geo_range": cc.geo_range, # CONFIRMED 2026-04-20: 0x00=Normal 10in/s, 0x01=Sensitive 1.25in/s
"max_range_geo": cc.max_range_geo,
"setup_name": cc.setup_name,
"project": cc.project,
"client": cc.client,
@@ -303,27 +298,6 @@ def _serialise_compliance_config(cc: Optional["ComplianceConfig"]) -> Optional[d
}
def _serialise_call_home_config(ch: Optional["CallHomeConfig"]) -> Optional[dict]:
if ch is None:
return None
return {
"auto_call_home_enabled": ch.auto_call_home_enabled,
"dial_string": ch.dial_string,
"after_event_recorded": ch.after_event_recorded,
"at_specified_times": ch.at_specified_times,
"time1_enabled": ch.time1_enabled,
"time1_hour": ch.time1_hour,
"time1_min": ch.time1_min,
"time2_enabled": ch.time2_enabled,
"time2_hour": ch.time2_hour,
"time2_min": ch.time2_min,
"num_retries": ch.num_retries,
"time_between_retries_sec": ch.time_between_retries_sec,
"wait_for_connection_sec": ch.wait_for_connection_sec,
"warm_up_time_sec": ch.warm_up_time_sec,
}
def _serialise_device_info(info: DeviceInfo) -> dict:
return {
"serial": info.serial,
@@ -414,33 +388,6 @@ def _run_with_retry(fn, *, is_tcp: bool):
return fn() # let any second failure propagate normally
# ── Helpers ────────────────────────────────────────────────────────────────────
def _backfill_events(events: list, info: "DeviceInfo") -> None:
"""
Fill in sample_rate and project_info fields that the per-event waveform
record doesn't carry — sourced from the device's compliance config.
Extracted from device_events() so it can be called from both the full
download path and the partial (new-events-only) path.
"""
if info.compliance_config and info.compliance_config.sample_rate:
for ev in events:
if ev.sample_rate is None:
ev.sample_rate = info.compliance_config.sample_rate
if info.compliance_config:
cc = info.compliance_config
for ev in events:
if ev.project_info is None:
ev.project_info = ProjectInfo()
pi = ev.project_info
if pi.client is None: pi.client = cc.client
if pi.operator is None: pi.operator = cc.operator
if pi.sensor_location is None: pi.sensor_location = cc.sensor_location
if pi.notes is None: pi.notes = cc.notes
# ── Endpoints ──────────────────────────────────────────────────────────────────
@app.get("/health")
@@ -467,7 +414,7 @@ def device_info(
baud: int = Query(38400, description="Serial baud rate (default 38400)"),
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay (e.g. 203.0.113.5)"),
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
force: bool = Query(False, description="Bypass cache and re-read from device"),
force: bool = Query(False, description="Bypass cache and re-read from device"),
) -> dict:
"""
Connect to the device, perform the POLL startup handshake, and return
@@ -476,21 +423,16 @@ def device_info(
Supply either *port* (serial) or *host* (TCP/modem).
Equivalent to POST /device/connect provided as GET for convenience.
**Caching**: device identity and compliance config are cached after the first
successful read (they rarely change). Pass *force=true* to bypass the cache
and re-read directly from the device (e.g. after a config push).
The cache is also automatically invalidated after POST /device/config.
Response is cached until a POST /device/config write invalidates it.
Pass ?force=true to bypass the cache.
"""
conn_key = _live_cache.make_conn_key(host, tcp_port, port, baud)
log.info("GET /device/info port=%s host=%s tcp_port=%d force=%s", port, host, tcp_port, force)
cache = get_cache()
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
if not force:
cached = cache.get_device_info(conn_key)
cached = _live_cache.get_device_info(conn_key)
if cached is not None:
log.info("device info cache hit for %s", conn_key)
cached["_cached"] = True
log.debug("device_info cache hit for %s", conn_key)
return cached
try:
@@ -512,7 +454,7 @@ def device_info(
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
result = _serialise_device_info(info)
cache.set_device_info(conn_key, result)
_live_cache.set_device_info(conn_key, result)
return result
@@ -536,8 +478,8 @@ def device_events(
baud: int = Query(38400, description="Serial baud rate"),
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
debug: bool = Query(False, description="Include raw record hex for field-layout inspection"),
force: bool = Query(False, description="Bypass cache and re-download all events from device"),
debug: bool = Query(False, description="Include raw record hex for field-layout inspection"),
force: bool = Query(False, description="Bypass cache and re-download from device"),
) -> dict:
"""
Connect to the device, read the event index, and download all stored
@@ -555,107 +497,38 @@ def device_events(
This does NOT download raw ADC waveform samples those are large and
fetched separately via GET /device/event/{idx}/waveform.
**Caching**: event headers are cached after the first download. On subsequent
calls, the device is contacted only to check the event count (fast: ~2s).
If the count matches the cache, all events are returned from cache instantly.
If new events exist on the device, only the new ones are downloaded and merged.
Pass *force=true* to bypass the cache entirely and re-download everything.
"""
conn_key = _live_cache.make_conn_key(host, tcp_port, port, baud)
log.info("GET /device/events port=%s host=%s debug=%s force=%s", port, host, debug, force)
cache = get_cache()
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
# ── Smart cache path (skip when debug=True or force=True) ────────────────
# debug mode uses raw_record_hex which isn't stored in the cache, so we
# must always go to the device when debug is requested.
# ── Cache fast path ───────────────────────────────────────────────────────
# Do a quick poll + count_events() probe (~2s over cellular) to check if the
# device has any new events. If the count matches the cache, return early.
if not force and not debug:
cached_events = cache.get_all_events(conn_key)
cached_count = len(cached_events) if cached_events else 0
if cached_count > 0:
# Quick device contact: just count events via the fast 1E/1F chain.
# This takes ~2s instead of the full event download (~10-30s).
try:
def _count():
with _build_client(port, baud, host, tcp_port) as client:
client.connect()
return client.count_events()
device_count = _run_with_retry(_count, is_tcp=_is_tcp(host))
except HTTPException:
raise
except (ProtocolError, OSError, Exception) as exc:
# If we can't reach the device at all, serve stale cache rather
# than returning an error — field units go offline regularly.
log.warning("count_events failed (%s) — serving stale cache for %s", exc, conn_key)
cached_info = cache.get_device_info(conn_key) or {}
try:
def _count():
with _build_client(port, baud, host, tcp_port) as client:
try:
client.poll()
except Exception:
client.poll()
return client.count_events()
device_count = _run_with_retry(_count, is_tcp=_is_tcp(host))
cached_events = _live_cache.get_events(conn_key, device_count)
if cached_events is not None:
log.info(" events cache hit (%d events, count=%d)", len(cached_events), device_count)
# Also serve cached device info if available
cached_info = _live_cache.get_device_info(conn_key)
return {
"device": cached_info,
"event_count": cached_count,
"device": cached_info or {},
"event_count": len(cached_events),
"events": cached_events,
"_cached": True,
"_stale": True,
"cached": True,
}
except Exception as exc:
log.warning(" count probe failed (%s) — falling through to full download", exc)
if device_count == cached_count:
# Nothing new — return cache immediately, no event download needed.
log.info(
"event cache hit for %s: %d events, device count matches",
conn_key, cached_count,
)
cached_info = cache.get_device_info(conn_key) or {}
return {
"device": cached_info,
"event_count": cached_count,
"events": cached_events,
"_cached": True,
}
if device_count > cached_count:
# New events on the device — download all events but only store/return
# the new ones. Events are append-only; indices 0..(cached_count-1)
# are already in the cache and don't need to be re-downloaded logically,
# but the protocol requires iterating from event 0 to reach later ones.
# The device download time is dominated by the number of events requested,
# so we stop at the last known event index to avoid re-downloading everything.
log.info(
"new events on device %s: have %d, device has %d — fetching all up to %d",
conn_key, cached_count, device_count, device_count - 1,
)
try:
def _fetch_new():
with _build_client(port, baud, host, tcp_port) as client:
info = client.connect()
all_evs = client.get_events(stop_after_index=device_count - 1)
return info, all_evs
info, all_events = _run_with_retry(_fetch_new, is_tcp=_is_tcp(host))
except HTTPException:
raise
except ProtocolError as exc:
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
except OSError as exc:
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
_backfill_events(all_events, info)
# Only the new events (indices >= cached_count) are truly new.
new_events = [ev for ev in all_events if ev.index >= cached_count]
new_serialised = [_serialise_event(ev) for ev in new_events]
cache.set_events(conn_key, new_serialised)
cache.set_device_info(conn_key, _serialise_device_info(info))
merged_events = cache.get_all_events(conn_key)
return {
"device": _serialise_device_info(info),
"event_count": len(merged_events),
"events": merged_events,
"_cached": True,
"_new_events": len(new_events),
}
# ── Full download path (first call, force=True, or debug=True) ───────────
# ── Full download ─────────────────────────────────────────────────────────
try:
def _do():
with _build_client(port, baud, host, tcp_port) as client:
@@ -670,14 +543,23 @@ def device_events(
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
_backfill_events(events, info)
serialised = [_serialise_event(ev, debug=debug) for ev in events]
# Fill sample_rate from compliance config where the event record doesn't supply it.
if info.compliance_config and info.compliance_config.sample_rate:
for ev in events:
if ev.sample_rate is None:
ev.sample_rate = info.compliance_config.sample_rate
if not debug:
# Only cache when not in debug mode (debug adds raw_record_hex which
# we don't want polluting the normal cache entries).
cache.set_events(conn_key, serialised)
cache.set_device_info(conn_key, _serialise_device_info(info))
# Backfill event.project_info fields that the 210-byte waveform record doesn't carry.
if info.compliance_config:
cc = info.compliance_config
for ev in events:
if ev.project_info is None:
ev.project_info = ProjectInfo()
pi = ev.project_info
if pi.client is None: pi.client = cc.client
if pi.operator is None: pi.operator = cc.operator
if pi.sensor_location is None: pi.sensor_location = cc.sensor_location
if pi.notes is None: pi.notes = cc.notes
serialised_info = _serialise_device_info(info)
serialised_events = [_serialise_event(ev, debug=debug) for ev in events]
@@ -690,7 +572,8 @@ def device_events(
return {
"device": serialised_info,
"event_count": len(events),
"events": serialised,
"events": serialised_events,
"cached": False,
}
@@ -701,36 +584,21 @@ def device_event(
baud: int = Query(38400, description="Serial baud rate"),
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
force: bool = Query(False, description="Bypass cache and re-download from device"),
) -> dict:
"""
Download a single event by index (0-based).
Supply either *port* (serial) or *host* (TCP/modem).
Performs: POLL startup event index event header waveform record.
**Caching**: if this event was already downloaded (e.g. via GET /device/events),
it is returned instantly from cache with no device contact.
"""
log.info("GET /device/event/%d port=%s host=%s force=%s", index, port, host, force)
cache = get_cache()
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
if not force:
cached = cache.get_event(conn_key, index)
if cached is not None:
log.info("event cache hit for %s index %d", conn_key, index)
cached["_cached"] = True
return cached
log.info("GET /device/event/%d port=%s host=%s", index, port, host)
try:
def _do():
with _build_client(port, baud, host, tcp_port) as client:
info = client.connect()
events = client.get_events(stop_after_index=index)
return info, events
info, events = _run_with_retry(_do, is_tcp=_is_tcp(host))
client.connect()
return client.get_events(stop_after_index=index)
events = _run_with_retry(_do, is_tcp=_is_tcp(host))
except HTTPException:
raise
except ProtocolError as exc:
@@ -747,14 +615,7 @@ def device_event(
detail=f"Event index {index} not found on device",
)
_backfill_events(matching, info)
result = _serialise_event(matching[0])
# Store all downloaded events (we paid for them anyway — indices 0..index)
all_serialised = [_serialise_event(ev) for ev in events]
cache.set_events(conn_key, all_serialised)
return result
return _serialise_event(matching[0])
@app.get("/device/event/{index}/waveform")
@@ -764,7 +625,7 @@ def device_event_waveform(
baud: int = Query(38400, description="Serial baud rate"),
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
force: bool = Query(False, description="Bypass cache and re-download from device"),
force: bool = Query(False, description="Bypass cache and re-download waveform"),
) -> dict:
"""
Download the full raw ADC waveform for a single event (0-based index).
@@ -784,29 +645,28 @@ def device_event_waveform(
- **channels**: dict of channel name list of signed int16 ADC counts
(keys: "Tran", "Vert", "Long", "Mic")
**Caching**: full waveforms are cached permanently after the first download
they are immutable once recorded on the device. Subsequent requests for the
same event return instantly from cache without any device contact.
Pass *force=true* to force a fresh download (rarely needed).
Waveforms are immutable once recorded and are cached permanently per
(connection, event index). Pass ?force=true to re-download.
"""
conn_key = _live_cache.make_conn_key(host, tcp_port, port, baud)
log.info("GET /device/event/%d/waveform port=%s host=%s force=%s", index, port, host, force)
cache = get_cache()
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
if not force:
cached = cache.get_waveform(conn_key, index)
if cached is not None:
log.info("waveform cache hit for %s event %d", conn_key, index)
cached["_cached"] = True
return cached
cached_waveform = _live_cache.get_waveform(conn_key, index)
if cached_waveform is not None:
log.debug("waveform cache hit: %s event %d", conn_key, index)
return cached_waveform
try:
def _do():
with _build_client(port, baud, host, tcp_port, timeout=120.0) as client:
info = client.connect()
# stop_after_index avoids downloading events beyond the one requested.
events = client.get_events(full_waveform=True, stop_after_index=index)
events = client.get_events(
full_waveform=True,
stop_after_index=index,
compliance_config=info.compliance_config if info else None,
)
matching = [ev for ev in events if ev.index == index]
return matching[0] if matching else None, info
ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host))
@@ -833,125 +693,31 @@ def device_event_waveform(
if sample_rate is None and info.compliance_config:
sample_rate = info.compliance_config.sample_rate
# Recompute rectime_seconds using the actual sample rate now that we have it.
# _decode_a5_waveform used 1024 sps as default; override if device says otherwise.
# strt[18] is a record-mode byte (0x46 / 0x0E), NOT rectime in seconds.
rectime_seconds = ev.rectime_seconds
if (ev.total_samples is not None and ev.pretrig_samples is not None
and sample_rate and sample_rate > 0):
post_trig = max(0, ev.total_samples - ev.pretrig_samples)
rectime_seconds = round(post_trig / sample_rate, 2)
result = {
"index": ev.index,
"record_type": ev.record_type,
"timestamp": _serialise_timestamp(ev.timestamp),
"total_samples": ev.total_samples,
"pretrig_samples": ev.pretrig_samples,
"rectime_seconds": ev.rectime_seconds,
"rectime_seconds": rectime_seconds,
"samples_decoded": samples_decoded,
"sample_rate": sample_rate,
"peak_values": _serialise_peak_values(ev.peak_values),
"channels": raw,
}
cache.set_waveform(conn_key, index, result)
_live_cache.set_waveform(conn_key, index, result)
return result
@app.get("/device/event/{index}/blastware_file")
def device_event_blastware_file(
index: int,
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
baud: int = Query(38400, description="Serial baud rate"),
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
) -> FileResponse:
"""
Download the waveform for a single event (0-based index) and return it
as a Blastware-compatible binary file with a correct Blastware filename.
Supply either *port* (serial) or *host* (TCP/modem).
The file is written to /tmp and streamed back as a binary download.
Blastware can open it directly filename encodes serial + timestamp.
Filename format: <prefix><serial3><stem><AB>0<W|H>
- prefix letter = chr(ord('B') + floor(serial_numeric / 1000))
- stem + AB = second-resolution timestamp since 1985-01-01 local
- W / H = Full Waveform / Full Histogram (defaults to W for
triggered events; histogram requires recording_mode
to be populated from compliance config)
Performs: POLL startup get_events(full_waveform=False, extra_chunks=1,
stop_after_index=index) write_blastware_file() FileResponse.
"""
log.info(
"GET /device/event/%d/blastware_file port=%s host=%s",
index, port, host,
)
try:
def _do():
with _build_client(port, baud, host, tcp_port, timeout=120.0) as client:
info = client.connect()
# Use stop_after_metadata=True (full_waveform=False) with 1 extra
# chunk after "Project:". The extra chunk primes the device so that
# the termination response carries the full waveform footer bytes.
# Without it the terminator returns only ~90 bytes (no useful footer).
#
# The extra chunk's ADC data IS part of the Blastware file body —
# confirmed from 4-27-26 TCP capture: all 14 A5 frames (including the
# extra chunk's 2 TCP sub-frames) contribute to the correct 6864-byte
# output. write_blastware_file() includes all frames unconditionally.
#
# full_waveform=True (natural end-of-stream) downloads ALL chunks
# including post-event silence (35+ chunks for a 9-sec event at
# 1024 sps) — this produces 24KB+ files that Blastware rejects.
events = client.get_events(
full_waveform=False,
stop_after_index=index,
extra_chunks_after_metadata=1,
)
matching = [ev for ev in events if ev.index == index]
return matching[0] if matching else None, info
ev, info = _run_with_retry(_do, is_tcp=_is_tcp(host))
except HTTPException:
raise
except ProtocolError as exc:
log.error("blastware_file: protocol error: %s", exc, exc_info=True)
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
except OSError as exc:
log.error("blastware_file: connection error: %s", exc, exc_info=True)
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
except Exception as exc:
log.error("blastware_file: unexpected error: %s", exc, exc_info=True)
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
if ev is None:
raise HTTPException(
status_code=404,
detail=f"Event index {index} not found on device",
)
a5_frames = getattr(ev, "_a5_frames", None)
if not a5_frames:
raise HTTPException(
status_code=502,
detail=f"No waveform data received for event index {index} — 5A download failed",
)
# Determine serial number from device info
serial = getattr(info, "serial", None) or "UNKNOWN"
# Build filename using the same algorithm Blastware uses
filename = blastware_filename(ev, serial)
# Write to /tmp so FastAPI can stream it back
out_path = Path("/tmp") / filename
write_blastware_file(ev, a5_frames, out_path)
log.info(
"blastware_file: wrote %s (%d A5 frames, serial=%s)",
out_path, len(a5_frames), serial,
)
return FileResponse(
path=str(out_path),
filename=filename,
media_type="application/octet-stream",
)
# ── Write endpoints ───────────────────────────────────────────────────────────
class DeviceConfigBody(BaseModel):
@@ -963,15 +729,16 @@ class DeviceConfigBody(BaseModel):
Recording parameters
--------------------
recording_mode : Recording mode enum. Values: 0=Single Shot, 1=Continuous, 3=Histogram, 4=Histogram+Continuous.
sample_rate : Samples per second. Valid values: 1024, 2048, 4096.
record_time : Record duration in seconds (e.g. 1.0, 2.0, 3.0).
sample_rate : Samples per second. Valid values: 1024, 2048, 4096.
record_time : Record duration in seconds (e.g. 1.0, 2.0, 3.0).
Trigger / alarm thresholds and range (geo channels)
----------------------------------------------------
Trigger / alarm thresholds (geo channels, in/s)
------------------------------------------------
trigger_level_geo : Trigger threshold in in/s (e.g. 0.5).
alarm_level_geo : Alarm threshold in in/s (e.g. 1.0).
geo_range : Geophone range/sensitivity. 0=Normal 10.000 in/s, 1=Sensitive 1.250 in/s.
max_range_geo : Full-scale calibration constant (e.g. 6.206).
Rarely changed only set if you know what you're doing.
Project / operator strings (max 41 ASCII characters each)
----------------------------
project : Project description.
@@ -981,14 +748,12 @@ class DeviceConfigBody(BaseModel):
notes : Extended notes.
"""
# Recording parameters
recording_mode: Optional[int] = None
sample_rate: Optional[int] = None
record_time: Optional[float] = None
histogram_interval_sec: Optional[int] = None # seconds: 2, 5, 15, 60, 300, 900 (mode-gated)
# Threshold parameters / geo range
sample_rate: Optional[int] = None
record_time: Optional[float] = None
# Threshold parameters
trigger_level_geo: Optional[float] = None
alarm_level_geo: Optional[float] = None
geo_range: Optional[int] = None # 0=Normal 10.000 in/s, 1=Sensitive 1.250 in/s
max_range_geo: Optional[float] = None
# Project / operator strings
project: Optional[str] = None
client_name: Optional[str] = None
@@ -1016,7 +781,6 @@ def device_config(
Example body (all fields optional include only what you want to change):
{
"recording_mode": 1,
"sample_rate": 1024,
"record_time": 3.0,
"trigger_level_geo": 0.5,
@@ -1044,13 +808,11 @@ def device_config(
with _build_client(port, baud, host, tcp_port) as client:
client.connect()
client.apply_config(
recording_mode=body.recording_mode,
sample_rate=body.sample_rate,
record_time=body.record_time,
histogram_interval_sec=body.histogram_interval_sec,
trigger_level_geo=body.trigger_level_geo,
alarm_level_geo=body.alarm_level_geo,
geo_range=body.geo_range,
max_range_geo=body.max_range_geo,
project=body.project,
client_name=body.client_name,
operator=body.operator,
@@ -1069,9 +831,9 @@ def device_config(
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
# Config was written to the device — the cached compliance config is now stale.
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
get_cache().mark_config_dirty(conn_key)
# Config was written — invalidate cached device info and events so the next
# /device/info or /device/events call re-reads fresh data from the device.
_live_cache.mark_config_dirty(conn_key)
return {
"status": "ok",
@@ -1113,17 +875,14 @@ def device_monitor_status(
Returns is_monitoring bool, battery voltage, and memory usage (total + free
bytes). Battery and memory are only present when the unit is idle.
**Caching**: status is cached for 30 seconds to reduce cellular polling overhead.
Pass *force=true* to bypass the cache for an immediate fresh read.
**Caching:** response is cached for 30 seconds. Pass ?force=true to bypass.
"""
cache = get_cache()
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
conn_key = _live_cache.make_conn_key(host, tcp_port, port, baud)
if not force:
cached = cache.get_monitor_status(conn_key)
cached = _live_cache.get_monitor_status(conn_key)
if cached is not None:
log.debug("monitor status cache hit for %s", conn_key)
cached["_cached"] = True
log.debug("monitor_status cache hit for %s", conn_key)
return cached
with _build_client(port=port, baud=baud, host=host, tcp_port=tcp_port) as client:
@@ -1144,7 +903,7 @@ def device_monitor_status(
result["memory_free_bytes"] = status.memory_free
result["memory_free_kb"] = round(status.memory_free / 1024, 1)
cache.set_monitor_status(conn_key, result)
_live_cache.set_monitor_status(conn_key, result)
return result
@@ -1168,9 +927,7 @@ def device_monitor_start(
log.warning("start monitoring poll retry: %s", exc)
client.poll()
client.start_monitoring()
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
get_cache().invalidate_monitor_status(conn_key)
_live_cache.invalidate_monitor_status(conn_key)
return {"status": "started"}
@@ -1194,182 +951,10 @@ def device_monitor_stop(
log.warning("stop monitoring poll retry: %s", exc)
client.poll()
client.stop_monitoring()
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
get_cache().invalidate_monitor_status(conn_key)
_live_cache.invalidate_monitor_status(conn_key)
return {"status": "stopped"}
# ── Call home config endpoints ───────────────────────────────────────────────
@app.get("/device/call_home")
def device_call_home_get(
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
baud: int = Query(38400, description="Serial baud rate"),
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
) -> dict:
"""
Read the Auto Call Home (ACH) configuration from the device.
Sends SUB 0x2C (two-step read) and returns the decoded call home config.
Confirmed from 4-20-26 call home settings captures (BE11529).
Returns:
{
"auto_call_home_enabled": true/false,
"dial_string": "RADIO RING",
"after_event_recorded": true/false,
"at_specified_times": true/false,
"time1_enabled": true/false, "time1_hour": 19, "time1_min": 55,
"time2_enabled": false, "time2_hour": 0, "time2_min": 0,
"num_retries": 3,
"time_between_retries_sec": 15,
"wait_for_connection_sec": 60,
"warm_up_time_sec": 60
}
"""
try:
def _do():
with _build_client(port, baud, host, tcp_port) as client:
client.poll()
return client.get_call_home_config()
ch_config = _run_with_retry(_do, is_tcp=_is_tcp(host))
except HTTPException:
raise
except ProtocolError as exc:
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
except OSError as exc:
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
return _serialise_call_home_config(ch_config) or {}
class CallHomeConfigBody(BaseModel):
"""
Request body for POST /device/call_home.
All fields are optional only supplied (non-null) fields are modified.
All other call home config bytes are round-tripped verbatim from the device.
Confirmed writable fields (4-20-26 captures):
auto_call_home_enabled : bool master enable for auto call home
after_event_recorded : bool call home after each triggered event
at_specified_times : bool enable time-based scheduled calls
time1_enabled : bool enable time slot 1
time1_hour : int hour for slot 1 (0-23; avoid 3 DLE escape limitation)
time1_min : int minute for slot 1 (0-59; avoid 3)
time2_enabled : bool enable time slot 2
time2_hour : int hour for slot 2 (0-23; avoid 3)
time2_min : int minute for slot 2 (0-59; avoid 3)
Read-only fields (not writable via this endpoint):
dial_string, num_retries, time_between_retries_sec,
wait_for_connection_sec, warm_up_time_sec
"""
auto_call_home_enabled: Optional[bool] = None
after_event_recorded: Optional[bool] = None
at_specified_times: Optional[bool] = None
time1_enabled: Optional[bool] = None
time1_hour: Optional[int] = None
time1_min: Optional[int] = None
time2_enabled: Optional[bool] = None
time2_hour: Optional[int] = None
time2_min: Optional[int] = None
@app.post("/device/call_home")
def device_call_home_set(
body: CallHomeConfigBody,
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
baud: int = Query(38400, description="Serial baud rate"),
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
) -> dict:
"""
Read the current call home config, apply supplied changes, and write back.
Only non-null fields are modified. All other bytes round-trip verbatim.
Write sequence (confirmed from 4-20-26 call home settings captures):
SUB 0x2C (read 2-step) 125-byte raw payload
patch fields
SUB 0x7E (write 127-byte payload) ack 0x81
SUB 0x7F (confirm) ack 0x80
Example body:
{ "auto_call_home_enabled": true, "after_event_recorded": true,
"time1_enabled": true, "time1_hour": 20, "time1_min": 0 }
"""
changed = body.model_dump(exclude_none=True)
log.info("POST /device/call_home port=%s host=%s fields=%s", port, host, list(changed.keys()))
try:
def _do():
with _build_client(port, baud, host, tcp_port) as client:
client.poll()
client.set_call_home_config(
auto_call_home_enabled=body.auto_call_home_enabled,
after_event_recorded=body.after_event_recorded,
at_specified_times=body.at_specified_times,
time1_enabled=body.time1_enabled,
time1_hour=body.time1_hour,
time1_min=body.time1_min,
time2_enabled=body.time2_enabled,
time2_hour=body.time2_hour,
time2_min=body.time2_min,
)
_run_with_retry(_do, is_tcp=_is_tcp(host))
except HTTPException:
raise
except ProtocolError as exc:
raise HTTPException(status_code=502, detail=f"Protocol error: {exc}") from exc
except OSError as exc:
raise HTTPException(status_code=502, detail=f"Connection error: {exc}") from exc
except ValueError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Device error: {exc}") from exc
return {"status": "ok", "updated_fields": changed}
# ── Cache management endpoints ────────────────────────────────────────────────
@app.get("/cache/stats")
def cache_stats() -> dict:
"""
Return row counts for all cache tables.
Useful for debugging and verifying that caching is working as expected.
"""
return get_cache().stats()
@app.delete("/cache/device")
def cache_clear_device(
port: Optional[str] = Query(None, description="Serial port (e.g. COM5)"),
baud: int = Query(38400, description="Serial baud rate"),
host: Optional[str] = Query(None, description="TCP host — modem IP or ACH relay"),
tcp_port: int = Query(DEFAULT_TCP_PORT, description=f"TCP port (default {DEFAULT_TCP_PORT})"),
) -> dict:
"""
Clear all cached data for a specific device (identified by its connection address).
Clears: device info, all event headers, all waveforms, monitor status.
The next request to any endpoint for this device will re-fetch from the device.
Supply either *port* (serial) or *host* (TCP/modem) to identify the device.
"""
conn_key = SFMCache.make_conn_key(host, tcp_port, port, baud)
counts = get_cache().clear_device(conn_key)
return {"status": "cleared", "conn_key": conn_key, "deleted": counts}
# ── DB read endpoints ─────────────────────────────────────────────────────────
#
# These endpoints expose the seismo-relay SQLite DB written by ach_server.py.
@@ -1434,6 +1019,41 @@ def db_set_false_trigger(
return {"status": "ok", "event_id": event_id, "false_trigger": value}
@app.get("/db/events/{event_id}/waveform")
def db_event_waveform(event_id: str) -> dict:
"""
Return the stored waveform blob for a DB event.
The response shape is identical to GET /device/event/{index}/waveform so the
waveform viewer can consume either source without modification:
- total_samples, pretrig_samples, rectime_seconds, samples_decoded
- sample_rate
- peak_values (tran_in_s, vert_in_s, long_in_s, micl_psi, peak_vector_sum)
- channels ({"Tran": [...], "Vert": [...], "Long": [...], "Mic": [...]})
Returns 404 if the event doesn't exist, 422 if the event exists but has no
stored waveform (downloaded before waveform storage was implemented).
"""
import json as _json
db = _get_db()
found, blob_str = db.get_event_waveform(event_id)
if not found:
raise HTTPException(status_code=404, detail=f"Event {event_id} not found")
if blob_str is None:
raise HTTPException(
status_code=422,
detail=(
f"Event {event_id} has no stored waveform. "
"Waveform storage requires ACH server v0.11+. "
"Re-download the event from the device to backfill."
),
)
try:
return _json.loads(blob_str)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Waveform blob corrupt: {exc}") from exc
@app.get("/db/monitor_log")
def db_monitor_log(
serial: Optional[str] = Query(None, description="Filter by unit serial"),
+271 -339
View File
@@ -548,6 +548,18 @@
.ft-toggle-btn:hover { border-color: var(--red); color: var(--red); }
.ft-toggle-btn.flagged { border-color: var(--red); color: var(--red); background: rgba(248,81,73,0.1); }
.wf-btn {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--accent);
cursor: pointer;
font-size: 13px;
padding: 1px 6px;
line-height: 1;
}
.wf-btn:hover { background: rgba(56,139,253,0.15); border-color: var(--accent); }
.db-empty {
color: var(--text-mute);
font-size: 13px;
@@ -736,10 +748,9 @@
<!-- ── Live tab bar ───────────────────────────────────────────────── -->
<div class="tab-bar" id="live-tab-bar">
<button class="tab-btn active" data-tab="device" onclick="switchTab('device')">Device</button>
<button class="tab-btn" data-tab="events" onclick="switchTab('events')">Events</button>
<button class="tab-btn" data-tab="config" onclick="switchTab('config')">Config</button>
<button class="tab-btn" data-tab="call-home" onclick="switchTab('call-home')">Call Home</button>
<button class="tab-btn active" data-tab="device" onclick="switchTab('device')">Device</button>
<button class="tab-btn" data-tab="events" onclick="switchTab('events')">Events</button>
<button class="tab-btn" data-tab="config" onclick="switchTab('config')">Config</button>
</div>
<!-- ════════════════════════════════════════════════════════════════
@@ -771,6 +782,12 @@
<button class="btn btn-ghost" id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
<button class="btn btn-ghost" id="prev-btn" onclick="stepEvent(-1)" disabled></button>
<button class="btn btn-ghost" id="next-btn" onclick="stepEvent(+1)" disabled></button>
<label style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--fg-muted);cursor:pointer;margin-left:4px"
title="Bypass server cache and re-download from device. Checking this auto-reloads if a waveform is already displayed.">
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb"
onchange="if(this.checked && lastWaveformData !== null) loadWaveform()" />
Force&nbsp;reload
</label>
<div class="event-chips" id="event-chips"></div>
</div>
@@ -782,6 +799,14 @@
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="pk-pvs"></div></div>
</div>
<!-- Debug panel: raw ADC sample readout for diagnosing decode issues -->
<div id="debug-panel" style="display:none; background:#0d1117; border-bottom:1px solid #21262d;
padding:5px 16px; font-family:monospace; font-size:11px; color:#6e7681; line-height:1.8">
<span style="float:right; cursor:pointer; color:#484f58; text-decoration:underline"
onclick="document.getElementById('debug-panel').style.display='none'">hide</span>
<div id="debug-content"></div>
</div>
<div id="waveform-area" style="flex:1; overflow-y:auto;">
<div id="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -804,17 +829,6 @@
<div class="cfg-section">
<div class="cfg-section-title">Recording</div>
<div class="cfg-field">
<label>Recording Mode</label>
<select id="cfg-recording-mode">
<option value="">— unchanged —</option>
<option value="0">Single Shot</option>
<option value="1">Continuous</option>
<option value="3">Histogram</option>
<option value="4">Histogram + Continuous</option>
</select>
</div>
<div class="cfg-field">
<label>Sample Rate</label>
<select id="cfg-sample-rate">
@@ -825,20 +839,6 @@
</select>
</div>
<div class="cfg-field">
<label>Histogram Interval</label>
<select id="cfg-histogram-interval">
<option value="">— unchanged —</option>
<option value="2">2 seconds</option>
<option value="5">5 seconds</option>
<option value="15">15 seconds</option>
<option value="60">1 minute</option>
<option value="300">5 minutes</option>
<option value="900">15 minutes</option>
</select>
<div class="hint">Only active in Histogram / Histogram + Continuous mode</div>
</div>
<div class="cfg-field">
<label>Record Time (seconds)</label>
<input type="number" id="cfg-record-time" step="0.5" min="0.5" max="60" placeholder="e.g. 3.0" />
@@ -858,15 +858,10 @@
</div>
<div class="cfg-field">
<label>Maximum Range — Geo</label>
<select id="cfg-geo-range">
<option value="">— unchanged —</option>
<option value="0">Normal — 10.000 in/s</option>
<option value="1">Sensitive — 1.250 in/s</option>
</select>
<div class="hint">Geophone sensitivity (applies to Tran / Vert / Long channels)</div>
<label>Max Range — Geo (in/s)</label>
<input type="number" id="cfg-max-range" step="0.001" min="0.001" placeholder="e.g. 6.206" />
<div class="hint">Full-scale calibration constant — only change if you have a cal cert</div>
</div>
</div>
<!-- Project / operator strings -->
@@ -910,123 +905,6 @@
</div><!-- end #tab-config -->
<!-- ════════════════════════════════════════════════════════════════
TAB: Call Home
═══════════════════════════════════════════════════════════════════ -->
<div id="tab-call-home" class="tab-pane">
<div class="cfg-grid">
<!-- Enable / dial -->
<div class="cfg-section">
<div class="cfg-section-title">Auto Call Home</div>
<div class="cfg-field">
<label>Enable Auto Call Home</label>
<select id="ch-enabled">
<option value="">— unchanged —</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</div>
<div class="cfg-field">
<label>Dial String</label>
<input type="text" id="ch-dial-string" disabled placeholder="Read-only (e.g. RADIO RING)" />
<div class="hint">Read from device — not writable via this interface</div>
</div>
<div class="cfg-section-title" style="margin-top:16px">When to Call</div>
<div class="cfg-field">
<label>After Event Recorded</label>
<select id="ch-after-event">
<option value="">— unchanged —</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<div class="cfg-field">
<label>At Specified Times</label>
<select id="ch-at-times">
<option value="">— unchanged —</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
</div>
<!-- Scheduled call times -->
<div class="cfg-section">
<div class="cfg-section-title">Scheduled Call Times</div>
<div class="cfg-field">
<label>Time Slot 1</label>
<div style="display:flex;gap:8px;align-items:center">
<select id="ch-t1-enabled" style="width:120px">
<option value="">— enable —</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
<input type="number" id="ch-t1-hour" min="0" max="23" step="1" placeholder="HH" style="width:64px" />
<span>:</span>
<input type="number" id="ch-t1-min" min="0" max="59" step="1" placeholder="MM" style="width:64px" />
</div>
<div class="hint">Hour (0-23) and minute (0-59). Avoid value 3 (DLE limitation).</div>
</div>
<div class="cfg-field">
<label>Time Slot 2</label>
<div style="display:flex;gap:8px;align-items:center">
<select id="ch-t2-enabled" style="width:120px">
<option value="">— enable —</option>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
<input type="number" id="ch-t2-hour" min="0" max="23" step="1" placeholder="HH" style="width:64px" />
<span>:</span>
<input type="number" id="ch-t2-min" min="0" max="59" step="1" placeholder="MM" style="width:64px" />
</div>
<div class="hint">Hour (0-23) and minute (0-59). Avoid value 3 (DLE limitation).</div>
</div>
<div class="cfg-section-title" style="margin-top:16px">Retry Settings (read-only)</div>
<div class="cfg-field">
<label>Number of Retries</label>
<input type="text" id="ch-num-retries" disabled placeholder="—" />
</div>
<div class="cfg-field">
<label>Time Between Retries (s)</label>
<input type="text" id="ch-retry-gap" disabled placeholder="—" />
</div>
<div class="cfg-field">
<label>Wait for Connection (s)</label>
<input type="text" id="ch-wait-conn" disabled placeholder="—" />
</div>
<div class="cfg-field">
<label>Warm-up Time (s)</label>
<input type="text" id="ch-warmup" disabled placeholder="—" />
</div>
</div>
</div>
<div class="cfg-actions">
<button class="btn btn-ghost" id="ch-read-btn" onclick="readCallHome()" disabled>Read from Device</button>
<button class="btn btn-success" id="ch-write-btn" onclick="writeCallHome()" disabled>Write to Device</button>
<button class="btn btn-ghost" onclick="clearCallHomeForm()">Clear Form</button>
<span id="ch-status"></span>
</div>
</div><!-- end #tab-call-home -->
</div><!-- end #section-live -->
<!-- ════════════════════════════════════════════════════════════════
@@ -1069,6 +947,7 @@
<table class="db-table" id="hist-table">
<thead>
<tr>
<th></th>
<th>Timestamp</th>
<th>Serial</th>
<th>Tran (in/s)</th>
@@ -1185,7 +1064,8 @@ let unitInfo = null;
let eventList = [];
let currentEvent = 0;
let charts = {};
let geoAdcScale = 6.206;
let geoRange = 6.206;
let lastWaveformData = null; // last successfully rendered waveform payload
const DBL_REF = 2.9e-9; // 20 µPa in psi — reference pressure for dBL
const CHANNEL_COLORS = { Tran:'#58a6ff', Vert:'#3fb950', Long:'#d29922', Mic:'#bc8cff' };
@@ -1310,8 +1190,6 @@ async function connectUnit() {
document.getElementById('next-btn').disabled = eventList.length <= 1;
document.getElementById('cfg-read-btn').disabled = false;
document.getElementById('cfg-write-btn').disabled = false;
document.getElementById('ch-read-btn').disabled = false;
document.getElementById('ch-write-btn').disabled = false;
btn.disabled = false; btn.textContent = 'Reconnect';
@@ -1339,7 +1217,7 @@ function populateDeviceBar() {
qs('di-project').textContent = cc.project || '—';
qs('di-client').textContent = cc.client || '—';
qs('di-operator').textContent = cc.operator || '—';
geoAdcScale = cc.geo_adc_scale ?? 6.206;
geoRange = cc.max_range_geo ?? 6.206;
}
// ── Monitoring ─────────────────────────────────────────────────────────────────
@@ -1476,16 +1354,12 @@ function populateDeviceTab() {
// Compliance table
const cc = unitInfo.compliance_config || {};
const RECORDING_MODE_LABELS = {0: 'Single Shot', 1: 'Continuous', 3: 'Histogram', 4: 'Histogram + Continuous'};
const complianceRows = [
['Recording Mode', cc.recording_mode != null ? (RECORDING_MODE_LABELS[cc.recording_mode] || `0x${cc.recording_mode.toString(16).padStart(2,'0')}`) : '—'],
['Sample Rate', cc.sample_rate != null ? `${cc.sample_rate} sps` : '—'],
['Histogram Interval', cc.histogram_interval_sec != null ? (() => { const s = cc.histogram_interval_sec; return s < 60 ? `${s}s` : `${s/60}m`; })() : ''],
['Record Time', cc.record_time != null ? `${cc.record_time.toFixed(2)} s` : '—'],
['Trigger Level (geo)', cc.trigger_level_geo != null ? `${cc.trigger_level_geo.toFixed(4)} in/s` : '—'],
['Alarm Level (geo)', cc.alarm_level_geo != null ? `${cc.alarm_level_geo.toFixed(4)} in/s` : '—'],
['Max Range (geo)', cc.geo_range != null ? (cc.geo_range === 0 ? 'Normal — 10.000 in/s' : cc.geo_range === 1 ? 'Sensitive — 1.250 in/s' : `0x${cc.geo_range.toString(16).padStart(2,'0')}`) : '—'],
['ADC Scale Factor (geo)', cc.geo_adc_scale != null ? `${cc.geo_adc_scale.toFixed(4)} in/s` : '—'],
['Max Range (geo)', cc.max_range_geo != null ? `${cc.max_range_geo.toFixed(4)} in/s` : '—'],
['Setup Name', cc.setup_name || '—'],
];
renderTable('compliance-table', complianceRows);
@@ -1516,13 +1390,11 @@ function renderTable(id, rows) {
function populateConfigFromDeviceInfo() {
if (!unitInfo) return;
const cc = unitInfo.compliance_config || {};
if (cc.recording_mode != null) qs('cfg-recording-mode', String(cc.recording_mode));
if (cc.sample_rate) qs('cfg-sample-rate', String(cc.sample_rate));
if (cc.histogram_interval_sec != null) qs('cfg-histogram-interval', String(cc.histogram_interval_sec));
if (cc.record_time != null) qs('cfg-record-time', cc.record_time.toFixed(1));
if (cc.trigger_level_geo != null) qs('cfg-trigger', cc.trigger_level_geo.toFixed(4));
if (cc.alarm_level_geo != null) qs('cfg-alarm', cc.alarm_level_geo.toFixed(4));
if (cc.geo_range != null) qs('cfg-geo-range', String(cc.geo_range));
if (cc.sample_rate) qs('cfg-sample-rate', String(cc.sample_rate));
if (cc.record_time != null) qs('cfg-record-time', cc.record_time.toFixed(1));
if (cc.trigger_level_geo != null) qs('cfg-trigger', cc.trigger_level_geo.toFixed(4));
if (cc.alarm_level_geo != null) qs('cfg-alarm', cc.alarm_level_geo.toFixed(4));
if (cc.max_range_geo != null) qs('cfg-max-range',cc.max_range_geo.toFixed(4));
if (cc.project) qs('cfg-project', cc.project);
if (cc.client) qs('cfg-client', cc.client);
if (cc.operator) qs('cfg-operator', cc.operator);
@@ -1531,9 +1403,8 @@ function populateConfigFromDeviceInfo() {
}
function clearConfigForm() {
['cfg-sample-rate','cfg-record-time','cfg-trigger','cfg-alarm',
'cfg-project','cfg-client','cfg-operator','cfg-seis-loc','cfg-notes',
'cfg-recording-mode','cfg-histogram-interval','cfg-geo-range']
['cfg-sample-rate','cfg-record-time','cfg-trigger','cfg-alarm','cfg-max-range',
'cfg-project','cfg-client','cfg-operator','cfg-seis-loc','cfg-notes']
.forEach(id => { const el = qs(id); el.tagName === 'SELECT' ? el.selectedIndex = 0 : el.value = ''; });
setCfgStatus('');
}
@@ -1562,20 +1433,16 @@ async function writeConfig() {
// Build body — only include fields that have values
const body = {};
const rm = qs('cfg-recording-mode').value;
if (rm !== '') body.recording_mode = parseInt(rm, 10);
const sr = qs('cfg-sample-rate').value;
if (sr) body.sample_rate = parseInt(sr, 10);
const hi = qs('cfg-histogram-interval').value;
if (hi !== '') body.histogram_interval_sec = parseInt(hi, 10);
const rt = qs('cfg-record-time').value;
if (rt) body.record_time = parseFloat(rt);
const trig = qs('cfg-trigger').value;
if (trig) body.trigger_level_geo = parseFloat(trig);
const alarm = qs('cfg-alarm').value;
if (alarm) body.alarm_level_geo = parseFloat(alarm);
const gr = qs('cfg-geo-range').value;
if (gr !== '') body.geo_range = parseInt(gr, 10);
const mr = qs('cfg-max-range').value;
if (mr) body.max_range_geo = parseFloat(mr);
const proj = qs('cfg-project').value.trim();
if (proj) body.project = proj;
const cli = qs('cfg-client').value.trim();
@@ -1614,134 +1481,6 @@ async function writeConfig() {
}
}
// ── Call Home form ─────────────────────────────────────────────────────────────
function setChStatus(msg, type) {
const el = document.getElementById('ch-status');
el.textContent = msg;
el.style.color = type === 'ok' ? '#4caf50' : type === 'error' ? '#f44336' : '#aaa';
}
function populateCallHomeForm(ch) {
if (!ch) return;
const qs2 = id => document.getElementById(id);
// Read-only display fields
if (ch.dial_string != null) qs2('ch-dial-string').value = ch.dial_string || '';
if (ch.num_retries != null) qs2('ch-num-retries').value = ch.num_retries;
if (ch.time_between_retries_sec != null) qs2('ch-retry-gap').value = ch.time_between_retries_sec;
if (ch.wait_for_connection_sec != null) qs2('ch-wait-conn').value = ch.wait_for_connection_sec;
if (ch.warm_up_time_sec != null) qs2('ch-warmup').value = ch.warm_up_time_sec;
// Editable select/input fields (use "" for "unchanged" state when value is null)
function setBool(id, val) {
if (val != null) document.getElementById(id).value = val ? 'true' : 'false';
}
setBool('ch-enabled', ch.auto_call_home_enabled);
setBool('ch-after-event', ch.after_event_recorded);
setBool('ch-at-times', ch.at_specified_times);
setBool('ch-t1-enabled', ch.time1_enabled);
setBool('ch-t2-enabled', ch.time2_enabled);
if (ch.time1_hour != null) qs2('ch-t1-hour').value = ch.time1_hour;
if (ch.time1_min != null) qs2('ch-t1-min').value = ch.time1_min;
if (ch.time2_hour != null) qs2('ch-t2-hour').value = ch.time2_hour;
if (ch.time2_min != null) qs2('ch-t2-min').value = ch.time2_min;
}
function clearCallHomeForm() {
['ch-enabled','ch-after-event','ch-at-times','ch-t1-enabled','ch-t2-enabled']
.forEach(id => { document.getElementById(id).selectedIndex = 0; });
['ch-t1-hour','ch-t1-min','ch-t2-hour','ch-t2-min']
.forEach(id => { document.getElementById(id).value = ''; });
// Keep read-only display fields but clear them too
['ch-dial-string','ch-num-retries','ch-retry-gap','ch-wait-conn','ch-warmup']
.forEach(id => { document.getElementById(id).value = ''; });
setChStatus('');
}
async function readCallHome() {
if (!devHost()) { setChStatus('Not connected.', 'error'); return; }
setChStatus('Reading call home config from device…');
document.getElementById('ch-read-btn').disabled = true;
try {
const r = await fetch(`${api()}/device/call_home?${deviceParams()}`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
const ch = await r.json();
populateCallHomeForm(ch);
setChStatus('Call home config loaded from device.', 'ok');
} catch(e) {
setChStatus(`Read failed: ${e.message}`, 'error');
} finally {
document.getElementById('ch-read-btn').disabled = false;
}
}
async function writeCallHome() {
if (!devHost()) { setChStatus('Not connected.', 'error'); return; }
// Build body — only include fields that have values
const body = {};
function getBool(id) {
const v = document.getElementById(id).value;
return v === '' ? null : v === 'true';
}
function getIntField(id) {
const v = document.getElementById(id).value.trim();
return v === '' ? null : parseInt(v, 10);
}
const en = getBool('ch-enabled');
if (en !== null) body.auto_call_home_enabled = en;
const ae = getBool('ch-after-event');
if (ae !== null) body.after_event_recorded = ae;
const at = getBool('ch-at-times');
if (at !== null) body.at_specified_times = at;
const t1e = getBool('ch-t1-enabled');
if (t1e !== null) body.time1_enabled = t1e;
const t1h = getIntField('ch-t1-hour');
if (t1h !== null) body.time1_hour = t1h;
const t1m = getIntField('ch-t1-min');
if (t1m !== null) body.time1_min = t1m;
const t2e = getBool('ch-t2-enabled');
if (t2e !== null) body.time2_enabled = t2e;
const t2h = getIntField('ch-t2-hour');
if (t2h !== null) body.time2_hour = t2h;
const t2m = getIntField('ch-t2-min');
if (t2m !== null) body.time2_min = t2m;
if (Object.keys(body).length === 0) {
setChStatus('No fields to write — change at least one field.', 'error');
return;
}
// Warn about value 3 in hour/min fields
const hourMinFields = [body.time1_hour, body.time1_min, body.time2_hour, body.time2_min];
if (hourMinFields.some(v => v === 3)) {
setChStatus('Error: value 3 in hour/minute fields is not supported (DLE protocol limitation).', 'error');
return;
}
const fieldsStr = Object.keys(body).join(', ');
setChStatus(`Writing ${Object.keys(body).length} field(s)…`);
document.getElementById('ch-write-btn').disabled = true;
try {
const r = await fetch(`${api()}/device/call_home?${deviceParams()}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
setChStatus(`Written: ${fieldsStr}`, 'ok');
// Re-read to confirm changes
await readCallHome();
} catch(e) {
setChStatus(`Write failed: ${e.message}`, 'error');
} finally {
document.getElementById('ch-write-btn').disabled = false;
}
}
// ── Events ─────────────────────────────────────────────────────────────────────
function populateEventChips() {
const el = document.getElementById('event-chips');
@@ -1788,13 +1527,14 @@ function updatePeaksBar(ev) {
async function loadWaveform() {
if (!devHost()) { setStatus('Enter device host first.', 'error'); return; }
const idx = currentEvent;
const idx = currentEvent;
const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
document.getElementById('load-btn').disabled = true;
setStatus('Fetching waveform…', 'loading');
let data;
try {
const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}`);
const r = await fetch(`${api()}/device/event/${idx}/waveform?${deviceParams()}${force}`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
data = await r.json();
} catch(e) {
@@ -1803,46 +1543,46 @@ async function loadWaveform() {
return;
}
lastWaveformData = data;
renderWaveform(data);
document.getElementById('load-btn').disabled = false;
}
function renderWaveform(data) {
// ── Shared waveform chart builder ──────────────────────────────────────────────
// Renders waveform channel charts into chartsEl, destroys+replaces instances in
// chartsStore. emptyEl (optional) is shown/hidden based on decoded sample count.
function _buildWaveformCharts(data, chartsEl, emptyEl, chartsStore) {
const sr = data.sample_rate || 1024;
const pretrig = data.pretrig_samples || 0;
const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
const channels = data.channels || {};
// Status bar
const bar = document.getElementById('status-bar');
bar.innerHTML = '';
bar.className = 'ok';
const ts = data.timestamp;
bar.textContent = ts ? `Event #${data.index} — ${ts.display} ` : `Event #${data.index} `;
addPill(`${data.record_type || '?'}`);
addPill(`${sr} sps`);
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
addPill(`pretrig ${pretrig}`);
addPill(`${data.rectime_seconds ?? '?'} s`);
// Destroy old chart instances
Object.values(chartsStore).forEach(c => c.destroy());
for (const k in chartsStore) delete chartsStore[k];
if (decoded === 0) {
document.getElementById('empty-state').style.display = 'flex';
document.getElementById('empty-state').querySelector('p').textContent =
data.record_type === 'Waveform'
if (emptyEl) {
emptyEl.style.display = 'flex';
const p = emptyEl.querySelector('p');
if (p) p.textContent = data.record_type === 'Waveform'
? 'No samples decoded — check server logs'
: `Record type "${data.record_type}" — waveform not supported yet`;
document.getElementById('charts').style.display = 'none';
Object.values(charts).forEach(c => c.destroy()); charts = {};
}
chartsEl.style.display = 'none';
chartsEl.innerHTML = '';
return;
}
const times = Array.from({length: decoded}, (_, i) => ((i - pretrig) / sr * 1000).toFixed(2));
document.getElementById('empty-state').style.display = 'none';
const chartsDiv = document.getElementById('charts');
chartsDiv.style.display = 'flex';
chartsDiv.innerHTML = '';
Object.values(charts).forEach(c => c.destroy()); charts = {};
// Clip to configured record window — device streams extra zero-padded frames
// beyond total_samples; showing them just adds a flat tail to every chart.
const display = data.total_samples ? Math.min(data.total_samples, decoded) : decoded;
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';
chartsEl.style.gap = '8px';
chartsEl.innerHTML = '';
const micPeakPsi = data.peak_values?.micl_psi ?? null;
@@ -1854,8 +1594,8 @@ function renderWaveform(data) {
let plotData, peakLabel, yUnit, ttFmt, tickFmt;
if (isGeo) {
const scale = geoAdcScale / 32767;
plotData = samples.map(s => s * scale);
const scale = geoRange / 32767;
plotData = samples.slice(0, display).map(s => s * scale);
// Use the device-recorded peak from the 0C waveform record — authoritative
// and matches Blastware. Computing from raw samples can catch rogue
// near-full-scale values from decoding artifacts.
@@ -1866,9 +1606,10 @@ function renderWaveform(data) {
ttFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
tickFmt = v => v.toFixed(4);
} else {
const peakCounts = Math.max(...samples.map(Math.abs));
const clippedMic = samples.slice(0, display);
const peakCounts = Math.max(...clippedMic.map(Math.abs));
const micScale = (micPeakPsi !== null && peakCounts > 0) ? Math.abs(micPeakPsi) / peakCounts : 1.0;
plotData = samples.map(s => s * micScale);
plotData = clippedMic.map(s => s * micScale);
const peakPsi = Math.max(...plotData.map(Math.abs));
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF) : -Infinity;
peakLabel = `${peakDbl.toFixed(1)} dBL`;
@@ -1894,9 +1635,9 @@ function renderWaveform(data) {
const cw = document.createElement('div');
cw.className = 'chart-canvas-wrap';
const canvas = document.createElement('canvas');
cw.appendChild(canvas); wrap.appendChild(cw); chartsDiv.appendChild(wrap);
cw.appendChild(canvas); wrap.appendChild(cw); chartsEl.appendChild(wrap);
charts[ch] = new Chart(canvas, {
chartsStore[ch] = new Chart(canvas, {
type: 'line',
data: { labels: rTimes, datasets: [{ data: rData, borderColor: color, borderWidth: 1, pointRadius: 0, tension: 0 }] },
options: {
@@ -1931,6 +1672,64 @@ function renderWaveform(data) {
}
}
function renderWaveform(data) {
const sr = data.sample_rate || 1024;
const pretrig = data.pretrig_samples || 0;
const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
lastWaveformData = data;
// Status bar
const bar = document.getElementById('status-bar');
bar.innerHTML = '';
bar.className = 'ok';
const ts = data.timestamp;
bar.textContent = ts ? `Event #${data.index} — ${ts.display} ` : `Event #${data.index} `;
addPill(`${data.record_type || '?'}`);
addPill(`${sr} sps`);
addPill(`${decoded.toLocaleString()} / ${total.toLocaleString()} samples`);
addPill(`pretrig ${pretrig}`);
addPill(`${data.rectime_seconds ?? '?'} s`);
_buildWaveformCharts(data, document.getElementById('charts'), document.getElementById('empty-state'), charts);
updateDebugPanel(data);
}
// ── Debug panel population ─────────────────────────────────────────────────────
function _fillDebugPanel(data, dbg, cont) {
if (!dbg || !cont) return;
const channels = data.channels || {};
const pv = data.peak_values || {};
const scale = geoRange / 32767;
const geoChans = ['Tran', 'Vert', 'Long'];
let html = '<div style="display:flex;gap:24px;flex-wrap:wrap;">';
for (const ch of [...geoChans, 'Mic']) {
const raw = (channels[ch] || []).slice(0, 8);
if (raw.length === 0) continue;
const maxAbs = Math.max(...raw.map(Math.abs));
const keyMap = { Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' };
const p0c = ch !== 'Mic' ? (pv[keyMap[ch]] ?? null) : null;
const src = p0c !== null ? `<span style="color:#3fb950">0C=${p0c.toFixed(4)}</span>`
: `<span style="color:#e3b341">Math.max≈${(maxAbs*scale).toFixed(4)}</span>`;
html += `<div><span style="color:#8b949e">${ch} raw[0:8]:</span> <span style="color:#c9d1d9">${raw.join(', ')}</span> peak: ${src}</div>`;
}
html += '</div>';
const nullPeaks = geoChans.filter(ch => (pv[{ Tran:'tran_in_s', Vert:'vert_in_s', Long:'long_in_s' }[ch]] ?? null) === null);
if (nullPeaks.length > 0) {
html += `<div style="color:#e3b341;margin-top:2px">⚠ peak0C null for: ${nullPeaks.join(', ')} — peaks shown are Math.max of waveform samples, not 0C record</div>`;
}
html += `<div style="color:#484f58;margin-top:2px">decoded=${data.samples_decoded} total=${data.total_samples} pretrig=${data.pretrig_samples} sr=${data.sample_rate} geoRange=${geoRange.toFixed(3)}</div>`;
cont.innerHTML = html;
dbg.style.display = 'block';
}
function updateDebugPanel(data) {
_fillDebugPanel(data, document.getElementById('debug-panel'), document.getElementById('debug-content'));
}
// ── DB tabs ────────────────────────────────────────────────────────────────────
let histLoaded = false;
let unitsLoaded = false;
@@ -2034,6 +1833,7 @@ async function loadHistory() {
const pvs = ev.peak_vector_sum;
const maxPPV = Math.max(ev.tran_ppv ?? 0, ev.vert_ppv ?? 0, ev.long_ppv ?? 0);
tr.innerHTML = `
<td><button class="wf-btn" onclick="openDbWaveformModal('${ev.id}')" title="View waveform"></button></td>
<td>${_fmtTs(ev.timestamp)}</td>
<td class="td-key">${ev.serial ?? '—'}</td>
<td class="${_ppvClass(ev.tran_ppv)}">${_ppvFmt(ev.tran_ppv)}</td>
@@ -2208,9 +2008,86 @@ async function loadSessions() {
}
}
// ── DB waveform modal ─────────────────────────────────────────────────────────
let modalCharts = {};
async function openDbWaveformModal(id) {
const modal = document.getElementById('wf-modal');
const titleEl = document.getElementById('wf-modal-title');
const chartsEl = document.getElementById('wf-modal-charts');
const emptyEl = document.getElementById('wf-modal-empty');
const peaksEl = document.getElementById('wf-modal-peaks');
const debugEl = document.getElementById('wf-modal-debug');
// Show modal in loading state
titleEl.textContent = 'Loading…';
peaksEl.classList.remove('visible');
if (debugEl) debugEl.style.display = 'none';
chartsEl.style.display = 'none';
chartsEl.innerHTML = '';
emptyEl.style.display = 'flex';
emptyEl.querySelector('p').textContent = 'Loading waveform…';
modal.style.display = 'flex';
let data;
try {
const r = await fetch(`${api()}/db/events/${encodeURIComponent(id)}/waveform`);
if (!r.ok) { const e = await r.json().catch(()=>({})); throw new Error(e.detail || r.statusText); }
data = await r.json();
} catch(e) {
emptyEl.querySelector('p').textContent = `Error: ${e.message}`;
return;
}
// Normalize old blob peak_values keys (pre-fix ACH blobs used tran/vert/long without _in_s)
if (data.peak_values) {
const pv = data.peak_values;
if (pv.tran_in_s == null && pv.tran != null) pv.tran_in_s = pv.tran;
if (pv.vert_in_s == null && pv.vert != null) pv.vert_in_s = pv.vert;
if (pv.long_in_s == null && pv.long != null) pv.long_in_s = pv.long;
}
// Header — DB blobs have timestamp as ISO string; live device returns {display:...}
const sr = data.sample_rate || 1024;
const decoded = data.samples_decoded || 0;
const total = data.total_samples || decoded;
const pretrig = data.pretrig_samples || 0;
let tsStr = '';
if (data.timestamp) {
const tsDisplay = typeof data.timestamp === 'object'
? (data.timestamp.display || String(data.timestamp))
: new Date(data.timestamp).toLocaleString();
tsStr = `<strong style="color:var(--text)">${tsDisplay}</strong> `;
}
titleEl.innerHTML = `${tsStr}<span style="color:var(--text-dim)">${data.record_type || '?'} · ${sr} sps · ${decoded.toLocaleString()} / ${total.toLocaleString()} samples · pretrig ${pretrig} · ${data.rectime_seconds ?? '?'} s</span>`;
// Peaks bar
const pv = data.peak_values || {};
const micDbl = pv.micl_psi != null && pv.micl_psi > 0 ? 20 * Math.log10(pv.micl_psi / DBL_REF) : null;
document.getElementById('wf-mpk-tran').textContent = pv.tran_in_s != null ? `${pv.tran_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-vert').textContent = pv.vert_in_s != null ? `${pv.vert_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-long').textContent = pv.long_in_s != null ? `${pv.long_in_s.toFixed(5)} in/s` : '—';
document.getElementById('wf-mpk-mic').textContent = micDbl != null ? `${micDbl.toFixed(1)} dBL` : '—';
document.getElementById('wf-mpk-pvs').textContent = pv.peak_vector_sum != null ? `${pv.peak_vector_sum.toFixed(5)} in/s` : '—';
peaksEl.classList.add('visible');
_buildWaveformCharts(data, chartsEl, emptyEl, modalCharts);
_fillDebugPanel(data, debugEl, document.getElementById('wf-modal-debug-content'));
}
function closeWfModal() {
const modal = document.getElementById('wf-modal');
if (!modal || modal.style.display === 'none') return;
modal.style.display = 'none';
// Destroy chart instances to free canvas memory
Object.values(modalCharts).forEach(c => c.destroy());
for (const k in modalCharts) delete modalCharts[k];
}
// ── Keyboard shortcuts ─────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return;
if (e.key === 'Escape') { closeWfModal(); return; }
if (e.key === 'ArrowLeft') { stepEvent(-1); e.preventDefault(); }
if (e.key === 'ArrowRight') { stepEvent(+1); e.preventDefault(); }
});
@@ -2224,5 +2101,60 @@ document.getElementById('api-base').value = window.location.origin;
document.getElementById(id)?.addEventListener('keydown', e => { if (e.key === 'Enter') connectUnit(); });
});
</script>
<!-- ── Waveform Modal (DB history view) ──────────────────────────────────────
Opened by openDbWaveformModal(id). Click outside or press Esc to close. -->
<div id="wf-modal"
style="display:none; position:fixed; inset:0; z-index:1000;
background:rgba(1,4,9,0.88); align-items:flex-start;
justify-content:center; padding:24px; overflow:auto;"
onclick="if(event.target===this)closeWfModal()">
<div style="background:var(--surface); border:1px solid var(--border);
border-radius:8px; width:100%; max-width:1100px;
display:flex; flex-direction:column; max-height:calc(100vh - 48px);">
<!-- Header row -->
<div style="display:flex; align-items:center; padding:10px 16px;
border-bottom:1px solid var(--border); flex-shrink:0; gap:10px;">
<div id="wf-modal-title"
style="flex:1; font-size:12px; color:var(--text-dim); font-family:monospace; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;">
</div>
<button onclick="closeWfModal()"
style="background:none; border:none; color:var(--text-dim); cursor:pointer;
font-size:20px; line-height:1; padding:0 2px; flex-shrink:0;"
title="Close (Esc)">×</button>
</div>
<!-- Peaks bar — reuses .peaks-bar styles from live Events tab -->
<div class="peaks-bar" id="wf-modal-peaks">
<div class="pk"><div class="pk-label">Tran</div><div class="pk-value pk-tran" id="wf-mpk-tran"></div></div>
<div class="pk"><div class="pk-label">Vert</div><div class="pk-value pk-vert" id="wf-mpk-vert"></div></div>
<div class="pk"><div class="pk-label">Long</div><div class="pk-value pk-long" id="wf-mpk-long"></div></div>
<div class="pk"><div class="pk-label">MicL</div><div class="pk-value pk-mic" id="wf-mpk-mic"></div></div>
<div class="pk"><div class="pk-label">PVS</div><div class="pk-value pk-pvs" id="wf-mpk-pvs"></div></div>
</div>
<!-- Debug panel (same as live debug panel, hidden by default) -->
<div id="wf-modal-debug"
style="display:none; background:#0d1117; border-bottom:1px solid #21262d;
padding:5px 16px; font-family:monospace; font-size:11px; color:#6e7681; line-height:1.8">
<span style="float:right; cursor:pointer; color:#484f58; text-decoration:underline"
onclick="document.getElementById('wf-modal-debug').style.display='none'">hide</span>
<div id="wf-modal-debug-content"></div>
</div>
<!-- Waveform area -->
<div style="flex:1; overflow-y:auto; min-height:200px;">
<div id="wf-modal-empty"
style="display:flex; flex-direction:column; align-items:center;
justify-content:center; padding:60px 20px; color:var(--text-dim); gap:12px;">
<p>Loading…</p>
</div>
<div id="wf-modal-charts" style="display:none;"></div>
</div>
</div>
</div>
</body>
</html>
+257 -93
View File
@@ -175,6 +175,27 @@
}
#connect-btn:hover { background: #2ea043; }
#connect-btn:disabled { background: #21262d; color: #484f58; }
#debug-panel {
display: none;
background: #0d1117;
border-bottom: 1px solid #21262d;
padding: 6px 20px;
font-family: monospace;
font-size: 11px;
color: #6e7681;
line-height: 1.7;
}
#debug-panel.visible { display: block; }
#debug-panel .dp-row { display: flex; gap: 24px; flex-wrap: wrap; }
#debug-panel .dp-ch { color: #8b949e; }
#debug-panel .dp-ch span { color: #c9d1d9; }
#debug-panel .dp-warn { color: #e3b341; }
#debug-toggle {
background: none; border: none; color: #484f58; font-size: 11px;
cursor: pointer; padding: 0; float: right; text-decoration: underline;
}
#debug-toggle:hover { color: #8b949e; }
</style>
</head>
<body>
@@ -193,6 +214,12 @@
</div>
<button id="connect-btn" onclick="connectUnit()">Connect</button>
<button id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
<label style="display:flex;align-items:center;gap:4px;color:#8b949e;font-size:12px;cursor:pointer"
title="Re-download from device, bypassing server cache. Check this then click Load Waveform (or checking it will auto-reload if a waveform is already shown).">
<input type="checkbox" id="force-reload" style="accent-color:#1f6feb"
onchange="if(this.checked && lastData !== null) loadWaveform()" />
Force&nbsp;reload
</label>
<button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button>
<button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button>
</header>
@@ -219,6 +246,10 @@
</div>
<div id="status-bar">Ready — enter device host and click Connect.</div>
<div id="debug-panel">
<button id="debug-toggle" onclick="document.getElementById('debug-panel').classList.remove('visible')">hide</button>
<div id="debug-content"></div>
</div>
<div id="empty-state">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
@@ -240,10 +271,50 @@
let charts = {};
let lastData = null;
let unitInfo = null;
let geoAdcScale = 10.0; // in/s full-scale for geo channels; updated on connect
let geoRange = 10.0; // in/s full-scale for geo channels; updated on connect
let eventList = []; // populated from /device/events after connect
let currentEventIndex = 0;
// ── DB mode: opened via ?db_id=<uuid>&api_base=<url> from History tab ────────
const _urlParams = new URLSearchParams(window.location.search);
const _dbId = _urlParams.get('db_id');
const _dbApiBase = (_urlParams.get('api_base') || '').replace(/\/$/, '');
async function _loadFromDb() {
const apiBase = _dbApiBase || document.getElementById('api-base').value.replace(/\/$/, '');
setStatus('Loading waveform from database…', 'loading');
document.getElementById('unit-bar').style.display = 'none';
// Hide live-device controls — not relevant in DB mode
document.querySelector('header .conn-group').style.display = 'none';
const url = `${apiBase}/db/events/${encodeURIComponent(_dbId)}/waveform`;
let data;
try {
const resp = await fetch(url);
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: resp.statusText }));
throw new Error(err.detail || resp.statusText);
}
data = await resp.json();
} catch (e) {
setStatus(`Error: ${e.message}`, 'error');
return;
}
lastData = data;
renderWaveform(data);
}
// Auto-load when opened with db_id param
window.addEventListener('DOMContentLoaded', () => {
if (_dbId) {
// Pre-fill api-base if provided
if (_dbApiBase) {
document.getElementById('api-base').value = _dbApiBase;
}
_loadFromDb();
}
});
function setStatus(msg, cls = '') {
const bar = document.getElementById('status-bar');
bar.textContent = msg;
@@ -278,7 +349,7 @@
throw new Error(err.detail || resp.statusText);
}
unitInfo = await resp.json();
geoAdcScale = unitInfo.compliance_config?.geo_adc_scale ?? 10.0;
geoRange = unitInfo.compliance_config?.max_range_geo ?? 10.0;
} catch (e) {
setStatus(`Error: ${e.message}`, 'error');
btn.disabled = false;
@@ -364,7 +435,8 @@
btn.disabled = true;
setStatus('Fetching waveform…', 'loading');
const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
const force = document.getElementById('force-reload')?.checked ? '&force=true' : '';
const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}${force}`;
let data;
try {
@@ -404,8 +476,11 @@
bar.innerHTML = '';
bar.className = 'ok';
const ts = data.timestamp;
if (ts) {
bar.textContent = `Event #${data.index} — ${ts.display} `;
const tsDisplay = ts
? (typeof ts === 'string' ? ts : (ts.display ?? JSON.stringify(ts)))
: null;
if (tsDisplay) {
bar.textContent = `Event #${data.index} — ${tsDisplay} `;
} else {
bar.textContent = `Event #${data.index} `;
}
@@ -413,7 +488,14 @@
appendMeta('sr', `${sr} sps`);
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
appendMeta('pretrig', pretrig);
appendMeta('rectime', `${data.rectime_seconds ?? '?'}s`);
// rectime_seconds is computed from (total_samples - pretrig_samples) / sr in
// _decode_a5_waveform. Also show the compliance config record_time for reference.
const cfgRt = unitInfo?.compliance_config?.record_time;
const strtRt = data.rectime_seconds;
const rtStr = (strtRt !== null && strtRt !== undefined)
? `${strtRt}s (stored)` + (cfgRt !== null && cfgRt !== undefined ? ` / ${cfgRt}s (cfg)` : '')
: (cfgRt !== null && cfgRt !== undefined ? `${cfgRt}s (cfg)` : '?');
appendMeta('rectime', rtStr);
// No waveform data — show a clear reason instead of empty charts
if (decoded === 0) {
@@ -423,14 +505,20 @@
? 'Waveform decode returned no samples — check server logs'
: `Record type "${recType}" — waveform decode not yet supported for this mode`;
document.getElementById('charts').style.display = 'none';
document.getElementById('debug-panel').classList.remove('visible');
Object.values(charts).forEach(c => c.destroy());
charts = {};
return;
}
// Build time axis (ms)
const times = Array.from({ length: decoded }, (_, i) =>
((i - pretrig) / sr * 1000).toFixed(2)
// Clip to total_samples to exclude zero-padding the device appends beyond
// the configured record window. total = pretrig + post_trig (e.g. 256+3072=3328).
// decoded may be larger (e.g. 4495) due to trailing zero-padded bulk-stream frames.
const displayCount = (total > 0 && total < decoded) ? total : decoded;
// Build time axis in seconds (matching Blastware event report layout).
const times = Array.from({ length: displayCount }, (_, i) =>
((i - pretrig) / sr).toFixed(3)
);
// Show charts area
@@ -447,6 +535,15 @@
const micPeakPsi = data.peak_values?.micl_psi ?? null;
const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi
// 0C record peak values (device-computed, authoritative) per channel.
// Keys: live-device endpoint uses tran_in_s/vert_in_s/long_in_s;
// DB blobs created before 2026-04-14 used tran/vert/long — fall back for compat.
const peakValues0C = {
Tran: data.peak_values?.tran_in_s ?? data.peak_values?.tran ?? null,
Vert: data.peak_values?.vert_in_s ?? data.peak_values?.vert ?? null,
Long: data.peak_values?.long_in_s ?? data.peak_values?.long ?? null,
};
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
const samples = channels[ch];
if (!samples || samples.length === 0) continue;
@@ -455,22 +552,38 @@
const isGeo = ch !== 'Mic';
let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt;
// Clip channel samples to displayCount (same as time axis)
const clippedSamples = samples.length > displayCount
? samples.slice(0, displayCount)
: samples;
// peak0C declared here (function scope) so it is visible in the Chart.js
// config block below (which lives outside the if(isGeo) block).
let peak0C = null;
if (isGeo) {
// Geo channels: counts × (range / 32767) → in/s
const scale = geoAdcScale / 32767;
plotSamples = samples.map(c => c * scale);
const peakIns = Math.max(...plotSamples.map(Math.abs));
// Scale factor for the waveform shape (may need calibration per unit)
const scale = geoRange / 32767;
plotSamples = clippedSamples.map(c => c * scale);
// Use the device-computed 0C record peak for the label (authoritative).
// The raw-sample-computed peak can be inflated by frame-boundary artifacts.
peak0C = peakValues0C[ch];
const peakIns = (peak0C !== null && peak0C !== undefined)
? peak0C
: Math.max(...plotSamples.map(Math.abs));
peakLabel = `${peakIns.toFixed(5)} in/s`;
yUnit = 'in/s';
tooltipFmt = v => `${ch}: ${v.toFixed(5)} in/s`;
tickFmt = v => v.toFixed(4);
} else {
// Mic: derive psi/count scale from the 0C peak value, display as psi; show dBL in header
const peakCounts = Math.max(...samples.map(Math.abs));
const peakCounts = Math.max(...clippedSamples.map(Math.abs));
const micScale = (micPeakPsi !== null && peakCounts > 0)
? Math.abs(micPeakPsi) / peakCounts
: 1.0;
plotSamples = samples.map(c => c * micScale);
plotSamples = clippedSamples.map(c => c * micScale);
const peakPsi = Math.max(...plotSamples.map(Math.abs));
const peakDbl = peakPsi > 0 ? 20 * Math.log10(peakPsi / DBL_REF_PSI) : -Infinity;
peakLabel = `${peakDbl.toFixed(1)} dBL (${peakPsi.toExponential(3)} psi)`;
@@ -504,88 +617,139 @@
renderData = plotSamples.filter((_, i) => i % step === 0);
}
const chart = new Chart(canvas, {
type: 'line',
data: {
labels: renderTimes,
datasets: [{
data: renderData,
borderColor: color,
borderWidth: 1,
pointRadius: 0,
tension: 0,
let chart;
try {
chart = new Chart(canvas, {
type: 'line',
data: {
labels: renderTimes,
datasets: [{
data: renderData,
borderColor: color,
borderWidth: 1,
pointRadius: 0,
tension: 0,
}],
},
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: items => `t = ${items[0].label} s`,
label: item => tooltipFmt(item.raw),
},
},
},
scales: {
x: {
type: 'category',
ticks: {
color: '#484f58',
maxTicksLimit: 10,
maxRotation: 0,
callback: (val, i) => renderTimes[i] + ' s',
},
grid: { color: '#21262d' },
},
y: {
// Clamp geo-channel y-axis to ±(0C peak × 1.4) so near-saturation
// decode artifacts (which inflate autoscale to full range) don't
// squash the actual blast signal into an invisible flat line.
// The 0C peak value is authoritative for the true signal amplitude.
// Guard: only apply if peak0C is a valid finite positive number.
...(isGeo && peak0C !== null && peak0C !== undefined
&& isFinite(peak0C) && peak0C > 0 ? {
min: -(peak0C * 1.4),
max: (peak0C * 1.4),
} : {}),
ticks: {
color: '#484f58',
maxTicksLimit: 5,
callback: v => tickFmt(v),
},
grid: { color: '#21262d' },
title: {
display: true,
text: yUnit,
color: '#484f58',
font: { size: 10 },
},
},
},
},
plugins: [{
// Draw trigger line at t=0
id: 'triggerLine',
afterDraw(chart) {
const ctx = chart.ctx;
const xAxis = chart.scales.x;
const yAxis = chart.scales.y;
// Find index of the trigger point (t ≥ 0.000 s)
const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0);
if (zeroIdx < 0) return;
const x = xAxis.getPixelForValue(zeroIdx);
ctx.save();
ctx.beginPath();
ctx.moveTo(x, yAxis.top);
ctx.lineTo(x, yAxis.bottom);
ctx.strokeStyle = 'rgba(248, 81, 73, 0.7)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.restore();
},
}],
},
options: {
animation: false,
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: items => `t = ${items[0].label} ms`,
label: item => tooltipFmt(item.raw),
},
},
},
scales: {
x: {
type: 'category',
ticks: {
color: '#484f58',
maxTicksLimit: 10,
maxRotation: 0,
callback: (val, i) => renderTimes[i] + ' ms',
},
grid: { color: '#21262d' },
},
y: {
ticks: {
color: '#484f58',
maxTicksLimit: 5,
callback: v => tickFmt(v),
},
grid: { color: '#21262d' },
title: {
display: true,
text: yUnit,
color: '#484f58',
font: { size: 10 },
},
},
},
},
plugins: [{
// Draw trigger line at t=0
id: 'triggerLine',
afterDraw(chart) {
const ctx = chart.ctx;
const xAxis = chart.scales.x;
const yAxis = chart.scales.y;
});
} catch (err) {
console.error(`Chart.js error for channel ${ch}:`, err);
canvasWrap.innerHTML = `<p style="color:#f85149;padding:8px;font-size:11px;">Chart error: ${err.message}</p>`;
}
// Find index of t=0
const zeroIdx = renderTimes.findIndex(t => parseFloat(t) >= 0);
if (zeroIdx < 0) return;
const x = xAxis.getPixelForValue(zeroIdx);
ctx.save();
ctx.beginPath();
ctx.moveTo(x, yAxis.top);
ctx.lineTo(x, yAxis.bottom);
ctx.strokeStyle = 'rgba(248, 81, 73, 0.7)';
ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]);
ctx.stroke();
ctx.restore();
},
}],
});
charts[ch] = chart;
if (chart) charts[ch] = chart;
}
// ── Debug panel: raw ADC counts + decode diagnostics ────────────────────
// Shows the first 8 decoded ADC counts per channel and whether peak values
// came from the 0C record (authoritative) or from Math.max fallback.
// Useful for diagnosing channel misalignment without touching server logs.
const dbg = document.getElementById('debug-panel');
const dbgContent = document.getElementById('debug-content');
const geoChans = ['Tran', 'Vert', 'Long'];
const rawChans = channels;
const scale = geoRange / 32767;
let dbgHtml = '<div class="dp-row">';
// per-channel first-8 raw counts
for (const ch of [...geoChans, 'Mic']) {
const raw = (rawChans[ch] || []).slice(0, 8);
if (raw.length === 0) continue;
const maxAbs = Math.max(...raw.map(Math.abs));
const p0c = peakValues0C?.[ch] ?? null;
const src = (ch !== 'Mic' && p0c !== null) ? `0C=${p0c.toFixed(4)}` : `Math.max=${(maxAbs*scale).toFixed(4)}`;
dbgHtml += `<div class="dp-ch">${ch} raw[0:8]: <span>${raw.join(', ')}</span> peak src: <span>${src}</span></div>`;
}
dbgHtml += '</div>';
// warn if peak0C was null for any geo channel
const nullPeaks = geoChans.filter(ch => (peakValues0C?.[ch] ?? null) === null);
if (nullPeaks.length > 0) {
dbgHtml += `<div class="dp-warn">⚠ peak0C null for: ${nullPeaks.join(', ')} — using Math.max fallback (check Force reload + Load Waveform)</div>`;
}
// summary line
dbgHtml += `<div>decoded=${data.samples_decoded} total=${data.total_samples} pretrig=${data.pretrig_samples} sr=${data.sample_rate} geoRange=${geoRange}</div>`;
dbgContent.innerHTML = dbgHtml;
dbg.classList.add('visible');
}
// Auto-detect API base from wherever this page was served from