Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1a6fd5386 | |||
| 6b875e161b | |||
| f5c81f2cab | |||
| a7585cb5e0 | |||
| ae30a02898 | |||
| 2f084ed105 | |||
| 7976b544ed | |||
| 0415af19b4 | |||
| 35c3f4f945 | |||
| 43c8158493 | |||
| 242666f358 | |||
| 03540fdc00 | |||
| f83fd880c0 | |||
| ab2c11e9a9 | |||
| fa887b85d9 | |||
| ecd980d345 | |||
| bc9f16e503 | |||
| aa2b02535b | |||
| 2a2031c3a9 | |||
| 9e7e0bce2a | |||
| 5e2f3bf2a1 | |||
| 39ebd4bdaa | |||
| 84c87d0b57 | |||
| ec6362cb8e | |||
| 3eeafd24aa | |||
| 8cb8b86192 | |||
| 6dcca4da79 | |||
| c47e3a3af0 | |||
| dfbc9f29c5 | |||
| 4331215e23 | |||
| b3dcfe7239 | |||
| 9b5cdfd857 | |||
| 7129aae279 | |||
| 2186bc238b | |||
| 3fb24e1895 | |||
| 7bdd7c92f2 | |||
| b6ffdcfa87 | |||
| a7aec31915 | |||
| 34df9ec5fa | |||
| eec6c3dc6a | |||
| 702e06873e | |||
| 94767f5a9d | |||
| e04114fd6c | |||
| f10c5c1b86 | |||
| aa28495a43 | |||
| b23cf4bb50 | |||
| 969010b983 | |||
| 5fba9bcff8 | |||
| ec7be4d784 | |||
| b8ed237363 | |||
| 5866ecdb3e | |||
| ea9c69b7c9 | |||
| 71bcf71cf7 | |||
| 3e7de848bc | |||
| 72a4209cfd | |||
| 2b5574511e | |||
| ce2c859f11 | |||
| 7f322f9ff9 | |||
| 42b7a88c3d | |||
| c474db4f69 | |||
| 2765ee6ea7 | |||
| ef88240796 | |||
| 5591d345d9 | |||
| 7883a31aa7 | |||
| b241da970d | |||
| 6acb419ebd | |||
| f6a0846bab | |||
| 3d9db8b662 | |||
| c7e7d177e6 | |||
| a3b8d10fa8 | |||
| 4921b0489a | |||
| 8688d815a0 | |||
| 9b50ec9133 | |||
| cba8b1b401 | |||
| 41a14ca468 | |||
| 1bfc6e4258 | |||
| 574d40027f | |||
| 0358acb51d | |||
| cf7d838bf4 | |||
| 5e44cdc668 | |||
| 37d32077a4 | |||
| b384ba66d1 | |||
| 27d9823cc1 | |||
| 70c9528611 | |||
| e8bef1ac7c | |||
| 27db663579 | |||
| e5ea17388a | |||
| c0a5131c7d | |||
| 4ec2f33308 | |||
| 6282eacf8b | |||
| 034b3f044d | |||
| 48d7e94c02 | |||
| 03d224ccc3 | |||
| ef2c38e7db | |||
| b9a8e50b3c | |||
| 77d9c17680 | |||
| 8a1bd34551 | |||
| 09788b931a | |||
| e712d68505 | |||
| 8f5da918b5 | |||
| a03c77af09 | |||
| 87fa9c954f | |||
| 3f7b5c07b5 | |||
| 3d2ebfc057 | |||
| 9d9c14af79 | |||
| ab14328c8b | |||
| 0baf343bf5 | |||
| 05421764a5 | |||
| 74233d7e31 | |||
| 46a86939b7 | |||
| 2db565ff9c | |||
| 990cb8850e | |||
| dda5683572 | |||
| 16e072698b | |||
| c8c57e950c | |||
| a41e7a9e1a | |||
| 8545daac04 | |||
| 1a9dcc04b4 | |||
| a7ab6eaf7c | |||
| 7005ae766d | |||
| bcc044655a | |||
| c2ab94f20c | |||
| b5828de534 | |||
| 9bef430451 | |||
| 781d21f132 | |||
| e3a5c6f07d | |||
| 1397f8486f | |||
| 5b3e8af1e3 | |||
| ad1c9e48b0 | |||
| 227c481022 | |||
| 33de4239f4 | |||
| d0d5a18d5c | |||
| 41090a9346 | |||
| d87e02fab2 | |||
| 57e7225a62 | |||
| 5d43acd827 | |||
| dfa09d2a4f | |||
| ecb1147216 | |||
| 1c570b083a | |||
| 2286d2ccf8 | |||
| 755050b347 | |||
| 6adf8b6078 | |||
| 4fb1bbfe35 | |||
| 3effa1aab5 | |||
| 95f2becf21 | |||
| 2cb95cd45e | |||
| 7cd8fda5e8 | |||
| f495b91d8a | |||
| e4730376ad | |||
| 23e4febba6 | |||
| 8941dd0aef | |||
| dfb974d658 | |||
| 790e442a7a | |||
| 5d0f0855f2 | |||
| 0f5aa7a3fc | |||
| 3b04d4683b | |||
| 0363425d83 | |||
| 66967e036c | |||
| 9bf20803c2 | |||
| 9b1ed1f3a8 | |||
| 501b5080e9 | |||
| 5948c833bd | |||
| c4a5da893c | |||
| 638e60532c | |||
| 6eecd0c1d1 | |||
| 870a10365e | |||
| b2d10fd689 | |||
| ce44852383 | |||
| 6a42facf02 | |||
| 4b703811d9 | |||
| ea4475c9ad | |||
| df51fe0668 | |||
| 114cbb4679 |
+33
-28
@@ -1,28 +1,33 @@
|
||||
/bridges/captures/
|
||||
/example-events/
|
||||
|
||||
/manuals/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Analyzer outputs
|
||||
*.report
|
||||
claude_export_*.md
|
||||
|
||||
# Frame database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
/bridges/captures/
|
||||
/example-events/
|
||||
|
||||
/manuals/
|
||||
|
||||
# Python build artifacts
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Python bytecode
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
*.swp
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Analyzer outputs
|
||||
*.report
|
||||
claude_export_*.md
|
||||
|
||||
# Frame database
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
+448
@@ -4,6 +4,454 @@ All notable changes to seismo-relay are documented here.
|
||||
|
||||
---
|
||||
|
||||
## v0.12.5 — 2026-04-21
|
||||
|
||||
### Changed
|
||||
|
||||
- **`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.
|
||||
|
||||
- **`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 anchor−8 (E5 read) / anchor−7 (write); enum: 0x00=Single Shot,
|
||||
0x01=Continuous, 0x03=Histogram, 0x04=Histogram+Continuous
|
||||
- `histogram_interval_sec`: uint16 BE seconds at anchor−4; 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.
|
||||
|
||||
---
|
||||
|
||||
## v0.12.0 — 2026-04-13
|
||||
|
||||
### Added
|
||||
|
||||
- **`sfm/server.py` — `_LiveCache`** — in-memory live device cache, eliminating
|
||||
redundant TCP round-trips between requests. No extra dependencies (plain Python
|
||||
dict + threading.Lock). Replaces the SQLAlchemy-based `sfm/cache.py` experiment
|
||||
from the `feature/intelligent-caching` branch.
|
||||
|
||||
Cache behaviour by endpoint:
|
||||
|
||||
| Endpoint | Cache strategy |
|
||||
|---|---|
|
||||
| `GET /device/info` | Indefinite; invalidated by `POST /device/config` |
|
||||
| `GET /device/events` | Count-probe fast path: quick `poll()+count_events()` (~2s); return cache if count matches; full download only when new events detected |
|
||||
| `GET /device/monitor/status` | 30-second TTL; invalidated by monitor start/stop |
|
||||
| `GET /device/event/{idx}/waveform` | Permanent per-index (waveforms are immutable) |
|
||||
|
||||
- **`?force=true` param** on all four cached endpoints — bypasses cache and re-reads
|
||||
from device.
|
||||
|
||||
- **`POST /device/config` cache invalidation** — marks device info + events dirty so
|
||||
the next read reflects the new compliance config.
|
||||
|
||||
- **`POST /device/monitor/start` / `stop` cache invalidation** — evicts the monitor
|
||||
status cache entry immediately so the next poll returns the updated state.
|
||||
|
||||
### Removed
|
||||
|
||||
- `sfm/cache.py` — SQLAlchemy-based cache from the experimental caching branch.
|
||||
Its logic has been ported to the sqlite3-native `_LiveCache` class above.
|
||||
`sqlalchemy` is no longer a dependency.
|
||||
|
||||
---
|
||||
|
||||
## v0.11.0 — 2026-04-13
|
||||
|
||||
### Added
|
||||
|
||||
- **`sfm/database.py` — SeismoDb** — SQLite persistence layer for all ACH data.
|
||||
Three tables, all unit-keyed by serial number:
|
||||
- `ach_sessions` — one row per inbound call-home: serial, timestamp, peer IP,
|
||||
events_downloaded, monitor_entries, duration_seconds
|
||||
- `events` — one row per triggered waveform event: serial, waveform_key (dedup),
|
||||
timestamp, Tran/Vert/Long/VectorSum/Mic PPV, project/client/operator/sensor_location
|
||||
strings, sample_rate, record_type, false_trigger flag
|
||||
- `monitor_log` — one row per monitoring interval: serial, waveform_key (dedup),
|
||||
start_time, stop_time, duration_seconds, geo_threshold_ips
|
||||
- WAL mode, per-request connections — safe for the single-writer / occasional-reader
|
||||
ACH server pattern
|
||||
- Deduplication by `(serial, waveform_key)` UNIQUE constraint — re-runs and repeat
|
||||
call-homes never produce duplicate rows
|
||||
|
||||
- **`ach_server.py` — DB integration** — after each successful call-home, writes new
|
||||
events and monitor log entries to `seismo_relay.db` then records the session in
|
||||
`ach_sessions`. DB write failures are logged as warnings and do not abort the session.
|
||||
|
||||
- **`sfm/server.py` — DB read endpoints**:
|
||||
- `GET /db/units` — distinct serials with last_seen, total_events, total_monitor_entries
|
||||
- `GET /db/events` — query events with serial / date range / false_trigger filters
|
||||
- `GET /db/monitor_log` — query monitoring intervals
|
||||
- `GET /db/sessions` — query ACH call-home sessions
|
||||
- `PATCH /db/events/{id}/false_trigger` — flag/unflag false triggers (for review UI)
|
||||
|
||||
### Architecture
|
||||
|
||||
- seismo-relay DB is unit-keyed only — no project concepts. Project aggregation is
|
||||
terra-view's responsibility via `UnitAssignment` / `DeploymentRecord` + date range
|
||||
queries against the SFM DB endpoints.
|
||||
- DB file lives at `bridges/captures/seismo_relay.db` by default.
|
||||
|
||||
---
|
||||
|
||||
## v0.10.0 — 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
- **`MiniMateClient.get_monitor_log_entries(skip_keys=None)`** — browse-mode walk
|
||||
(`1E → 0A → 1F`) that collects partial records (`0x2C` record type) from the device's
|
||||
event list without triggering a full waveform download (no 0C or 5A). Returns
|
||||
`list[MonitorLogEntry]`. Each entry represents one continuous monitoring interval where
|
||||
no threshold was exceeded.
|
||||
|
||||
- **`_decode_0a_partial_header(raw_data, index, key4)`** in `client.py` — decodes a SUB
|
||||
0x0A response payload whose record type is `0x2C`. Extracts:
|
||||
- `start_time` / `stop_time` — two consecutive timestamps; auto-detects 9-byte
|
||||
(sub_code=0x10, single-shot) vs 10-byte (sub_code=0x03, continuous) format from
|
||||
`raw_data[11]`. Handles a 1-byte gap between the two timestamps that occurs when
|
||||
ts1 and ts2 share the same minute:second.
|
||||
- `serial` — device serial string found via `b"BE"` anchor scan.
|
||||
- `geo_threshold_ips` — trigger level found via `b"Geo: "` anchor scan.
|
||||
|
||||
- **`MonitorLogEntry` dataclass** in `models.py` — new model for partial records:
|
||||
`index`, `key`, `start_time`, `stop_time`, `serial`, `geo_threshold_ips`,
|
||||
`raw_header`, and a `duration_seconds` property.
|
||||
|
||||
- **`read_waveform_header()` return value extended** — now returns `(data_rsp.data, length)`
|
||||
(full payload) instead of `(data_rsp.data[11:11+length], length)`. Callers get the
|
||||
complete payload including the record-type byte at position 0. Full records use
|
||||
`raw_data[11:11+length]` as before; partial records are detected by `raw_data[0] == 0x2C`.
|
||||
|
||||
- **ACH server: monitor log collection** — after `get_events()`, calls
|
||||
`get_monitor_log_entries(skip_keys=seen_keys)` and saves new entries to
|
||||
`monitor_log.json` in the session directory. Monitor log keys are included in
|
||||
`downloaded_keys` for state persistence (no re-processing on next call-home).
|
||||
|
||||
- **`_monitor_log_entry_to_dict()`** in `ach_server.py` — serialises a `MonitorLogEntry`
|
||||
to a JSON-compatible dict with ISO-format timestamps.
|
||||
|
||||
### Protocol / Documentation
|
||||
|
||||
- **SUB 0x0A partial record (0x2C) format confirmed** (✅ 4-11-26 MITM capture, 12 frames):
|
||||
- Record type `0x2C` at `raw_data[0]`; length < 64 bytes.
|
||||
- Two timestamps at `raw_data[11:]` — start and stop of the monitoring interval.
|
||||
- ASCII metadata region after timestamps: `BE<serial>\x00Geo: <float> in/s`.
|
||||
- Edge case: 1-byte separator between timestamps when ts1 and ts2 share minute:second.
|
||||
- 10-byte timestamp format (sub_code=0x03) signalled by `raw_data[11] == 0x10`.
|
||||
|
||||
- **Key reuse detection for monitor log entries** — monitor log keys are tracked alongside
|
||||
event keys in `ach_state.json` so the ACH server does not re-process them after a
|
||||
call-home cycle.
|
||||
|
||||
---
|
||||
|
||||
## v0.9.0 — 2026-04-11
|
||||
|
||||
### Added
|
||||
|
||||
- **`MiniMateClient.list_event_keys()`** — fast browse-mode walk (1E → 0A → 1F, no waveform
|
||||
download) that returns the list of event key hex strings currently stored on the device.
|
||||
Used by the ACH server as a cheap pre-check before deciding whether to call `get_events()`.
|
||||
|
||||
- **`get_events(skip_waveform_for_keys=set(...))`** — new optional parameter. For any key in
|
||||
the set the function performs only 0A + 1F(browse) instead of the full
|
||||
1E-arm → 0C → POLL×3 → 5A sequence. Eliminates redundant waveform downloads on repeat
|
||||
call-homes when the device still holds previously downloaded events.
|
||||
|
||||
- **`MiniMateClient.delete_all_events()`** — erases all events from device memory using the
|
||||
confirmed 4-step sequence:
|
||||
- SUB 0xA3 `begin_erase_all` — initiate erase (token=0xFE) → ack 0x5C
|
||||
- SUB 0x1C `read_monitor_status` — intermediate status read (Blastware-required)
|
||||
- SUB 0x06 `read_event_storage_range` — verify storage state (token=0xFE) → 36-byte response
|
||||
- SUB 0xA2 `confirm_erase_all` — commit erase (token=0xFE) → ack 0x5D
|
||||
|
||||
All four steps confirmed from 4-11-26 MITM capture of a live Blastware ACH session.
|
||||
After a successful call, the device's event counter resets to `0x01110000`.
|
||||
|
||||
- **`MiniMateProtocol` erase methods**: `begin_erase_all()`, `confirm_erase_all()`,
|
||||
`read_event_storage_range()` added to `protocol.py` with documented SUB constants
|
||||
`SUB_ERASE_ALL_BEGIN = 0xA3` and `SUB_ERASE_ALL_CONFIRM = 0xA2`.
|
||||
|
||||
- **`bridges/ach_mitm.py`** — transparent TCP-to-TCP MITM proxy. Listens for inbound unit
|
||||
connections, connects upstream to a real Blastware ACH server, and saves both directions
|
||||
to `raw_bw_<ts>.bin` / `raw_s3_<ts>.bin` files matching the existing capture format.
|
||||
Used to capture the 4-11-26 Blastware ACH session including event deletion.
|
||||
Usage: `python bridges/ach_mitm.py --bw-host 127.0.0.1 --bw-port 9999 --listen-port 9998`
|
||||
|
||||
- **ACH server: key-based state tracking** — `ach_state.json` now stores
|
||||
`downloaded_keys: [hex_strings]` and `max_downloaded_key: hex_string` per unit instead of
|
||||
`event_count: N`. This correctly handles the standard workflow where events are deleted
|
||||
from the device after upload — a count-based approach would see `count=0` on the next
|
||||
call-home and silently skip new events.
|
||||
|
||||
- **ACH server: `--clear-after-download` flag** — after a successful download (at least one
|
||||
new event saved), erases all events from the device using `delete_all_events()`. Mirrors
|
||||
the standard Blastware ACH workflow. On success, `downloaded_keys` and
|
||||
`max_downloaded_key` are reset to empty so the next session starts fresh.
|
||||
|
||||
- **ACH server: post-erase key-reuse detection** — after an external erase (Blastware or
|
||||
manual), device keys restart from `0x01110000`, colliding with previously downloaded keys.
|
||||
On each browse walk, if `max(device_keys) < max_downloaded_key` (device counter rolled
|
||||
back), all device keys are treated as new regardless of `seen_keys`. This also catches
|
||||
erases performed by Blastware between our sessions.
|
||||
|
||||
### Protocol / Documentation
|
||||
|
||||
- **SUB 0xA3 / SUB 0xA2 — erase-all sequence confirmed** (✅ 4-11-26 MITM capture):
|
||||
Both frames use `token=0xFE` at `params[7]` and are standard `build_bw_frame` requests
|
||||
(not write-format). Response SUBs follow the standard formula: 0x5C and 0x5D.
|
||||
The intermediate 0x1C + 0x06 reads between them are required by Blastware.
|
||||
|
||||
- **SUB 0x06 — event storage range read confirmed** (✅ 4-11-26 MITM capture):
|
||||
Two-step read, data offset = 0x24 (36 bytes). The last 8 bytes of the response contain
|
||||
the first and last stored event keys (4 bytes each). After a successful erase, both keys
|
||||
read as `01110000` (device-empty state).
|
||||
|
||||
- **Event key counter resets to `0x01110000` after erase** — confirmed by observing key
|
||||
`01110000` on the device immediately after the MITM erase session.
|
||||
|
||||
---
|
||||
|
||||
## v0.8.0 — 2026-04-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Write pipeline end-to-end** — `push_config_raw(event_index_data, compliance_data,
|
||||
trigger_data, waveform_data)` on `MiniMateClient` orchestrates the full
|
||||
`68→73 | 71×3→72 | 82→83 | 69→74→72` write sequence.
|
||||
|
||||
- **`build_bw_write_frame(sub, data, *, offset, params)`** in `framing.py` — dedicated frame
|
||||
builder for write commands (SUBs 0x68–0x83). Doubles only the BW_CMD byte; all other
|
||||
bytes including offset, params, data, and checksum are written raw. Uses the large-frame
|
||||
DLE-aware checksum (`sum(b for b in payload[2:] if b != 0x10) + 0x10) & 0xFF`).
|
||||
|
||||
- **`MiniMateProtocol` write methods** — `write_event_index()`, `write_compliance()`,
|
||||
`write_trigger_config()`, `write_waveform_data()`, `write_confirm()`,
|
||||
`start_monitoring()`, `stop_monitoring()`.
|
||||
|
||||
- **`AchSession` inbound server** (`bridges/ach_server.py`) — accepts call-home TCP
|
||||
connections, runs the full handshake + device-info + event-download sequence, saves
|
||||
`device_info.json` + `events.json` per session.
|
||||
|
||||
### Protocol / Documentation
|
||||
|
||||
- **Write frame format confirmed** (✅ 3-11-26 BW TX capture, all 11 frames): only BW_CMD
|
||||
byte `0x10` is doubled; all other bytes sent raw. Standard `build_bw_frame` DLE-stuffing
|
||||
is incorrect for write commands.
|
||||
- **Write ack responses** confirmed as 17-byte zero-data S3 frames.
|
||||
- **Monitoring SUBs 0x96/0x97** confirmed from 4-8-26 capture.
|
||||
- **SESSION_RESET signal** (`41 03`) required before POLL for monitoring units.
|
||||
- **SUB 0x1C monitoring flag** at `section[1]`: `0x00` = idle, `0x10` = monitoring.
|
||||
Confirmed by byte-diff of all 144 data frames in 4-8-26/2ndtry capture.
|
||||
|
||||
---
|
||||
|
||||
## v0.7.0 — 2026-04-03
|
||||
|
||||
### Added
|
||||
- **Raw ADC waveform decode — `_decode_a5_waveform(frames_data, event)`** in `client.py`.
|
||||
Parses the complete set of SUB 5A A5 response frames into per-channel time-series:
|
||||
- Reads the STRT record from A5[0] (bytes 7+): extracts `total_samples` (BE uint16 at +8),
|
||||
`pretrig_samples` (BE uint16 at +16), and `rectime_seconds` (uint8 at +18) into
|
||||
`event.total_samples / pretrig_samples / rectime_seconds`.
|
||||
- Skips the 6-byte preamble (`00 00 ff ff ff ff`) that follows the 21-byte STRT header;
|
||||
waveform data begins at `strt_pos + 27`.
|
||||
- Strips the 8-byte per-frame counter header from A5[1–6, 8] before appending waveform bytes.
|
||||
- Skips A5[7] (metadata-only) and A5[9] (terminator).
|
||||
- **Cross-frame alignment correction**: accumulates `running_offset % 8` across all frames
|
||||
and discards `(8 − align) % 8` leading bytes per frame to re-align to a T/V/L/M boundary.
|
||||
Required because individual frame waveform payloads are not always multiples of 8 bytes.
|
||||
- Decodes as 4-channel interleaved signed 16-bit LE at 8 bytes per sample-set:
|
||||
bytes 0–1 = Tran, 2–3 = Vert, 4–5 = Long, 6–7 = Mic.
|
||||
- Stores result in `event.raw_samples = {"Tran": [...], "Vert": [...], "Long": [...], "Mic": [...]}`.
|
||||
- **`download_waveform(event)` public method** on `MiniMateClient`.
|
||||
Issues a full SUB 5A stream with `stop_after_metadata=False`, then calls
|
||||
`_decode_a5_waveform()` to populate `event.raw_samples` and `event.total_samples /
|
||||
pretrig_samples / rectime_seconds`. Previously only metadata frames were fetched during
|
||||
`get_events()`; raw waveform data is now available on demand.
|
||||
- **`Event` model new fields** (`models.py`): `total_samples`, `pretrig_samples`,
|
||||
`rectime_seconds` (from STRT record), and `_waveform_key` (4-byte key stored during
|
||||
`get_events()` for later use by `download_waveform()`).
|
||||
|
||||
### Protocol / Documentation
|
||||
- **SUB 5A A5[0] STRT record layout confirmed** (✅ 2026-04-03, 4-2-26 blast capture):
|
||||
- STRT header is 21 bytes: `b"STRT"` + length fields + `total_samples` (BE uint16 at +8) +
|
||||
`pretrig_samples` (BE uint16 at +16) + `rectime_seconds` (uint8 at +18).
|
||||
- Followed by 6-byte preamble: `00 00 ff ff ff ff`. Waveform begins at `strt_pos + 27`.
|
||||
- Confirmed: 4-2-26 blast → `total_samples=9306`, `pretrig_samples=298`, `rectime_seconds=70`.
|
||||
- **Blast/waveform mode A5 format confirmed** (✅ 2026-04-03, 4-2-26 blast capture):
|
||||
4-channel interleaved int16 LE at 8 bytes per sample-set; cross-frame alignment correction
|
||||
required. 948 of 9306 total sample-sets captured via `stop_after_metadata=True` (10 frames).
|
||||
- **Noise/histogram mode A5 format — endianness corrected** (✅ 2026-04-03, 3-31-26 capture):
|
||||
32-byte block samples are signed 16-bit **little-endian** (previously documented as BE).
|
||||
`0a 00` → LE int16 = 10 (correct noise floor); BE would give 2560 (wrong).
|
||||
- Protocol reference §7.6 rewritten — split into §7.6.1 (Blast/Waveform mode) and §7.6.2
|
||||
(Noise/Histogram mode), each with confirmed field layouts and open questions noted.
|
||||
|
||||
---
|
||||
|
||||
## v0.6.0 — 2026-04-02
|
||||
|
||||
### Added
|
||||
- **True event-time metadata via SUB 5A bulk waveform stream** — `get_events()` now issues a SUB 5A request after each SUB 0C download, reads the A5 response frames, and extracts the `Client:`, `User Name:`, and `Seis Loc:` fields as they existed at the moment the event was recorded. Previously these fields were backfilled from the current compliance config (SUB 1A), which reflects today's setup, not the setup active when the event triggered.
|
||||
- `build_5a_frame(offset_word, raw_params)` in `framing.py` — reproduces Blastware's exact wire format for SUB 5A requests: raw (non-DLE-stuffed) `offset_hi`, DLE-stuffed params, and a DLE-aware checksum where `10 XX` pairs count only `XX`.
|
||||
- `bulk_waveform_params()` returns 11 bytes (extra trailing `0x00` confirmed from 1-2-26 BW wire capture).
|
||||
- `read_bulk_waveform_stream(key4, *, stop_after_metadata=True, max_chunks=32)` in `protocol.py` — loops sending chunk requests (counter increments `0x0400` per chunk), stops early when `b"Project:"` is found, then sends a termination frame.
|
||||
- `_decode_a5_metadata_into(frames_data, event)` in `client.py` — needle-searches A5 frame data for `Project:`, `Client:`, `User Name:`, `Seis Loc:`, `Extended Notes` and overwrites `event.project_info`.
|
||||
- **`get_events()` sequence extended** — now `1E → 0A → 0C → 5A → 1F` per event.
|
||||
|
||||
### Fixed
|
||||
- **Compliance config (SUB 1A) channel block missing** — orphaned `self._send(build_bw_frame(SUB_COMPLIANCE, 0x2A, _DATA_PARAMS))` before the B/C/D receive loop had no corresponding `recv_one()`, shifting all subsequent receives one step behind and leaving frame D's channel-block data (trigger_level_geo, alarm_level_geo, max_range_geo) unread. Removed the orphaned send. Total config bytes received now correctly ~2126 (was ~1071).
|
||||
- **Compliance config anchor search range** — `_decode_compliance_config_into()` searched `cfg[40:100]` for the sample-rate/record-time anchor. With the orphaned-send bug fixed the 44-byte padding it had been adding is gone, and the anchor now appears at `cfg[11]`. Search widened to `cfg[0:150]` to be robust to future layout shifts.
|
||||
- Removed byte-content deduplication from `read_compliance_config()` — was masking the real receive-ordering bug.
|
||||
|
||||
### Protocol / Documentation
|
||||
- **SUB 5A frame format confirmed** — `offset_hi` byte (`0x10`) must be sent raw (not DLE-stuffed); checksum is DLE-aware (only the second byte of a `10 XX` pair is summed). Standard `build_bw_frame` DLE-stuffs `0x10` incorrectly for 5A — a dedicated `build_5a_frame` is required.
|
||||
- **Event-time metadata source confirmed** — `Client:`, `User Name:`, and `Seis Loc:` strings are present in A5 frame 7 of the bulk waveform stream (SUB 5A), not in the 210-byte SUB 0C waveform record. They reflect the compliance setup as it was when the event was stored on the device.
|
||||
|
||||
---
|
||||
|
||||
## v0.5.0 — 2026-03-31
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# seismo-relay `v0.5.0`
|
||||
# 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. Connects to instruments over direct RS-232
|
||||
or cellular modem (Sierra Wireless RV50 / RV55).
|
||||
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. Core read pipeline working (device info,
|
||||
> config, event index). Event download and write commands in progress.
|
||||
> See [CHANGELOG.md](CHANGELOG.md) for version history.
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
@@ -19,26 +21,28 @@ seismo-relay/
|
||||
├── seismo_lab.py ← Main GUI (Bridge + Analyzer + Console tabs)
|
||||
│
|
||||
├── minimateplus/ ← MiniMate Plus client library
|
||||
│ ├── transport.py ← SerialTransport and TcpTransport
|
||||
│ ├── protocol.py ← DLE frame layer (read/write/parse)
|
||||
│ ├── client.py ← High-level client (connect, get_config, etc.)
|
||||
│ ├── framing.py ← Frame builder/parser primitives
|
||||
│ └── models.py ← DeviceInfo, EventRecord, etc.
|
||||
│ ├── 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)
|
||||
│ └── server.py ← /device/info, /device/events, /device/event
|
||||
├── 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/
|
||||
│ ├── s3-bridge/
|
||||
│ │ └── s3_bridge.py ← RS-232 serial bridge (capture tool)
|
||||
│ ├── 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 (legacy)
|
||||
│ ├── gui_bridge.py ← Standalone bridge GUI
|
||||
│ └── raw_capture.py ← Simple raw capture tool
|
||||
│
|
||||
├── parsers/
|
||||
│ ├── s3_parser.py ← DLE frame extractor
|
||||
│ ├── s3_analyzer.py ← Session parser, differ, Claude export
|
||||
│ ├── gui_analyzer.py ← Standalone analyzer GUI (legacy)
|
||||
│ ├── gui_analyzer.py ← Standalone analyzer GUI
|
||||
│ └── frame_db.py ← SQLite frame database
|
||||
│
|
||||
└── docs/
|
||||
@@ -49,123 +53,88 @@ seismo-relay/
|
||||
|
||||
## Quick start
|
||||
|
||||
### Seismo Lab (main GUI)
|
||||
### ACH inbound server (production)
|
||||
|
||||
The all-in-one tool. Three tabs: **Bridge**, **Analyzer**, **Console**.
|
||||
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/
|
||||
```
|
||||
python seismo_lab.py
|
||||
|
||||
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 MiniMate Plus commands as a REST API for integration with other systems.
|
||||
Exposes device control and DB queries as a REST API. Proxied by terra-view.
|
||||
|
||||
```
|
||||
cd sfm
|
||||
uvicorn server:app --reload
|
||||
```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
|
||||
```
|
||||
|
||||
**Endpoints:**
|
||||
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` | `/device/info?port=COM5` | Device info via serial |
|
||||
| `GET` | `/device/info?host=1.2.3.4&tcp_port=9034` | Device info via cellular modem |
|
||||
| `GET` | `/device/events?port=COM5` | Event index |
|
||||
| `GET` | `/device/event?port=COM5&index=0` | Single event record |
|
||||
|
||||
---
|
||||
|
||||
## Seismo Lab tabs
|
||||
|
||||
### Bridge tab
|
||||
|
||||
Captures live RS-232 traffic between Blastware and the seismograph. Sits in
|
||||
the middle as a transparent pass-through while logging everything to disk.
|
||||
|
||||
```
|
||||
Blastware → COM4 (virtual) ↔ s3_bridge ↔ COM5 (physical) → MiniMate Plus
|
||||
```
|
||||
|
||||
Set your COM ports and log directory, then hit **Start Bridge**. Use
|
||||
**Add Mark** to annotate the capture at specific moments (e.g. "changed
|
||||
trigger level"). When the bridge starts, the Analyzer tab automatically wires
|
||||
up to the live files and starts updating in real time.
|
||||
|
||||
### Analyzer tab
|
||||
|
||||
Parses raw captures into DLE-framed protocol sessions, diffs consecutive
|
||||
sessions to show exactly which bytes changed, and lets you query across all
|
||||
historical captures via the built-in SQLite database.
|
||||
|
||||
- **Inventory** — all frames in a session, click to drill in
|
||||
- **Hex Dump** — full payload hex dump with changed-byte annotations
|
||||
- **Diff** — byte-level before/after diff between sessions
|
||||
- **Full Report** — plain text session report
|
||||
- **Query DB** — search across all captures by SUB, direction, or byte value
|
||||
|
||||
Use **Export for Claude** to generate a self-contained `.md` report for
|
||||
AI-assisted field mapping.
|
||||
|
||||
### Console tab
|
||||
|
||||
Direct connection to a MiniMate Plus — no bridge, no Blastware. Useful for
|
||||
diagnosing field units over cellular without a full capture session.
|
||||
|
||||
**Connection:** choose Serial (COM port + baud) or TCP (IP + port for
|
||||
cellular modem).
|
||||
|
||||
**Commands:**
|
||||
| Button | What it does |
|
||||
|--------|-------------|
|
||||
| POLL | Startup handshake — confirms unit is alive and identifies model |
|
||||
| Serial # | Reads unit serial number |
|
||||
| Full Config | Reads full 166-byte config block (firmware version, channel scales, etc.) |
|
||||
| Event Index | Reads stored event list |
|
||||
|
||||
Output is colour-coded: TX in blue, raw RX bytes in teal, decoded fields in
|
||||
green, errors in red. **Save Log** writes a timestamped `.log` file to
|
||||
`bridges/captures/`. **Send to Analyzer** injects the captured bytes into the
|
||||
Analyzer tab for deeper inspection.
|
||||
|
||||
---
|
||||
|
||||
## Connecting over cellular (RV50 / RV55 modems)
|
||||
|
||||
Field units connect via Sierra Wireless RV50 or RV55 cellular modems. Use
|
||||
TCP mode in the Console or SFM:
|
||||
|
||||
```
|
||||
# Console tab
|
||||
Transport: TCP
|
||||
Host: <modem public IP>
|
||||
Port: 9034 ← Device Port in ACEmanager (call-up mode)
|
||||
```
|
||||
|
||||
```python
|
||||
# In code
|
||||
from minimateplus.transport import TcpTransport
|
||||
from minimateplus.client import MiniMateClient
|
||||
|
||||
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 9034))
|
||||
info = client.connect()
|
||||
```
|
||||
|
||||
### Required ACEmanager settings (Serial tab)
|
||||
|
||||
These must match exactly — a single wrong setting causes the unit to beep
|
||||
on connect but never respond:
|
||||
|
||||
| Setting | Value | Why |
|
||||
|---------|-------|-----|
|
||||
| Configure Serial Port | `38400,8N1` | Must match MiniMate baud rate |
|
||||
| Flow Control | `None` | Hardware flow control blocks unit TX if pins unconnected |
|
||||
| **Quiet Mode** | **Enable** | **Critical.** Disabled → modem injects `RING`/`CONNECT` onto serial line, corrupting the S3 handshake |
|
||||
| Data Forwarding Timeout | `1` (= 0.1 s) | Lower latency; `5` works but is sluggish |
|
||||
| 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 |
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -173,21 +142,77 @@ on connect but never respond:
|
||||
|
||||
```python
|
||||
from minimateplus import MiniMateClient
|
||||
from minimateplus.transport import SerialTransport, TcpTransport
|
||||
from minimateplus.transport import TcpTransport
|
||||
|
||||
# Serial
|
||||
client = MiniMateClient(port="COM5")
|
||||
|
||||
# TCP (cellular modem)
|
||||
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 9034), timeout=30.0)
|
||||
client = MiniMateClient(transport=TcpTransport("1.2.3.4", 12345), timeout=30.0)
|
||||
|
||||
with client:
|
||||
info = client.connect() # DeviceInfo — model, serial, firmware
|
||||
serial = client.get_serial() # Serial number string
|
||||
config = client.get_config() # Full config block (bytes)
|
||||
events = client.get_events() # Event index
|
||||
# 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
|
||||
@@ -197,23 +222,10 @@ with client:
|
||||
| DLE | `0x10` | Data Link Escape |
|
||||
| STX | `0x02` | Start of frame |
|
||||
| ETX | `0x03` | End of frame |
|
||||
| ACK | `0x41` (`'A'`) | Frame-start marker sent before every frame |
|
||||
| ACK | `0x41` | Frame-start marker sent before every BW frame |
|
||||
| DLE stuffing | `10 10` on wire | Literal `0x10` in payload |
|
||||
|
||||
**S3-side frame** (seismograph → Blastware): `ACK DLE+STX [payload] CHK DLE+ETX`
|
||||
|
||||
**De-stuffed payload header:**
|
||||
```
|
||||
[0] CMD 0x10 = BW request, 0x00 = S3 response
|
||||
[1] ? unknown (0x00 BW / 0x10 S3)
|
||||
[2] SUB Command/response identifier ← the key field
|
||||
[3] PAGE_HI Page address high byte
|
||||
[4] PAGE_LO Page address low byte
|
||||
[5+] DATA Payload content
|
||||
```
|
||||
|
||||
**Response SUB rule:** `response_SUB = 0xFF - request_SUB`
|
||||
Example: request SUB `0x08` (Event Index) → response SUB `0xF7`
|
||||
**Response SUB rule:** `response_SUB = 0xFF - request_SUB` (no exceptions)
|
||||
|
||||
Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/instantel_protocol_reference.md)
|
||||
|
||||
@@ -221,31 +233,36 @@ Full protocol documentation: [`docs/instantel_protocol_reference.md`](docs/insta
|
||||
|
||||
## Requirements
|
||||
|
||||
```
|
||||
```bash
|
||||
pip install pyserial fastapi uvicorn
|
||||
```
|
||||
|
||||
Python 3.10+. Tkinter is included with the standard Python installer on
|
||||
Windows (make sure "tcl/tk and IDLE" is checked during install).
|
||||
Windows (check "tcl/tk and IDLE" during install).
|
||||
|
||||
---
|
||||
|
||||
## Virtual COM ports (bridge capture)
|
||||
|
||||
The bridge needs two COM ports on the same PC — one that Blastware connects
|
||||
to, and one wired to the seismograph. Use a virtual COM port pair
|
||||
(**com0com** or **VSPD**) to give Blastware a port to talk to.
|
||||
|
||||
```
|
||||
Blastware → COM4 (virtual) ↔ s3_bridge.py ↔ COM5 (physical) → MiniMate Plus
|
||||
```
|
||||
|
||||
Use **com0com** or **VSPD** to create the virtual COM pair on Windows.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [ ] Event download — pull waveform records from the unit (SUBs `1E` → `0A` → `0C` → `5A`)
|
||||
- [ ] Write commands — push config changes to the unit (compliance setup, channel config, trigger settings)
|
||||
- [ ] ACH inbound server — accept call-home connections from field units
|
||||
- [ ] Modem manager — push standard configs to RV50/RV55 fleet via Sierra Wireless API
|
||||
- [ ] Full Blastware parity — complete read/write/download cycle without Blastware
|
||||
- [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
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ach_bridge.py — Transparent TCP bridge / splitter for Instantel MiniMate Plus
|
||||
call-home (ACH) traffic.
|
||||
|
||||
Modes
|
||||
-----
|
||||
standalone Accept connection, capture frames, do NOT forward anywhere.
|
||||
Good for initial discovery with a test unit.
|
||||
|
||||
bridge Forward to one upstream server while capturing.
|
||||
Use this for the initial discovery phase with your test server.
|
||||
|
||||
splitter Forward to the PRIMARY upstream (production ACH server) AND
|
||||
mirror a copy to a SECONDARY server simultaneously.
|
||||
The device never knows — it talks to the primary the whole time.
|
||||
If the mirror fails, the primary connection is unaffected.
|
||||
|
||||
Think of it like a headphone splitter: one input, two outputs.
|
||||
Primary → authoritative responses back to device.
|
||||
Mirror → gets all device bytes, its responses are discarded.
|
||||
|
||||
Usage
|
||||
-----
|
||||
# Standalone capture (test/discovery — no forwarding)
|
||||
python bridges/ach_bridge.py --standalone [--port 12345]
|
||||
|
||||
# Bridge mode (forward to one server, e.g. your test server)
|
||||
python bridges/ach_bridge.py --upstream HOST:PORT [--port 12345]
|
||||
|
||||
# Splitter mode (production: forward to prod + mirror to your server)
|
||||
python bridges/ach_bridge.py --upstream PROD_HOST:PORT --mirror MY_HOST:PORT [--port 12345]
|
||||
|
||||
Setup for discovery (test server, don't touch prod)
|
||||
----------------------------------------------------
|
||||
1. Stand up your test ACH server, note its IP and port (e.g. 192.168.1.50:12345).
|
||||
2. Take ONE test unit. In ACEmanager → Call Home, point it at:
|
||||
<this machine's LAN IP> : <--port>
|
||||
3. Run: python bridges/ach_bridge.py --upstream TEST_SERVER:12345 --port 12345
|
||||
4. Trigger the unit. Raw frames are saved to bridges/captures/ach_<ts>/.
|
||||
5. Revert the unit's ACEmanager setting when done.
|
||||
|
||||
Setup for production splitter (when you're ready)
|
||||
-------------------------------------------------
|
||||
This does NOT touch the units. Instead you re-route traffic at the network
|
||||
layer so that call-home packets arrive at a machine running this script first.
|
||||
Typical approach: update the DNS entry / host record your prod ACH server is
|
||||
registered under to point at this machine. The units keep their existing
|
||||
ACEmanager settings.
|
||||
|
||||
python bridges/ach_bridge.py \\
|
||||
--upstream PROD_ACH_HOST:12345 \\
|
||||
--mirror MY_NEW_SERVER:12345 \\
|
||||
--port 12345
|
||||
|
||||
Output (each connection gets its own timestamped sub-directory)
|
||||
------
|
||||
bridges/captures/ach_<ts>/
|
||||
raw_client_<ts>.bin — raw bytes from the device (S3 side)
|
||||
raw_server_<ts>.bin — raw bytes from the primary upstream (BW side)
|
||||
raw_mirror_<ts>.bin — raw bytes from the mirror upstream (splitter mode only)
|
||||
session_<ts>.log — human-readable frame parse log
|
||||
session_<ts>.jsonl — JSON-lines frame log
|
||||
|
||||
raw_client / raw_server are byte-for-byte compatible with parse_capture.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from minimateplus.framing import S3FrameParser, S3Frame
|
||||
|
||||
log = logging.getLogger("ach_bridge")
|
||||
|
||||
|
||||
# ── Frame label helpers ──────────────────────────────────────────────────────
|
||||
|
||||
_KNOWN_RSP_SUBS = {
|
||||
0xA4: "POLL_RSP",
|
||||
0xA5: "BULK_WAVEFORM_RSP",
|
||||
0xE0: "ADVANCE_EVENT_RSP",
|
||||
0xE1: "EVENT_INDEX_FIRST_RSP",
|
||||
0xE3: "MONITOR_STATUS_RSP",
|
||||
0xEA: "SERIAL_NUM_RSP",
|
||||
0xF3: "WAVEFORM_RECORD_RSP",
|
||||
0xF5: "WAVEFORM_HEADER_RSP",
|
||||
0xF7: "EVENT_INDEX_RSP",
|
||||
0xF9: "UNK_06_RSP",
|
||||
0xFE: "DEVICE_INFO_RSP",
|
||||
# Write acks
|
||||
0x97: "EVT_IDX_WRITE_ACK",
|
||||
0x8C: "CONFIRM_B_ACK",
|
||||
0x8E: "COMPLIANCE_WRITE_ACK",
|
||||
0x8D: "CONFIRM_A_ACK",
|
||||
0x7D: "TRIGGER_WRITE_ACK",
|
||||
0x7C: "TRIGGER_CONFIRM_ACK",
|
||||
0x96: "WAVEFORM_WRITE_ACK",
|
||||
0x8B: "CONFIRM_C_ACK",
|
||||
0x69: "START_MONITOR_ACK",
|
||||
0x68: "STOP_MONITOR_ACK",
|
||||
}
|
||||
|
||||
_KNOWN_REQ_SUBS = {
|
||||
0x5B: "POLL",
|
||||
0x5A: "BULK_WAVEFORM",
|
||||
0x1F: "ADVANCE_EVENT",
|
||||
0x1E: "EVENT_INDEX_FIRST",
|
||||
0x1C: "MONITOR_STATUS",
|
||||
0x15: "SERIAL_NUM",
|
||||
0x0C: "WAVEFORM_RECORD",
|
||||
0x0A: "WAVEFORM_HEADER",
|
||||
0x08: "EVENT_INDEX",
|
||||
0x06: "UNK_06",
|
||||
0x01: "DEVICE_INFO",
|
||||
# Write commands
|
||||
0x68: "EVT_IDX_WRITE",
|
||||
0x73: "CONFIRM_B",
|
||||
0x71: "COMPLIANCE_WRITE",
|
||||
0x72: "CONFIRM_A",
|
||||
0x82: "TRIGGER_WRITE",
|
||||
0x83: "TRIGGER_CONFIRM",
|
||||
0x69: "WAVEFORM_WRITE",
|
||||
0x74: "CONFIRM_C",
|
||||
0x96: "START_MONITOR",
|
||||
0x97: "STOP_MONITOR",
|
||||
}
|
||||
|
||||
|
||||
def _label_s3_frame(frame: S3Frame) -> str:
|
||||
name = _KNOWN_RSP_SUBS.get(frame.sub, f"UNK_0x{frame.sub:02X}")
|
||||
chk = "✓" if frame.checksum_valid else "✗CHK"
|
||||
return (
|
||||
f"S3→ SUB=0x{frame.sub:02X} ({name}) "
|
||||
f"page=0x{frame.page_key:04X} data={len(frame.data)}B {chk}"
|
||||
)
|
||||
|
||||
|
||||
def _label_bw_frame(data: bytes, prefix: str = " →BW") -> str:
|
||||
"""Best-effort label for a raw BW request frame (wire bytes)."""
|
||||
# Wire layout: 41 02 10 10 00 sub ...
|
||||
if len(data) < 6:
|
||||
return f"{prefix} (short {len(data)}B)"
|
||||
sub = data[5]
|
||||
name = _KNOWN_REQ_SUBS.get(sub, f"UNK_0x{sub:02X}")
|
||||
return f"{prefix} SUB=0x{sub:02X} ({name}) {len(data)}B"
|
||||
|
||||
|
||||
# ── Per-session capture writer ─────────────────────────────────────────────────
|
||||
|
||||
class CaptureSession:
|
||||
"""Writes raw bytes + parsed log for one TCP connection."""
|
||||
|
||||
def __init__(self, capture_dir: Path, peer: str, *, has_mirror: bool = False):
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
self.dir = capture_dir / f"ach_{ts}"
|
||||
self.dir.mkdir(parents=True, exist_ok=True)
|
||||
self.peer = peer
|
||||
|
||||
self._raw_client = open(self.dir / f"raw_client_{ts}.bin", "wb")
|
||||
self._raw_server = open(self.dir / f"raw_server_{ts}.bin", "wb")
|
||||
self._raw_mirror = (
|
||||
open(self.dir / f"raw_mirror_{ts}.bin", "wb") if has_mirror else None
|
||||
)
|
||||
self._log_fh = open(self.dir / f"session_{ts}.log", "w")
|
||||
self._jsonl_fh = open(self.dir / f"session_{ts}.jsonl", "w")
|
||||
|
||||
self._s3_parser = S3FrameParser()
|
||||
self._frame_count = 0
|
||||
self._byte_count_client = 0
|
||||
self._byte_count_server = 0
|
||||
self._byte_count_mirror = 0
|
||||
|
||||
self._log(
|
||||
f"# ACH capture — peer={peer} "
|
||||
f"mirror={'yes' if has_mirror else 'no'} "
|
||||
f"started={datetime.datetime.now().isoformat()}"
|
||||
)
|
||||
self._log(f"# Output dir: {self.dir}")
|
||||
log.info("Capture session opened: %s (peer=%s)", self.dir, peer)
|
||||
|
||||
# ── public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def feed_client(self, data: bytes) -> None:
|
||||
"""Bytes FROM the device (S3 response frames)."""
|
||||
self._raw_client.write(data)
|
||||
self._raw_client.flush()
|
||||
self._byte_count_client += len(data)
|
||||
|
||||
for byte in data:
|
||||
frame = self._s3_parser.feed(bytes([byte]))
|
||||
if frame:
|
||||
frames = frame if isinstance(frame, list) else [frame]
|
||||
for f in frames:
|
||||
self._frame_count += 1
|
||||
label = _label_s3_frame(f)
|
||||
self._log(f"[{self._frame_count:04d}] {label}")
|
||||
self._log(
|
||||
f" hex: {f.data[:64].hex()}"
|
||||
+ (" ..." if len(f.data) > 64 else "")
|
||||
)
|
||||
self._emit_json("s3", f)
|
||||
|
||||
def feed_server(self, data: bytes) -> None:
|
||||
"""Bytes FROM the primary upstream server (BW request frames)."""
|
||||
self._raw_server.write(data)
|
||||
self._raw_server.flush()
|
||||
self._byte_count_server += len(data)
|
||||
label = _label_bw_frame(data, prefix=" →BW[primary]")
|
||||
self._log(f" {label}")
|
||||
|
||||
def feed_mirror(self, data: bytes) -> None:
|
||||
"""Bytes FROM the mirror server (logged, not forwarded to device)."""
|
||||
if self._raw_mirror:
|
||||
self._raw_mirror.write(data)
|
||||
self._raw_mirror.flush()
|
||||
self._byte_count_mirror += len(data)
|
||||
label = _label_bw_frame(data, prefix=" →BW[mirror] ")
|
||||
self._log(f" {label} [MIRROR — not sent to device]")
|
||||
|
||||
def close(self, reason: str = "connection closed") -> None:
|
||||
self._log(f"# Session ended: {reason}")
|
||||
self._log(
|
||||
f"# Totals — client={self._byte_count_client}B "
|
||||
f"server={self._byte_count_server}B "
|
||||
f"mirror={self._byte_count_mirror}B "
|
||||
f"s3_frames={self._frame_count}"
|
||||
)
|
||||
handles = [self._raw_client, self._raw_server, self._log_fh, self._jsonl_fh]
|
||||
if self._raw_mirror:
|
||||
handles.append(self._raw_mirror)
|
||||
for fh in handles:
|
||||
try:
|
||||
fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
log.info(
|
||||
"Session closed (%s): %dB client, %dB server, %dB mirror, %d S3 frames → %s",
|
||||
reason,
|
||||
self._byte_count_client, self._byte_count_server,
|
||||
self._byte_count_mirror, self._frame_count,
|
||||
self.dir,
|
||||
)
|
||||
|
||||
# ── internals ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _log(self, msg: str) -> None:
|
||||
print(msg, file=self._log_fh, flush=True)
|
||||
print(msg)
|
||||
|
||||
def _emit_json(self, direction: str, frame: S3Frame) -> None:
|
||||
record = {
|
||||
"dir": direction,
|
||||
"sub": frame.sub,
|
||||
"page_key": frame.page_key,
|
||||
"data_len": len(frame.data),
|
||||
"data_hex": frame.data.hex(),
|
||||
"checksum_valid": frame.checksum_valid,
|
||||
}
|
||||
print(json.dumps(record), file=self._jsonl_fh, flush=True)
|
||||
|
||||
|
||||
# ── Bridge / splitter connection handler ──────────────────────────────────────
|
||||
|
||||
class BridgeHandler:
|
||||
"""
|
||||
Handles inbound device connections.
|
||||
|
||||
Modes (determined by which upstreams are configured):
|
||||
standalone — no upstream_host / no mirror_host
|
||||
bridge — upstream_host set, no mirror_host
|
||||
splitter — upstream_host AND mirror_host both set
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
capture_dir: Path,
|
||||
upstream_host: Optional[str],
|
||||
upstream_port: Optional[int],
|
||||
mirror_host: Optional[str] = None,
|
||||
mirror_port: Optional[int] = None,
|
||||
):
|
||||
self.capture_dir = capture_dir
|
||||
self.upstream_host = upstream_host
|
||||
self.upstream_port = upstream_port
|
||||
self.mirror_host = mirror_host
|
||||
self.mirror_port = mirror_port
|
||||
|
||||
async def handle(
|
||||
self,
|
||||
client_reader: asyncio.StreamReader,
|
||||
client_writer: asyncio.StreamWriter,
|
||||
) -> None:
|
||||
peer = client_writer.get_extra_info("peername", ("?", 0))
|
||||
peer_str = f"{peer[0]}:{peer[1]}"
|
||||
log.info("Inbound connection from %s", peer_str)
|
||||
|
||||
has_mirror = bool(self.mirror_host)
|
||||
session = CaptureSession(self.capture_dir, peer_str, has_mirror=has_mirror)
|
||||
|
||||
if not self.upstream_host:
|
||||
# ── Standalone mode ──────────────────────────────────────────────
|
||||
log.info("Standalone mode — recording inbound traffic only")
|
||||
try:
|
||||
while True:
|
||||
data = await client_reader.read(4096)
|
||||
if not data:
|
||||
break
|
||||
session.feed_client(data)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
log.warning("Standalone read error: %s", exc)
|
||||
finally:
|
||||
session.close("standalone capture ended")
|
||||
try:
|
||||
client_writer.close()
|
||||
await client_writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
|
||||
# ── Bridge / splitter mode ───────────────────────────────────────────
|
||||
# Connect to primary upstream (required)
|
||||
try:
|
||||
up_reader, up_writer = await asyncio.open_connection(
|
||||
self.upstream_host, self.upstream_port
|
||||
)
|
||||
log.info("Connected to primary %s:%s", self.upstream_host, self.upstream_port)
|
||||
except Exception as exc:
|
||||
log.error("Failed to connect to primary upstream: %s", exc)
|
||||
session.close(f"primary connect failed: {exc}")
|
||||
client_writer.close()
|
||||
return
|
||||
|
||||
# Connect to mirror upstream (optional — failure is non-fatal)
|
||||
mir_reader: Optional[asyncio.StreamReader] = None
|
||||
mir_writer: Optional[asyncio.StreamWriter] = None
|
||||
if self.mirror_host:
|
||||
try:
|
||||
mir_reader, mir_writer = await asyncio.open_connection(
|
||||
self.mirror_host, self.mirror_port
|
||||
)
|
||||
log.info("Connected to mirror %s:%s", self.mirror_host, self.mirror_port)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"Mirror connect failed — continuing without mirror: %s", exc
|
||||
)
|
||||
session._log(f"# WARNING: mirror connect failed: {exc}")
|
||||
|
||||
# Build relay tasks
|
||||
#
|
||||
# ┌──────────┐ device bytes ┌─────────────┐
|
||||
# │ Device │ ─────────────► │ PRIMARY │ responses ──► device
|
||||
# └──────────┘ └─────────────┘
|
||||
# │
|
||||
# │ device bytes (copy)
|
||||
# ▼
|
||||
# ┌─────────────┐
|
||||
# │ MIRROR │ responses discarded (logged only)
|
||||
# └─────────────┘
|
||||
#
|
||||
tasks = [
|
||||
asyncio.create_task(
|
||||
self._relay_device(client_reader, up_writer, mir_writer, session),
|
||||
name="device→upstreams",
|
||||
),
|
||||
asyncio.create_task(
|
||||
self._relay_simple(up_reader, client_writer, session, "server"),
|
||||
name="primary→device",
|
||||
),
|
||||
]
|
||||
if mir_reader is not None:
|
||||
tasks.append(asyncio.create_task(
|
||||
self._relay_drain(mir_reader, session),
|
||||
name="mirror→drain",
|
||||
))
|
||||
|
||||
try:
|
||||
# Wait for the device-to-upstreams relay to exit first (device
|
||||
# disconnected or primary dropped). Then cancel the rest.
|
||||
done, pending = await asyncio.wait(
|
||||
tasks,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
try:
|
||||
await t
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
except Exception as exc:
|
||||
log.warning("Bridge relay error: %s", exc)
|
||||
finally:
|
||||
session.close("relay ended")
|
||||
for writer in filter(None, [client_writer, up_writer, mir_writer]):
|
||||
try:
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Relay helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _relay_device(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
primary_writer: asyncio.StreamWriter,
|
||||
mirror_writer: Optional[asyncio.StreamWriter],
|
||||
session: CaptureSession,
|
||||
) -> None:
|
||||
"""
|
||||
Read bytes from the device, write to the primary server, and also
|
||||
write a copy to the mirror server (if connected). Mirror write
|
||||
failures are non-fatal — we log and continue.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(4096)
|
||||
if not data:
|
||||
break
|
||||
session.feed_client(data)
|
||||
|
||||
# Primary write — failure IS fatal (lose primary = lose prod)
|
||||
primary_writer.write(data)
|
||||
await primary_writer.drain()
|
||||
|
||||
# Mirror write — failure is non-fatal
|
||||
if mirror_writer is not None:
|
||||
try:
|
||||
mirror_writer.write(data)
|
||||
await mirror_writer.drain()
|
||||
except Exception as exc:
|
||||
log.warning("Mirror write failed (non-fatal): %s", exc)
|
||||
session._log(f"# WARNING: mirror write failed: {exc}")
|
||||
mirror_writer = None # stop trying
|
||||
|
||||
except (asyncio.IncompleteReadError, ConnectionResetError, BrokenPipeError):
|
||||
pass
|
||||
|
||||
async def _relay_simple(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
writer: asyncio.StreamWriter,
|
||||
session: CaptureSession,
|
||||
direction: str,
|
||||
) -> None:
|
||||
"""Standard single-pipe relay (primary→device or vice-versa)."""
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(4096)
|
||||
if not data:
|
||||
break
|
||||
if direction == "server":
|
||||
session.feed_server(data)
|
||||
else:
|
||||
session.feed_client(data)
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
except (asyncio.IncompleteReadError, ConnectionResetError, BrokenPipeError):
|
||||
pass
|
||||
|
||||
async def _relay_drain(
|
||||
self,
|
||||
reader: asyncio.StreamReader,
|
||||
session: CaptureSession,
|
||||
) -> None:
|
||||
"""
|
||||
Read mirror server responses, log them to session, do NOT forward to
|
||||
device. The device only ever sees primary server responses.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(4096)
|
||||
if not data:
|
||||
break
|
||||
session.feed_mirror(data)
|
||||
except (asyncio.IncompleteReadError, ConnectionResetError, BrokenPipeError):
|
||||
pass
|
||||
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
async def main(args: argparse.Namespace) -> None:
|
||||
capture_dir = Path(__file__).parent / "captures"
|
||||
capture_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
upstream_host: Optional[str] = None
|
||||
upstream_port: Optional[int] = None
|
||||
mirror_host: Optional[str] = None
|
||||
mirror_port: Optional[int] = None
|
||||
|
||||
if not args.standalone:
|
||||
if not args.upstream:
|
||||
print("ERROR: --upstream HOST:PORT is required unless --standalone is set.")
|
||||
sys.exit(1)
|
||||
parts = args.upstream.rsplit(":", 1)
|
||||
if len(parts) != 2:
|
||||
print("ERROR: --upstream must be HOST:PORT (e.g. 203.0.113.5:12345)")
|
||||
sys.exit(1)
|
||||
upstream_host = parts[0]
|
||||
upstream_port = int(parts[1])
|
||||
|
||||
if args.mirror:
|
||||
parts = args.mirror.rsplit(":", 1)
|
||||
if len(parts) != 2:
|
||||
print("ERROR: --mirror must be HOST:PORT (e.g. 192.168.1.50:12345)")
|
||||
sys.exit(1)
|
||||
mirror_host = parts[0]
|
||||
mirror_port = int(parts[1])
|
||||
|
||||
handler = BridgeHandler(
|
||||
capture_dir,
|
||||
upstream_host, upstream_port,
|
||||
mirror_host, mirror_port,
|
||||
)
|
||||
|
||||
server = await asyncio.start_server(
|
||||
handler.handle,
|
||||
host="0.0.0.0",
|
||||
port=args.port,
|
||||
)
|
||||
|
||||
# ── Startup banner ────────────────────────────────────────────────────────
|
||||
if args.standalone:
|
||||
mode = "STANDALONE capture (no forwarding)"
|
||||
elif mirror_host:
|
||||
mode = f"SPLITTER primary={upstream_host}:{upstream_port} mirror={mirror_host}:{mirror_port}"
|
||||
else:
|
||||
mode = f"BRIDGE → {upstream_host}:{upstream_port}"
|
||||
|
||||
addrs = ", ".join(str(s.getsockname()) for s in server.sockets)
|
||||
print(f"\n{'='*70}")
|
||||
print(f" ACH bridge/splitter listening on {addrs}")
|
||||
print(f" Mode: {mode}")
|
||||
print(f" Captures: {capture_dir}/ach_<timestamp>/")
|
||||
print(f"{'='*70}")
|
||||
|
||||
if upstream_host and not mirror_host:
|
||||
print(f"\n DISCOVERY PHASE")
|
||||
print(f" Point your TEST unit's ACEmanager call-home destination to:")
|
||||
print(f" <this machine's LAN IP> : {args.port}")
|
||||
print(f" All traffic will be forwarded to {upstream_host}:{upstream_port}")
|
||||
elif mirror_host:
|
||||
print(f"\n SPLITTER MODE — PRODUCTION SAFE")
|
||||
print(f" Units connect as normal. Every byte is forwarded to:")
|
||||
print(f" PRIMARY (authoritative): {upstream_host}:{upstream_port}")
|
||||
print(f" MIRROR (your server): {mirror_host}:{mirror_port}")
|
||||
print(f" Only PRIMARY responses reach the device.")
|
||||
print(f" Mirror failures are logged and do not affect the device.")
|
||||
else:
|
||||
print(f"\n STANDALONE MODE — capture only, nothing forwarded")
|
||||
print(f" Point a unit at <this machine's LAN IP> : {args.port}")
|
||||
|
||||
print(f"\n Waiting for inbound connections... (Ctrl-C to stop)\n")
|
||||
|
||||
async with server:
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Transparent TCP bridge / splitter for Instantel MiniMate Plus "
|
||||
"call-home (ACH) traffic."
|
||||
),
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
p.add_argument(
|
||||
"--upstream", "-u",
|
||||
metavar="HOST:PORT",
|
||||
help=(
|
||||
"Primary upstream ACH server to forward to "
|
||||
"(e.g. 203.0.113.5:12345). "
|
||||
"Omit with --standalone for capture-only mode."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--mirror", "-m",
|
||||
metavar="HOST:PORT",
|
||||
help=(
|
||||
"Mirror / secondary server to receive a copy of all device bytes "
|
||||
"(splitter mode). Mirror responses are logged but NOT forwarded "
|
||||
"to the device. Mirror failures are non-fatal."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--port", "-p",
|
||||
type=int,
|
||||
default=12345,
|
||||
help="Local port to listen on (default: 12345).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--standalone", "-s",
|
||||
action="store_true",
|
||||
help="Capture-only mode: accept connection, do not forward anywhere.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Enable debug logging.",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
|
||||
)
|
||||
try:
|
||||
asyncio.run(main(args))
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped.")
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ach_mitm.py — TCP man-in-the-middle proxy for capturing Blastware ACH sessions.
|
||||
|
||||
The unit calls home to THIS proxy instead of directly to Blastware. The proxy
|
||||
forwards every byte in both directions to the real Blastware ACH server and saves
|
||||
the traffic to separate raw capture files that the Analyzer can load directly.
|
||||
|
||||
Setup
|
||||
-----
|
||||
1. Start Blastware's ACH server on the BW PC as normal (it listens on its port).
|
||||
2. Run this proxy on any machine the unit can reach:
|
||||
|
||||
python bridges/ach_mitm.py --bw-host 192.168.1.50 --bw-port 9999
|
||||
|
||||
3. Point the unit's ACEmanager call-home destination to THIS machine's IP and
|
||||
the --listen-port (default 9999).
|
||||
4. Trigger a call-home (or wait for the unit to call in).
|
||||
5. The proxy transparently forwards everything and saves two files per session:
|
||||
|
||||
ach_mitm_<ts>/raw_bw_<ts>.bin -- bytes Blastware sent to unit (BW TX)
|
||||
ach_mitm_<ts>/raw_s3_<ts>.bin -- bytes unit sent to Blastware (S3 TX)
|
||||
|
||||
Both files load directly in the Analyzer (File > Open Capture).
|
||||
|
||||
The proxy exits cleanly when either side drops the connection.
|
||||
|
||||
Use case: capturing Blastware operations we haven't reverse-engineered yet,
|
||||
e.g. event deletion, factory reset, firmware update.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("ach_mitm")
|
||||
|
||||
|
||||
def _pipe(src: socket.socket, dst: socket.socket, label: str, outfile) -> None:
|
||||
"""Forward bytes from src to dst, writing everything to outfile."""
|
||||
try:
|
||||
while True:
|
||||
data = src.recv(4096)
|
||||
if not data:
|
||||
break
|
||||
dst.sendall(data)
|
||||
outfile.write(data)
|
||||
outfile.flush()
|
||||
log.debug("%s %d bytes", label, len(data))
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
log.info("%s pipe closed", label)
|
||||
# Signal the other direction to stop by shutting down our end.
|
||||
try:
|
||||
dst.shutdown(socket.SHUT_WR)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def handle(unit_sock: socket.socket, peer: str, bw_host: str, bw_port: int,
|
||||
output_dir: Path) -> None:
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
session_dir = output_dir / f"ach_mitm_{ts}"
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log.info("Session %s unit=%s forwarding to %s:%d", ts, peer, bw_host, bw_port)
|
||||
|
||||
# Connect upstream to Blastware.
|
||||
bw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
try:
|
||||
bw_sock.connect((bw_host, bw_port))
|
||||
except OSError as exc:
|
||||
log.error("Cannot reach Blastware at %s:%d: %s", bw_host, bw_port, exc)
|
||||
unit_sock.close()
|
||||
return
|
||||
|
||||
log.info("Connected to Blastware at %s:%d", bw_host, bw_port)
|
||||
|
||||
bw_path = session_dir / f"raw_bw_{ts}.bin" # Blastware → unit (BW TX)
|
||||
s3_path = session_dir / f"raw_s3_{ts}.bin" # unit → Blastware (S3 TX)
|
||||
|
||||
with open(bw_path, "wb") as bw_fh, open(s3_path, "wb") as s3_fh:
|
||||
# Two threads: one per direction.
|
||||
t_bw = threading.Thread(
|
||||
target=_pipe, args=(bw_sock, unit_sock, "BW->unit", bw_fh), daemon=True
|
||||
)
|
||||
t_s3 = threading.Thread(
|
||||
target=_pipe, args=(unit_sock, bw_sock, "unit->BW", s3_fh), daemon=True
|
||||
)
|
||||
t_bw.start()
|
||||
t_s3.start()
|
||||
t_bw.join()
|
||||
t_s3.join()
|
||||
|
||||
bw_bytes = bw_path.stat().st_size
|
||||
s3_bytes = s3_path.stat().st_size
|
||||
log.info(
|
||||
"Session %s done BW->unit: %d bytes unit->BW: %d bytes -> %s",
|
||||
ts, bw_bytes, s3_bytes, session_dir,
|
||||
)
|
||||
|
||||
unit_sock.close()
|
||||
bw_sock.close()
|
||||
|
||||
|
||||
def serve(args: argparse.Namespace) -> None:
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server.bind(("0.0.0.0", args.listen_port))
|
||||
server.listen(5)
|
||||
server.settimeout(1.0)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f" ACH MITM proxy")
|
||||
print(f" Listening on 0.0.0.0:{args.listen_port}")
|
||||
print(f" Forwarding to {args.bw_host}:{args.bw_port}")
|
||||
print(f" Captures in {output_dir.resolve()}/ach_mitm_<ts>/")
|
||||
print(f"{'='*60}")
|
||||
print(f"\n Point the unit's ACEmanager call-home to this machine on port {args.listen_port}")
|
||||
print(f" Ctrl-C to stop\n")
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
client_sock, addr = server.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
peer = f"{addr[0]}:{addr[1]}"
|
||||
log.info("Accepted connection from %s", peer)
|
||||
t = threading.Thread(
|
||||
target=handle,
|
||||
args=(client_sock, peer, args.bw_host, args.bw_port, output_dir),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopping.")
|
||||
finally:
|
||||
server.close()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
ap.add_argument("--bw-host", required=True,
|
||||
help="IP or hostname of the Blastware ACH server")
|
||||
ap.add_argument("--bw-port", type=int, default=9999,
|
||||
help="Port Blastware is listening on (default: 9999)")
|
||||
ap.add_argument("--listen-port", type=int, default=9999,
|
||||
help="Port this proxy listens on (default: 9999)")
|
||||
ap.add_argument("--output", default="bridges/captures/mitm",
|
||||
help="Directory for capture files")
|
||||
ap.add_argument("--log-level", default="INFO",
|
||||
choices=["DEBUG", "INFO", "WARNING", "ERROR"])
|
||||
args = ap.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, args.log_level),
|
||||
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
|
||||
stream=sys.stdout,
|
||||
)
|
||||
|
||||
serve(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,800 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ach_server.py — Minimal inbound ACH (Auto Call Home) server for MiniMate Plus.
|
||||
|
||||
This IS your test server. Run it on any machine on the same network, point a
|
||||
unit's ACEmanager call-home destination at it, and it will speak the full BW
|
||||
protocol to the device: handshake, pull device info, download all events, save
|
||||
everything as JSON.
|
||||
|
||||
The key thing this script tells you that no amount of packet sniffing can:
|
||||
- Does the device speak first (push) or wait for us to send POLL (pull)?
|
||||
|
||||
If startup() completes normally → it's pull protocol, same as Blastware.
|
||||
If startup() times out → the device sent something first; check raw_rx.bin.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python bridges/ach_server.py [--port 12345] [--output bridges/captures/]
|
||||
|
||||
Setup
|
||||
-----
|
||||
1. Run this script on a machine on your local network.
|
||||
2. In ACEmanager → Application → ALEOS Application Framework (or equivalent)
|
||||
find the Call Home / ACH settings. Set:
|
||||
Remote Host: <this machine's LAN IP>
|
||||
Remote Port: 12345
|
||||
3. Trigger the unit (wait for a vibration event, or use the manual call-home
|
||||
button if your firmware version has one).
|
||||
4. The unit connects. This script handshakes, downloads all events,
|
||||
and saves a timestamped session directory.
|
||||
|
||||
Output per session
|
||||
------------------
|
||||
bridges/captures/ach_inbound_<ts>/
|
||||
device_info.json — serial number, firmware version, calibration date, etc.
|
||||
events.json — all events: timestamp, PPV per channel, peaks, metadata
|
||||
raw_rx_<ts>.bin — raw bytes from the device (S3 side) for Analyzer
|
||||
raw_tx_<ts>.bin — raw bytes we sent to the device (BW side) for Analyzer
|
||||
session_<ts>.log — detailed protocol log
|
||||
|
||||
What to look for
|
||||
----------------
|
||||
Push vs pull: Check session_<ts>.log. If the first line after "Connected"
|
||||
shows bytes arriving BEFORE the POLL probe was sent, it's push. If POLL
|
||||
gets a clean response, it's pull.
|
||||
|
||||
Frequency: Look at raw_rx.bin in the Analyzer. SUB 5A (0xA5 responses) carry
|
||||
bulk waveform data — if frequency is sent pre-computed there will be float32
|
||||
values before the ADC sample blocks.
|
||||
|
||||
ACH-specific framing: Does the unit send anything extra before the DLE+STX
|
||||
framing starts? raw_rx.bin will show raw bytes including any preamble.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from minimateplus.transport import SocketTransport
|
||||
from minimateplus.client import MiniMateClient
|
||||
from minimateplus.models import DeviceInfo, Event, MonitorLogEntry
|
||||
from sfm.database import SeismoDb
|
||||
|
||||
log = logging.getLogger("ach_server")
|
||||
|
||||
# ── Per-unit state (downloaded-key set) ───────────────────────────────────────
|
||||
# Persisted as <output_dir>/ach_state.json
|
||||
# Format:
|
||||
# {
|
||||
# "BE11529": {
|
||||
# "downloaded_keys": ["01110000", "0111245a"], # hex keys already on disk
|
||||
# "max_downloaded_key": "0111245a", # highest key ever seen
|
||||
# "last_seen": "2026-04-11T01:04:36"
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# Key-based deduplication works well within a single "key generation" (between
|
||||
# erases). After the device memory is erased the event counter resets to
|
||||
# 0x01110000, so the first new event has the SAME key as the very first event
|
||||
# we ever downloaded. We detect this situation with max_downloaded_key:
|
||||
#
|
||||
# if max(current_device_keys) < max_downloaded_key
|
||||
# → device was wiped and keys have restarted → treat all device keys as new
|
||||
#
|
||||
# After our own erase (--clear-after-download) we also explicitly clear
|
||||
# downloaded_keys and max_downloaded_key so the next session starts fresh.
|
||||
|
||||
_state_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_state(state_path: Path) -> dict:
|
||||
if state_path.exists():
|
||||
try:
|
||||
with open(state_path) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save_state(state_path: Path, state: dict) -> None:
|
||||
with _state_lock:
|
||||
with open(state_path, "w") as f:
|
||||
json.dump(state, f, indent=2)
|
||||
|
||||
|
||||
# ── Per-session handler ────────────────────────────────────────────────────────
|
||||
|
||||
class AchSession:
|
||||
"""
|
||||
Handles one inbound unit connection in its own thread.
|
||||
Wraps the socket in a SocketTransport → MiniMateClient, then runs the
|
||||
standard connect → get_device_info → get_events sequence.
|
||||
|
||||
State tracking (ach_state.json in output_dir):
|
||||
On each successful download we record the SET of event keys downloaded.
|
||||
On the next call-home we compare: if all device keys are already in the
|
||||
set, there's nothing new. If any key is new (including after the device
|
||||
was wiped and re-recorded), we download and save only those events.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sock: socket.socket,
|
||||
peer: str,
|
||||
output_dir: Path,
|
||||
timeout: float,
|
||||
events_only: bool,
|
||||
max_events: Optional[int],
|
||||
state_path: Path,
|
||||
db: "SeismoDb",
|
||||
clear_after_download: bool = False,
|
||||
restart_monitoring: bool = False,
|
||||
) -> None:
|
||||
self.sock = sock
|
||||
self.peer = peer
|
||||
self.output_dir = output_dir
|
||||
self.timeout = timeout
|
||||
self.events_only = events_only
|
||||
self.max_events = max_events
|
||||
self.state_path = state_path
|
||||
self.db = db
|
||||
self.clear_after_download = clear_after_download
|
||||
self.restart_monitoring = restart_monitoring
|
||||
|
||||
def run(self) -> None:
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# Session dir and file handler are created lazily — only after startup
|
||||
# succeeds. This prevents internet scanners and dropped connections from
|
||||
# littering the output directory with empty session folders.
|
||||
try:
|
||||
self._run_inner(ts)
|
||||
except Exception as exc:
|
||||
log.error("Session failed (%s): %s", self.peer, exc, exc_info=True)
|
||||
finally:
|
||||
try:
|
||||
self.sock.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _run_inner(self, ts: str) -> None:
|
||||
transport = SocketTransport(self.sock, peer=self.peer)
|
||||
|
||||
# Collect raw bytes in memory until startup succeeds, then flush to disk.
|
||||
raw_rx_buf: list[bytes] = [] # device → us (S3 side)
|
||||
raw_tx_buf: list[bytes] = [] # us → device (BW side)
|
||||
_orig_read = transport.read
|
||||
_orig_write = transport.write
|
||||
|
||||
def tapped_read(n: int) -> bytes:
|
||||
data = _orig_read(n)
|
||||
if data:
|
||||
raw_rx_buf.append(data)
|
||||
return data
|
||||
|
||||
def tapped_write(data: bytes) -> None:
|
||||
_orig_write(data)
|
||||
if data:
|
||||
raw_tx_buf.append(data)
|
||||
|
||||
transport.read = tapped_read # type: ignore[method-assign]
|
||||
transport.write = tapped_write # type: ignore[method-assign]
|
||||
|
||||
serial: Optional[str] = None
|
||||
|
||||
# ── Step 1: startup handshake ─────────────────────────────────────────
|
||||
# Do this BEFORE creating the session directory so that scanner probes
|
||||
# and dropped connections leave no trace on disk.
|
||||
try:
|
||||
from minimateplus.protocol import MiniMateProtocol
|
||||
client = MiniMateClient(transport=transport, timeout=self.timeout)
|
||||
client.open()
|
||||
proto = MiniMateProtocol(transport, recv_timeout=self.timeout)
|
||||
proto.startup()
|
||||
except Exception as exc:
|
||||
log.warning("Startup failed from %s: %s -- ignoring", self.peer, exc)
|
||||
return # no session dir created
|
||||
|
||||
# Startup succeeded — this is a real unit. Create session dir now.
|
||||
session_dir = self.output_dir / f"ach_inbound_{ts}"
|
||||
session_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_path = session_dir / f"session_{ts}.log"
|
||||
raw_rx_path = session_dir / f"raw_rx_{ts}.bin" # device → us (S3 side)
|
||||
raw_tx_path = session_dir / f"raw_tx_{ts}.bin" # us → device (BW side)
|
||||
|
||||
# Flush buffered bytes to files and switch to direct file writes.
|
||||
raw_rx_fh = open(raw_rx_path, "wb")
|
||||
raw_tx_fh = open(raw_tx_path, "wb")
|
||||
for chunk in raw_rx_buf:
|
||||
raw_rx_fh.write(chunk)
|
||||
for chunk in raw_tx_buf:
|
||||
raw_tx_fh.write(chunk)
|
||||
raw_rx_buf.clear()
|
||||
raw_tx_buf.clear()
|
||||
|
||||
def tapped_read_file(n: int) -> bytes:
|
||||
data = _orig_read(n)
|
||||
if data:
|
||||
raw_rx_fh.write(data)
|
||||
raw_rx_fh.flush()
|
||||
return data
|
||||
|
||||
def tapped_write_file(data: bytes) -> None:
|
||||
_orig_write(data)
|
||||
if data:
|
||||
raw_tx_fh.write(data)
|
||||
raw_tx_fh.flush()
|
||||
|
||||
transport.read = tapped_read_file # type: ignore[method-assign]
|
||||
transport.write = tapped_write_file # type: ignore[method-assign]
|
||||
|
||||
# Wire up file handler now that the session dir exists.
|
||||
fh = logging.FileHandler(log_path, encoding="utf-8")
|
||||
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)-7s %(name)s %(message)s"))
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.addHandler(fh)
|
||||
|
||||
try:
|
||||
# ── Step 2: device info ───────────────────────────────────────────
|
||||
device_info = None
|
||||
if not self.events_only:
|
||||
log.info("Step 2/3: reading device info")
|
||||
try:
|
||||
device_info = client.connect()
|
||||
serial = device_info.serial
|
||||
_save_json(session_dir / "device_info.json", _device_info_to_dict(device_info))
|
||||
log.info(
|
||||
" [OK] Device: serial=%s firmware=%s model=%s events=%d",
|
||||
serial,
|
||||
device_info.firmware_version,
|
||||
device_info.model,
|
||||
device_info.event_count or 0,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.error(" [FAIL] Device info failed: %s", exc)
|
||||
else:
|
||||
log.info("Step 2/3: skipping device info (--events-only)")
|
||||
|
||||
# ── Step 3: check for new events by comparing key sets ────────────
|
||||
log.info("Step 3/3: checking for new events")
|
||||
|
||||
state = _load_state(self.state_path)
|
||||
unit_key = serial or self.peer # fall back to IP if no serial
|
||||
unit_state = state.get(unit_key, {})
|
||||
seen_keys: set[str] = set(unit_state.get("downloaded_keys", []))
|
||||
# Highest event key ever downloaded from this unit (hex string, 8 chars).
|
||||
# Used to detect post-erase key reuse — see comment block above.
|
||||
max_seen_key: str = unit_state.get("max_downloaded_key", "00000000")
|
||||
|
||||
# Walk the event index (browse-mode, no 5A) to get the actual current
|
||||
# key list. The SUB 08 event_count field is a lifetime "total events
|
||||
# ever recorded" counter that does NOT decrement on erase — confirmed
|
||||
# 2026-04-13. list_event_keys() via the 1E/1F chain is the only
|
||||
# reliable way to know what is actually stored on the device right now.
|
||||
log.info(" Checking device key list (browse walk, no waveform download)...")
|
||||
try:
|
||||
device_keys = client.list_event_keys()
|
||||
except Exception as exc:
|
||||
log.warning(" list_event_keys failed: %s -- falling back to full download", exc)
|
||||
device_keys = None
|
||||
|
||||
# Use the walk result as our authoritative current count.
|
||||
current_count = len(device_keys) if device_keys is not None else 0
|
||||
|
||||
log.info(" Unit has %d stored event(s); %d key(s) previously downloaded",
|
||||
current_count, len(seen_keys))
|
||||
|
||||
if device_keys is not None and current_count == 0:
|
||||
log.info(" [OK] No events on device -- nothing to download")
|
||||
log.info("Session complete (no events) -> %s", session_dir)
|
||||
return
|
||||
|
||||
if device_keys is not None:
|
||||
# ── Post-erase detection ──────────────────────────────────────
|
||||
# After the device memory is erased, new events start from key
|
||||
# 01110000 again — the same keys we already downloaded. Detect
|
||||
# this by comparing the device's current highest key against the
|
||||
# historical maximum. If the device has rolled back below our
|
||||
# high-water mark, its counter was reset and we must treat all
|
||||
# its keys as new, regardless of what seen_keys contains.
|
||||
if device_keys and max_seen_key != "00000000":
|
||||
max_device_key = max(device_keys) # lexicographic; safe because
|
||||
# keys share the same 4-char prefix
|
||||
if max_device_key < max_seen_key:
|
||||
log.info(
|
||||
" Post-erase reset detected: "
|
||||
"device max key %s < historical max %s "
|
||||
"-- treating all device keys as new",
|
||||
max_device_key, max_seen_key,
|
||||
)
|
||||
seen_keys = set() # discard stale dedup info for this session
|
||||
|
||||
new_key_set = set(device_keys) - seen_keys
|
||||
log.info(" Device has %d key(s): %d new, %d already seen",
|
||||
len(device_keys), len(new_key_set), len(device_keys) - len(new_key_set))
|
||||
if not new_key_set:
|
||||
log.info(" [OK] All events already downloaded -- nothing to do")
|
||||
# Refresh state timestamp; preserve max_seen_key unchanged.
|
||||
state[unit_key] = {
|
||||
"downloaded_keys": sorted(seen_keys | set(device_keys)),
|
||||
"max_downloaded_key": max_seen_key,
|
||||
"last_seen": datetime.datetime.now().isoformat(),
|
||||
"serial": serial,
|
||||
"peer": self.peer,
|
||||
}
|
||||
_save_state(self.state_path, state)
|
||||
|
||||
# ── Erase even when no new events (if requested) ──────────
|
||||
# Blastware ACH always erases after every session — even when
|
||||
# nothing new was downloaded. Without the erase the device
|
||||
# still sees stored events in its memory and immediately
|
||||
# retries the call-home, causing the looping we observed.
|
||||
# Only erase when device actually has events stored; skip
|
||||
# the erase if device_keys is empty (nothing to erase).
|
||||
if self.clear_after_download and device_keys:
|
||||
log.info(
|
||||
" Clearing device memory (--clear-after-download, "
|
||||
"no new events but device has %d stored)...",
|
||||
len(device_keys),
|
||||
)
|
||||
try:
|
||||
client.delete_all_events()
|
||||
log.info(" [OK] Device memory cleared")
|
||||
# Reset state so the next session starts fresh.
|
||||
state[unit_key] = {
|
||||
"downloaded_keys": [],
|
||||
"max_downloaded_key": "00000000",
|
||||
"last_seen": datetime.datetime.now().isoformat(),
|
||||
"serial": serial,
|
||||
"peer": self.peer,
|
||||
}
|
||||
_save_state(self.state_path, state)
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
" [WARN] Event deletion failed: %s -- events NOT cleared",
|
||||
exc,
|
||||
)
|
||||
|
||||
log.info("Session complete (no new events) -> %s", session_dir)
|
||||
return
|
||||
else:
|
||||
new_key_set = None # unknown; proceed with full download
|
||||
|
||||
# Apply max_events cap
|
||||
# stop_idx: when we know the count from list_event_keys, use it as
|
||||
# an upper bound. When list_event_keys failed (device_keys is None),
|
||||
# pass None — get_events will run until the null sentinel naturally.
|
||||
stop_idx: Optional[int] = (current_count - 1) if device_keys is not None else None
|
||||
if self.max_events is not None:
|
||||
cap = self.max_events - 1
|
||||
stop_idx = cap if stop_idx is None else min(stop_idx, cap)
|
||||
if device_keys is not None and self.max_events < current_count:
|
||||
log.warning(
|
||||
" max_events=%d cap: will download events 0-%d only "
|
||||
"(unit has %d total)",
|
||||
self.max_events, stop_idx, current_count,
|
||||
)
|
||||
|
||||
try:
|
||||
all_events = client.get_events(
|
||||
full_waveform=True,
|
||||
stop_after_index=stop_idx,
|
||||
skip_waveform_for_keys=seen_keys if seen_keys else None,
|
||||
)
|
||||
|
||||
# Filter to events whose keys we haven't saved before.
|
||||
new_events = [
|
||||
e for e in all_events
|
||||
if e._waveform_key is None
|
||||
or e._waveform_key.hex() not in seen_keys
|
||||
]
|
||||
skipped = len(all_events) - len(new_events)
|
||||
|
||||
log.info(" [OK] Downloaded %d event(s): %d new, %d skipped (already seen)",
|
||||
len(all_events), len(new_events), skipped)
|
||||
if skipped:
|
||||
log.info(" (skipped %d already-downloaded event(s))", skipped)
|
||||
|
||||
if new_events:
|
||||
_save_json(session_dir / "events.json", [_event_to_dict(e) for e in new_events])
|
||||
|
||||
for ev in new_events:
|
||||
pv = ev.peak_values
|
||||
pi = ev.project_info
|
||||
key_hex = ev._waveform_key.hex() if ev._waveform_key else "????????"
|
||||
log.info(
|
||||
" NEW [%s] %s Tran=%.4f Vert=%.4f Long=%.4f VS=%.4f project=%r",
|
||||
key_hex,
|
||||
str(ev.timestamp) if ev.timestamp else "?",
|
||||
pv.tran if pv else 0,
|
||||
pv.vert if pv else 0,
|
||||
pv.long if pv else 0,
|
||||
pv.peak_vector_sum if pv else 0,
|
||||
pi.project if pi else "",
|
||||
)
|
||||
else:
|
||||
log.info(" [OK] No new events since last call-home -- nothing to save")
|
||||
|
||||
# ── Monitor log entries (partial records / continuous monitoring) ──
|
||||
# Browse walk (0A + 1F only) to collect monitor log entries for
|
||||
# recording intervals where no threshold was crossed. This is a
|
||||
# second 1E-based pass over the device's record list, separate from
|
||||
# the get_events() download loop above.
|
||||
log.info(" Collecting monitor log entries (browse walk)...")
|
||||
new_monitor_entries: list[MonitorLogEntry] = []
|
||||
try:
|
||||
new_monitor_entries = client.get_monitor_log_entries(
|
||||
skip_keys=seen_keys if seen_keys else None,
|
||||
)
|
||||
if new_monitor_entries:
|
||||
_save_json(
|
||||
session_dir / "monitor_log.json",
|
||||
[_monitor_log_entry_to_dict(e) for e in new_monitor_entries],
|
||||
)
|
||||
log.info(
|
||||
" [OK] %d new monitor log entry(s) saved",
|
||||
len(new_monitor_entries),
|
||||
)
|
||||
for ml in new_monitor_entries:
|
||||
log.info(
|
||||
" MONLOG [%s] %s → %s (%s)",
|
||||
ml.key,
|
||||
ml.start_time.isoformat() if ml.start_time else "?",
|
||||
ml.stop_time.isoformat() if ml.stop_time else "?",
|
||||
f"{ml.duration_seconds:.0f}s" if ml.duration_seconds is not None else "?s",
|
||||
)
|
||||
else:
|
||||
log.info(" [OK] No new monitor log entries")
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
" [WARN] Monitor log collection failed: %s -- continuing",
|
||||
exc,
|
||||
)
|
||||
|
||||
# ── Persist to SQLite DB ─────────────────────────────────────
|
||||
_session_start = datetime.datetime.now()
|
||||
try:
|
||||
_ev_ins, _ev_skip = self.db.insert_events(
|
||||
new_events, serial=serial or self.peer, session_id=None
|
||||
)
|
||||
_ml_ins, _ml_skip = self.db.insert_monitor_log(
|
||||
new_monitor_entries, session_id=None
|
||||
)
|
||||
_session_id = self.db.insert_ach_session(
|
||||
serial=serial or self.peer,
|
||||
peer=self.peer,
|
||||
events_downloaded=_ev_ins,
|
||||
monitor_entries=_ml_ins,
|
||||
duration_seconds=(datetime.datetime.now() - _session_start).total_seconds(),
|
||||
session_time=_session_start,
|
||||
)
|
||||
log.info(
|
||||
" [DB] session=%s events +%d (skip %d) monitor +%d (skip %d)",
|
||||
_session_id[:8], _ev_ins, _ev_skip, _ml_ins, _ml_skip,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning(" [WARN] DB write failed: %s -- continuing", exc)
|
||||
|
||||
# ── Optional: erase device memory after successful download ────
|
||||
erased_successfully = False
|
||||
if self.clear_after_download and new_events:
|
||||
log.info(" Clearing device memory (--clear-after-download)...")
|
||||
try:
|
||||
client.delete_all_events()
|
||||
log.info(" [OK] Device memory cleared")
|
||||
erased_successfully = True
|
||||
except Exception as exc:
|
||||
log.error(
|
||||
" [WARN] Event deletion failed: %s -- events NOT cleared",
|
||||
exc,
|
||||
)
|
||||
|
||||
# ── Update persistent state ───────────────────────────────────
|
||||
# Include both triggered-event keys and monitor-log keys in the
|
||||
# downloaded set so they are not re-processed on the next call-home.
|
||||
current_event_keys = [
|
||||
e._waveform_key.hex()
|
||||
for e in all_events
|
||||
if e._waveform_key is not None
|
||||
]
|
||||
current_monitor_keys = [e.key for e in new_monitor_entries]
|
||||
current_keys = current_event_keys + current_monitor_keys
|
||||
|
||||
if erased_successfully:
|
||||
# Device memory is clear. Reset downloaded_keys and the
|
||||
# high-water mark so the next call-home starts fresh and
|
||||
# doesn't mis-identify the recycled key 01110000 as "seen".
|
||||
updated_keys = []
|
||||
new_max_key = "00000000"
|
||||
log.info(
|
||||
" State reset after erase -- next session will download "
|
||||
"from key 0 (device counter resets after erase)"
|
||||
)
|
||||
else:
|
||||
# Normal (no erase): union of previously-seen + all keys on
|
||||
# device now. Includes already-seen survivors so we never
|
||||
# re-download them if the device somehow keeps old records.
|
||||
updated_keys = sorted(set(seen_keys) | set(current_keys))
|
||||
new_max_key = updated_keys[-1] if updated_keys else max_seen_key
|
||||
|
||||
state[unit_key] = {
|
||||
"downloaded_keys": updated_keys,
|
||||
"max_downloaded_key": new_max_key,
|
||||
"last_seen": datetime.datetime.now().isoformat(),
|
||||
"serial": serial,
|
||||
"peer": self.peer,
|
||||
}
|
||||
_save_state(self.state_path, state)
|
||||
|
||||
except Exception as exc:
|
||||
log.error(" [FAIL] Event download failed: %s", exc, exc_info=True)
|
||||
|
||||
# ── Optional: restart monitoring after successful download ─────────
|
||||
if self.restart_monitoring:
|
||||
log.info(" Restarting monitoring on device (--restart-monitoring)...")
|
||||
try:
|
||||
client.start_monitoring()
|
||||
log.info(" [OK] Monitoring restarted")
|
||||
except Exception as exc:
|
||||
log.warning(" [WARN] Failed to restart monitoring: %s", exc)
|
||||
|
||||
finally:
|
||||
raw_rx_fh.close()
|
||||
raw_tx_fh.close()
|
||||
client.close() # closes transport / socket cleanly
|
||||
root_logger.removeHandler(fh)
|
||||
fh.close()
|
||||
|
||||
log.info("Session complete -> %s", session_dir)
|
||||
log.info("="*60)
|
||||
|
||||
|
||||
# ── JSON helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _save_json(path: Path, obj: object) -> None:
|
||||
with open(path, "w") as f:
|
||||
json.dump(obj, f, indent=2, default=str)
|
||||
log.debug("Saved %s", path)
|
||||
|
||||
|
||||
def _device_info_to_dict(d: DeviceInfo) -> dict:
|
||||
cc = d.compliance_config
|
||||
return {
|
||||
"serial": d.serial,
|
||||
"firmware_version": d.firmware_version,
|
||||
"dsp_version": d.dsp_version,
|
||||
"model": d.model,
|
||||
"event_count": d.event_count,
|
||||
# compliance config fields (None if 1A read failed)
|
||||
"setup_name": cc.setup_name if cc else None,
|
||||
"sample_rate": cc.sample_rate if cc else None,
|
||||
"record_time": cc.record_time if cc else None,
|
||||
"trigger_level_geo": cc.trigger_level_geo if cc else None,
|
||||
"alarm_level_geo": cc.alarm_level_geo if cc else None,
|
||||
"geo_adc_scale": cc.geo_adc_scale if cc else None, # hw scale factor (in/s)/V
|
||||
"geo_range": cc.geo_range if cc else None, # 0x01=Normal 10in/s, 0x00=Sensitive 1.25in/s (unconfirmed)
|
||||
"project": cc.project if cc else None,
|
||||
"client": cc.client if cc else None,
|
||||
"operator": cc.operator if cc else None,
|
||||
"sensor_location": cc.sensor_location if cc else None,
|
||||
}
|
||||
|
||||
|
||||
def _event_to_dict(e: Event) -> dict:
|
||||
pv = e.peak_values
|
||||
pi = e.project_info
|
||||
peaks = {}
|
||||
if pv:
|
||||
peaks = {
|
||||
"transverse": pv.tran,
|
||||
"vertical": pv.vert,
|
||||
"longitudinal": pv.long,
|
||||
"vector_sum": pv.peak_vector_sum,
|
||||
"mic": pv.micl,
|
||||
}
|
||||
samples = {}
|
||||
if e.raw_samples:
|
||||
samples = {
|
||||
ch: vals[:20] # first 20 sample-sets to keep the file sane
|
||||
for ch, vals in e.raw_samples.items()
|
||||
}
|
||||
samples["__note__"] = "first 20 sample-sets only; see raw_rx.bin for full waveform"
|
||||
return {
|
||||
"timestamp": str(e.timestamp) if e.timestamp else None,
|
||||
"project": pi.project if pi else None,
|
||||
"client": pi.client if pi else None,
|
||||
"operator": pi.operator if pi else None,
|
||||
"sensor_location": pi.sensor_location if pi else None,
|
||||
"peaks": peaks,
|
||||
"raw_samples_preview": samples,
|
||||
}
|
||||
|
||||
|
||||
def _monitor_log_entry_to_dict(e: MonitorLogEntry) -> dict:
|
||||
return {
|
||||
"key": e.key,
|
||||
"start_time": e.start_time.isoformat() if e.start_time else None,
|
||||
"stop_time": e.stop_time.isoformat() if e.stop_time else None,
|
||||
"duration_seconds": e.duration_seconds,
|
||||
"serial": e.serial,
|
||||
"geo_threshold_ips": e.geo_threshold_ips,
|
||||
}
|
||||
|
||||
|
||||
# ── Main server loop ───────────────────────────────────────────────────────────
|
||||
|
||||
def serve(args: argparse.Namespace) -> None:
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
state_path = output_dir / "ach_state.json"
|
||||
db = SeismoDb(output_dir / "seismo_relay.db")
|
||||
|
||||
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
server_sock.bind(("0.0.0.0", args.port))
|
||||
server_sock.listen(5)
|
||||
# Wake up every second so Ctrl-C is handled promptly on Windows.
|
||||
# Without this, accept() blocks indefinitely and ignores KeyboardInterrupt.
|
||||
server_sock.settimeout(1.0)
|
||||
|
||||
max_ev = args.max_events
|
||||
print(f"\n{'='*60}")
|
||||
print(f" ACH inbound server listening on 0.0.0.0:{args.port}")
|
||||
print(f" Output: {output_dir.resolve()}/ach_inbound_<timestamp>/")
|
||||
print(f" State file: {state_path}")
|
||||
print(f" Max events per session: {max_ev if max_ev else 'unlimited'}")
|
||||
print(f" Clear device after download: {'YES' if args.clear_after_download else 'no'}")
|
||||
print(f" Restart monitoring after download: {'YES' if args.restart_monitoring else 'no'}")
|
||||
print(f"{'='*60}")
|
||||
print(f"\n Point your test unit's ACEmanager call-home settings to:")
|
||||
print(f" Remote Host: <this machine's LAN IP>")
|
||||
print(f" Remote Port: {args.port}")
|
||||
print(f"\n Waiting for inbound connections... (Ctrl-C to stop)\n")
|
||||
|
||||
allow_ips = set(args.allow_ips)
|
||||
if allow_ips:
|
||||
print(f" Allowlist: {', '.join(sorted(allow_ips))}")
|
||||
else:
|
||||
print(" Allowlist: NONE -- accepting all IPs (add --allow-ip to restrict)")
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
client_sock, addr = server_sock.accept()
|
||||
except socket.timeout:
|
||||
continue # no connection this second; loop back and check for Ctrl-C
|
||||
try:
|
||||
peer_ip = addr[0]
|
||||
peer = f"{addr[0]}:{addr[1]}"
|
||||
|
||||
if allow_ips and peer_ip not in allow_ips:
|
||||
log.info("Rejected connection from %s (not in allowlist)", peer)
|
||||
client_sock.close()
|
||||
continue
|
||||
|
||||
log.info("Accepted connection from %s", peer)
|
||||
session = AchSession(
|
||||
sock=client_sock,
|
||||
peer=peer,
|
||||
output_dir=output_dir,
|
||||
timeout=args.timeout,
|
||||
events_only=args.events_only,
|
||||
max_events=max_ev,
|
||||
state_path=state_path,
|
||||
db=db,
|
||||
clear_after_download=args.clear_after_download,
|
||||
restart_monitoring=args.restart_monitoring,
|
||||
)
|
||||
t = threading.Thread(target=session.run, daemon=True, name=f"ach-{peer}")
|
||||
t.start()
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as exc:
|
||||
log.error("Accept error: %s", exc)
|
||||
finally:
|
||||
server_sock.close()
|
||||
print("\nServer stopped.")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Minimal inbound ACH server — speak BW protocol to calling MiniMate Plus units.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
p.add_argument(
|
||||
"--port", "-p",
|
||||
type=int,
|
||||
default=12345,
|
||||
help="Port to listen on (default: 12345).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--output", "-o",
|
||||
default=str(Path(__file__).parent / "captures"),
|
||||
metavar="DIR",
|
||||
help="Directory to write session captures (default: bridges/captures/).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--timeout", "-t",
|
||||
type=float,
|
||||
default=30.0,
|
||||
help="Protocol receive timeout in seconds (default: 30.0).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--events-only",
|
||||
action="store_true",
|
||||
help="Skip the device-info step and go straight to event download.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--max-events",
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help=(
|
||||
"Safety cap: download at most N events per session (default: unlimited). "
|
||||
"Useful if a unit has many old events stored — prevents a very long first run."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--allow-ip",
|
||||
metavar="IP",
|
||||
action="append",
|
||||
dest="allow_ips",
|
||||
default=[],
|
||||
help=(
|
||||
"Only accept connections from this IP address (repeat for multiple). "
|
||||
"Example: --allow-ip 63.43.212.232 "
|
||||
"If not specified, all IPs are accepted (not recommended for public servers)."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--restart-monitoring",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"After downloading events, send SUB 0x96 (start monitoring) before "
|
||||
"disconnecting. Required for RV55 units whose firmware does not assert "
|
||||
"DCD on disconnect — without this the unit stays idle after a call-home."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--clear-after-download",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help=(
|
||||
"After successfully downloading new events, erase all events from the "
|
||||
"device memory (SUB 0xA3 → 0x1C → 0x06 → 0xA2 sequence, confirmed from "
|
||||
"4-11-26 MITM capture). Only fires when at least one new event was saved. "
|
||||
"This mirrors the standard Blastware ACH workflow."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Enable debug logging.",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
args = parse_args()
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
|
||||
)
|
||||
try:
|
||||
serve(args)
|
||||
except KeyboardInterrupt:
|
||||
print("\nStopped.")
|
||||
+34
-29
@@ -58,16 +58,24 @@ 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
|
||||
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)
|
||||
# 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="")
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
# Row 4: Status + buttons
|
||||
self.status_var = tk.StringVar(value="Idle")
|
||||
@@ -102,13 +110,11 @@ class BridgeGUI(tk.Tk):
|
||||
var.set(filename)
|
||||
|
||||
def _toggle_raw_bw(self) -> None:
|
||||
if not self.raw_bw_var.get():
|
||||
# default name
|
||||
self.raw_bw_var.set(os.path.join(self.logdir_var.get(), "raw_bw.bin"))
|
||||
# Checkbox toggled — no path action needed; enabled state drives the flag.
|
||||
pass
|
||||
|
||||
def _toggle_raw_s3(self) -> None:
|
||||
if not self.raw_s3_var.get():
|
||||
self.raw_s3_var.set(os.path.join(self.logdir_var.get(), "raw_s3.bin"))
|
||||
pass
|
||||
|
||||
def start_bridge(self) -> None:
|
||||
if self.process and self.process.poll() is None:
|
||||
@@ -126,23 +132,22 @@ class BridgeGUI(tk.Tk):
|
||||
|
||||
args = [sys.executable, BRIDGE_PATH, "--bw", bw, "--s3", s3, "--baud", baud, "--logdir", logdir]
|
||||
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
# 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()
|
||||
|
||||
raw_bw = self.raw_bw_var.get().strip()
|
||||
raw_s3 = self.raw_s3_var.get().strip()
|
||||
if self.raw_bw_enabled.get():
|
||||
args += ["--raw-bw", raw_bw_explicit if raw_bw_explicit else "auto"]
|
||||
else:
|
||||
args += ["--raw-bw", ""] # 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]
|
||||
if self.raw_s3_enabled.get():
|
||||
args += ["--raw-s3", raw_s3_explicit if raw_s3_explicit else "auto"]
|
||||
else:
|
||||
args += ["--raw-s3", ""] # explicit disable
|
||||
|
||||
try:
|
||||
self.process = subprocess.Popen(
|
||||
|
||||
@@ -93,8 +93,11 @@ 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:
|
||||
@@ -124,6 +127,43 @@ 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:
|
||||
@@ -291,8 +331,18 @@ def forward_loop(
|
||||
time.sleep(0.002)
|
||||
|
||||
|
||||
def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None:
|
||||
print("[MARK] Type 'm' + Enter to annotate the capture. Ctrl+C to stop.")
|
||||
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>
|
||||
"""
|
||||
while not stop.is_set():
|
||||
try:
|
||||
line = input()
|
||||
@@ -303,7 +353,21 @@ def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None:
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if line.lower() == "m":
|
||||
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":
|
||||
try:
|
||||
sys.stdout.write(" Label: ")
|
||||
sys.stdout.flush()
|
||||
@@ -315,8 +379,9 @@ def annotation_loop(logger: SessionLogger, stop: threading.Event) -> None:
|
||||
print(f" [MARK written] {label}")
|
||||
else:
|
||||
print(" (empty label — mark cancelled)")
|
||||
|
||||
else:
|
||||
print(" (type 'm' + Enter to annotate)")
|
||||
print(f" (unknown command: {line!r})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -325,8 +390,14 @@ 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=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("--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("--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()
|
||||
@@ -349,12 +420,16 @@ 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).
|
||||
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
|
||||
# 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")
|
||||
|
||||
logger = SessionLogger(log_path, bin_path, raw_bw_path=raw_bw_path, raw_s3_path=raw_s3_path)
|
||||
|
||||
@@ -391,7 +466,7 @@ def main() -> int:
|
||||
t_ann = threading.Thread(
|
||||
target=annotation_loop,
|
||||
name="Annotator",
|
||||
args=(logger, stop),
|
||||
args=(logger, args.logdir, stop),
|
||||
daemon=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
serial_watch.py — Instantel Series-3 serial monitor with S3 frame parsing.
|
||||
|
||||
Taps the RS-232 line between the MiniMate Plus and its modem (RV50/RV55).
|
||||
Saves raw binary captures compatible with the rest of the analysis toolchain,
|
||||
plus a human-readable frame log.
|
||||
|
||||
Usage
|
||||
-----
|
||||
python bridges/serial_watch.py # interactive COM picker
|
||||
python bridges/serial_watch.py --port COM3 # specify port
|
||||
python bridges/serial_watch.py --port COM3 --ack-ok # reply OK to AT commands
|
||||
# (useful if modem is absent
|
||||
# and you want the device to
|
||||
# proceed past AT negotiation)
|
||||
python bridges/serial_watch.py --list # list available ports
|
||||
|
||||
Output
|
||||
------
|
||||
bridges/captures/serial_<ISO-timestamp>/
|
||||
raw_s3_<ts>.bin — raw bytes from device (feeds directly into S3FrameParser)
|
||||
session_<ts>.log — human-readable frame + control-line log
|
||||
session_<ts>.jsonl — JSON-lines frame log
|
||||
|
||||
The raw_s3_*.bin file is byte-for-byte compatible with the existing capture
|
||||
format used by bridges/parse_capture.py and all analysis scripts.
|
||||
|
||||
What to look for in a call-home capture
|
||||
----------------------------------------
|
||||
1. Does the device talk first after CONNECT, or does it wait?
|
||||
- If raw_s3_*.bin has bytes before any AT/POLL exchange → PUSH protocol
|
||||
- If it stays silent → PULL protocol (same as Blastware manual download)
|
||||
|
||||
2. Look for "Operating System" ASCII at the start — the device sends this 16-byte
|
||||
boot string on cold start before entering DLE-framed mode.
|
||||
|
||||
3. RING/CONNECT from the modem appear as ASCII before the DLE frames — the parser
|
||||
handles these automatically (scans forward to DLE+STX).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import serial
|
||||
from serial.tools import list_ports
|
||||
except ModuleNotFoundError:
|
||||
print(
|
||||
"pyserial not found. Install with:\n python -m pip install pyserial",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Add project root so we can import the frame parser
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from minimateplus.framing import S3FrameParser, S3Frame
|
||||
|
||||
import json
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _ts() -> str:
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
||||
|
||||
|
||||
def _hexdump(b: bytes) -> str:
|
||||
return " ".join(f"{x:02X}" for x in b)
|
||||
|
||||
|
||||
def _printable(b: bytes) -> str:
|
||||
return b.decode("latin1", errors="replace")
|
||||
|
||||
|
||||
_KNOWN_SUBS = {
|
||||
0xA4: "POLL_RSP", 0xA5: "BULK_WAVEFORM_RSP", 0xE0: "ADVANCE_EVENT_RSP",
|
||||
0xE1: "EVENT_IDX_FIRST_RSP", 0xE3: "MONITOR_STATUS_RSP", 0xEA: "SERIAL_NUM_RSP",
|
||||
0xF3: "WAVEFORM_RECORD_RSP", 0xF5: "WAVEFORM_HEADER_RSP", 0xF7: "EVENT_INDEX_RSP",
|
||||
0xF9: "UNK_06_RSP", 0xFE: "DEVICE_INFO_RSP",
|
||||
0x69: "START_MONITOR_ACK", 0x68: "STOP_MONITOR_ACK",
|
||||
0x97: "EVT_IDX_WRITE_ACK", 0x8C: "CONFIRM_B_ACK", 0x8E: "COMPLIANCE_WRITE_ACK",
|
||||
0x8D: "CONFIRM_A_ACK", 0x7D: "TRIGGER_WRITE_ACK", 0x7C: "TRIGGER_CONFIRM_ACK",
|
||||
0x96: "WAVEFORM_WRITE_ACK", 0x8B: "CONFIRM_C_ACK",
|
||||
}
|
||||
|
||||
|
||||
def _label_frame(frame: S3Frame) -> str:
|
||||
name = _KNOWN_SUBS.get(frame.sub, f"UNK_0x{frame.sub:02X}")
|
||||
chk = "✓" if frame.checksum_valid else "✗ BAD_CHK"
|
||||
peek = frame.data[:24].hex() + ("…" if len(frame.data) > 24 else "")
|
||||
return (
|
||||
f"S3 SUB=0x{frame.sub:02X} ({name:<22}) "
|
||||
f"page=0x{frame.page_key:04X} data={len(frame.data):4d}B {chk} {peek}"
|
||||
)
|
||||
|
||||
|
||||
# ── Logger ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class Logger:
|
||||
def __init__(self, log_path: Path, jsonl_path: Path, raw_path: Path) -> None:
|
||||
self._log = log_path.open("a", encoding="utf-8", newline="")
|
||||
self._jl = jsonl_path.open("a", encoding="utf-8", newline="")
|
||||
self._raw = raw_path.open("ab")
|
||||
self._lock = threading.Lock()
|
||||
self._frame_count = 0
|
||||
|
||||
def info(self, msg: str) -> None:
|
||||
line = f"[{_ts()}] INFO | {msg}"
|
||||
with self._lock:
|
||||
print(line)
|
||||
print(line, file=self._log, flush=True)
|
||||
|
||||
def ctrl(self, msg: str) -> None:
|
||||
line = f"[{_ts()}] CTRL | {msg}"
|
||||
with self._lock:
|
||||
print(line)
|
||||
print(line, file=self._log, flush=True)
|
||||
|
||||
def data_hex(self, msg: str) -> None:
|
||||
line = f"[{_ts()}] HEX | {msg}"
|
||||
with self._lock:
|
||||
print(line)
|
||||
print(line, file=self._log, flush=True)
|
||||
|
||||
def data_ascii(self, msg: str) -> None:
|
||||
line = f"[{_ts()}] DATA | {msg}"
|
||||
with self._lock:
|
||||
print(line)
|
||||
print(line, file=self._log, flush=True)
|
||||
|
||||
def frame(self, f: S3Frame) -> None:
|
||||
with self._lock:
|
||||
self._frame_count += 1
|
||||
label = f"[{_ts()}] FRAME | #{self._frame_count:04d} {_label_frame(f)}"
|
||||
print(label)
|
||||
print(label, file=self._log, flush=True)
|
||||
record = {
|
||||
"frame": self._frame_count,
|
||||
"sub": f.sub,
|
||||
"page_key": f.page_key,
|
||||
"data_len": len(f.data),
|
||||
"data_hex": f.data.hex(),
|
||||
"checksum_valid": f.checksum_valid,
|
||||
}
|
||||
print(json.dumps(record), file=self._jl, flush=True)
|
||||
|
||||
def write_raw(self, data: bytes) -> None:
|
||||
with self._lock:
|
||||
self._raw.write(data)
|
||||
self._raw.flush()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
for fh in (self._log, self._jl, self._raw):
|
||||
try:
|
||||
fh.flush()
|
||||
fh.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Control-line monitor thread ───────────────────────────────────────────────
|
||||
|
||||
def _monitor_control_lines(
|
||||
ser: serial.Serial,
|
||||
logger: Logger,
|
||||
stop: threading.Event,
|
||||
interval: float,
|
||||
) -> None:
|
||||
prev = dict(CTS=None, DSR=None, DCD=None, RI=None)
|
||||
try:
|
||||
prev.update(CTS=ser.cts, DSR=ser.dsr, DCD=ser.cd)
|
||||
try:
|
||||
prev["RI"] = ser.ri
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.ctrl(f"Init error: {exc}")
|
||||
return
|
||||
|
||||
logger.ctrl(
|
||||
f"Initial: CTS={prev['CTS']} DSR={prev['DSR']} DCD={prev['DCD']} RI={prev['RI']}"
|
||||
)
|
||||
while not stop.is_set():
|
||||
try:
|
||||
cur = dict(CTS=ser.cts, DSR=ser.dsr, DCD=ser.cd, RI=None)
|
||||
try:
|
||||
cur["RI"] = ser.ri
|
||||
except Exception:
|
||||
pass
|
||||
for name, val in cur.items():
|
||||
if val != prev[name]:
|
||||
logger.ctrl(f"{name} → {val}")
|
||||
prev[name] = val
|
||||
except serial.SerialException as exc:
|
||||
logger.ctrl(f"Poll error: {exc}")
|
||||
break
|
||||
stop.wait(interval)
|
||||
|
||||
|
||||
# ── Serial open ───────────────────────────────────────────────────────────────
|
||||
|
||||
_PARITY = {
|
||||
"N": serial.PARITY_NONE, "E": serial.PARITY_EVEN, "O": serial.PARITY_ODD,
|
||||
"M": serial.PARITY_MARK, "S": serial.PARITY_SPACE,
|
||||
}
|
||||
_STOPBITS = {
|
||||
1: serial.STOPBITS_ONE, 1.5: serial.STOPBITS_ONE_POINT_FIVE, 2: serial.STOPBITS_TWO,
|
||||
}
|
||||
|
||||
|
||||
def _open_serial(args: argparse.Namespace, logger: Logger) -> serial.Serial | None:
|
||||
for attempt in range(1, args.open_retries + 2):
|
||||
logger.info(
|
||||
f"Opening {args.port} @ {args.baud},{args.bytesize}{args.parity}{args.stopbits} "
|
||||
f"rtscts={args.rtscts} xonxoff={args.xonxoff} dsrdtr={args.dsrdtr} "
|
||||
f"(attempt {attempt})"
|
||||
)
|
||||
try:
|
||||
ser = serial.Serial(
|
||||
port=args.port,
|
||||
baudrate=args.baud,
|
||||
bytesize=args.bytesize,
|
||||
parity=_PARITY[args.parity],
|
||||
stopbits=_STOPBITS[args.stopbits],
|
||||
timeout=args.timeout,
|
||||
xonxoff=args.xonxoff,
|
||||
rtscts=args.rtscts,
|
||||
dsrdtr=args.dsrdtr,
|
||||
write_timeout=0,
|
||||
)
|
||||
try:
|
||||
ser.setDTR(args.dtr == "on")
|
||||
ser.setRTS(args.rts == "on")
|
||||
logger.ctrl(f"Set DTR={args.dtr} RTS={args.rts}")
|
||||
except Exception as exc:
|
||||
logger.ctrl(f"DTR/RTS set failed: {exc}")
|
||||
|
||||
if args.send_break > 0:
|
||||
try:
|
||||
ser.break_condition = True
|
||||
time.sleep(args.send_break / 1000.0)
|
||||
ser.break_condition = False
|
||||
logger.ctrl(f"BREAK held {args.send_break} ms")
|
||||
except Exception as exc:
|
||||
logger.ctrl(f"BREAK failed: {exc}")
|
||||
|
||||
return ser
|
||||
|
||||
except serial.SerialException as exc:
|
||||
logger.info(f"Open failed: {exc}")
|
||||
if attempt <= args.open_retries:
|
||||
time.sleep(args.open_retry_delay)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ── Port picker ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _list_ports() -> list:
|
||||
ports = list(list_ports.comports())
|
||||
if not ports:
|
||||
print("No serial ports found.")
|
||||
return []
|
||||
print("Available serial ports:")
|
||||
for i, p in enumerate(ports, 1):
|
||||
print(f" {i:2d}) {p.device:<12} {p.description or ''}")
|
||||
return ports
|
||||
|
||||
|
||||
def _pick_port() -> str:
|
||||
ports = _list_ports()
|
||||
if not ports:
|
||||
sys.exit(1)
|
||||
if len(ports) == 1:
|
||||
print(f"Auto-selecting: {ports[0].device}")
|
||||
return ports[0].device
|
||||
while True:
|
||||
sel = input("Select port (number or name, e.g. COM3): ").strip()
|
||||
if sel.isdigit() and 1 <= int(sel) <= len(ports):
|
||||
return ports[int(sel) - 1].device
|
||||
for p in ports:
|
||||
if p.device.upper() == sel.upper():
|
||||
return p.device
|
||||
print("Not recognised. Enter list number or exact port name.")
|
||||
|
||||
|
||||
# ── Main loop ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Monitor Instantel Series-3 serial traffic with S3 frame parsing."
|
||||
)
|
||||
ap.add_argument("--port", "-p",
|
||||
help="COM port (e.g. COM3). Omit to be prompted.")
|
||||
ap.add_argument("--baud", "-b", type=int, default=38400)
|
||||
ap.add_argument("--bytesize", type=int, choices=[5, 6, 7, 8], default=8)
|
||||
ap.add_argument("--parity", choices=["N", "E", "O", "M", "S"], default="N")
|
||||
ap.add_argument("--stopbits", type=float, choices=[1, 1.5, 2], default=1)
|
||||
ap.add_argument("--rtscts", action="store_true")
|
||||
ap.add_argument("--xonxoff", action="store_true")
|
||||
ap.add_argument("--dsrdtr", action="store_true")
|
||||
ap.add_argument("--dtr", choices=["on", "off"], default="on")
|
||||
ap.add_argument("--rts", choices=["on", "off"], default="on")
|
||||
ap.add_argument("--send-break", type=int, default=0,
|
||||
help="Hold BREAK for N ms after open.")
|
||||
ap.add_argument("--show", choices=["ascii", "hex", "both", "frames"],
|
||||
default="frames",
|
||||
help="'frames' (default) shows only parsed S3 frames. "
|
||||
"'ascii'/'hex'/'both' also show raw bytes.")
|
||||
ap.add_argument("--encoding", default="latin1")
|
||||
ap.add_argument("--read-chunk", type=int, default=4096)
|
||||
ap.add_argument("--timeout", type=float, default=0.05)
|
||||
ap.add_argument("--poll-lines-interval", type=float, default=0.2)
|
||||
ap.add_argument("--open-retries", type=int, default=0)
|
||||
ap.add_argument("--open-retry-delay", type=float, default=0.8)
|
||||
ap.add_argument("--ack-ok", action="store_true",
|
||||
help="Auto-reply OK to AT* commands (except ATDT). "
|
||||
"Useful for testing without a real modem.")
|
||||
ap.add_argument("--list", action="store_true",
|
||||
help="List available serial ports and exit.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.list:
|
||||
_list_ports()
|
||||
return
|
||||
|
||||
args.port = args.port or _pick_port()
|
||||
|
||||
# Build output paths
|
||||
ts_str = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = Path(__file__).parent / "captures" / f"serial_{ts_str}"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
log_path = out_dir / f"session_{ts_str}.log"
|
||||
jsonl_path = out_dir / f"session_{ts_str}.jsonl"
|
||||
raw_path = out_dir / f"raw_s3_{ts_str}.bin"
|
||||
|
||||
logger = Logger(log_path, jsonl_path, raw_path)
|
||||
logger.info(f"Output directory: {out_dir}")
|
||||
logger.info(f"raw_s3 → {raw_path.name} (compatible with parse_capture.py)")
|
||||
|
||||
ser = _open_serial(args, logger)
|
||||
if ser is None:
|
||||
logger.info("Could not open serial port. Exiting.")
|
||||
logger.close()
|
||||
sys.exit(1)
|
||||
|
||||
s3_parser = S3FrameParser()
|
||||
rx_buf = bytearray()
|
||||
stop_evt = threading.Event()
|
||||
|
||||
ctrl_thread = threading.Thread(
|
||||
target=_monitor_control_lines,
|
||||
args=(ser, logger, stop_evt, args.poll_lines_interval),
|
||||
daemon=True,
|
||||
)
|
||||
ctrl_thread.start()
|
||||
logger.info("Monitoring started. Waiting for call-home. Press Ctrl+C to stop.")
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
data = ser.read(args.read_chunk)
|
||||
except serial.SerialException as exc:
|
||||
logger.info(f"Read error: {exc}")
|
||||
break
|
||||
|
||||
if not data:
|
||||
continue
|
||||
|
||||
# 1. Save raw bytes
|
||||
logger.write_raw(data)
|
||||
|
||||
# 2. Optional raw display
|
||||
if args.show in ("ascii", "both"):
|
||||
txt = _printable(data)
|
||||
for line in txt.splitlines():
|
||||
logger.data_ascii(line)
|
||||
if args.show in ("hex", "both"):
|
||||
logger.data_hex(_hexdump(data))
|
||||
|
||||
# 3. Parse S3 frames
|
||||
for byte in data:
|
||||
result = s3_parser.feed(bytes([byte]))
|
||||
if result:
|
||||
frames = result if isinstance(result, list) else [result]
|
||||
for f in frames:
|
||||
logger.frame(f)
|
||||
|
||||
# 4. AT command handling for --ack-ok
|
||||
if args.ack_ok:
|
||||
rx_buf.extend(data)
|
||||
while b"\r" in rx_buf or b"\n" in rx_buf:
|
||||
for sep in (b"\r", b"\n"):
|
||||
idx = rx_buf.find(sep)
|
||||
if idx != -1:
|
||||
line_bytes = bytes(rx_buf[:idx])
|
||||
del rx_buf[:idx + 1]
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
line_str = line_bytes.decode("latin1", errors="ignore").strip().upper()
|
||||
if line_str.startswith("AT") and not line_str.startswith("ATDT"):
|
||||
try:
|
||||
ser.write(b"\r\nOK\r\n")
|
||||
ser.flush()
|
||||
logger.info(f"AT ack: {line_str!r} → OK")
|
||||
except Exception as exc:
|
||||
logger.info(f"AT ack write failed: {exc}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Ctrl+C — stopping.")
|
||||
|
||||
finally:
|
||||
stop_evt.set()
|
||||
try:
|
||||
ser.close()
|
||||
except Exception:
|
||||
pass
|
||||
ctrl_thread.join(timeout=1.0)
|
||||
logger.info(f"Capture saved to: {out_dir}")
|
||||
logger.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://terra-mechanics.com/schemas/seismo-relay/device-config/v1",
|
||||
"title": "MiniMate Plus Device Config",
|
||||
"description": "Writable configuration fields for an Instantel MiniMate Plus seismograph, as exposed by the seismo-relay SFM API (POST /device/config). All fields are optional — only supplied fields are written; all others are round-tripped from the device.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
"properties": {
|
||||
|
||||
"sample_rate": {
|
||||
"title": "Sample Rate",
|
||||
"description": "ADC sample rate in samples per second. Must be one of the three supported rates.",
|
||||
"type": "integer",
|
||||
"enum": [1024, 2048, 4096],
|
||||
"examples": [1024]
|
||||
},
|
||||
|
||||
"record_time": {
|
||||
"title": "Record Time",
|
||||
"description": "Waveform record duration in seconds. Typical values are 1.0–15.0 s. The device stores this as a 32-bit IEEE 754 float.",
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 60.0,
|
||||
"examples": [3.0]
|
||||
},
|
||||
|
||||
"trigger_level_geo": {
|
||||
"title": "Trigger Level (Geo)",
|
||||
"description": "Geophone trigger threshold in in/s. Event recording begins when any geo channel exceeds this level.",
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"examples": [0.5]
|
||||
},
|
||||
|
||||
"alarm_level_geo": {
|
||||
"title": "Alarm Level (Geo)",
|
||||
"description": "Geophone alarm threshold in in/s. An alarm is flagged when any geo channel exceeds this level.",
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"examples": [1.0]
|
||||
},
|
||||
|
||||
"max_range_geo": {
|
||||
"title": "Max Range (Geo)",
|
||||
"description": "Full-scale calibration constant for geo channels in in/s. This is a factory-calibrated value — only modify if you have a calibration certificate. Default for MiniMate Plus is approximately 6.206 in/s.",
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0,
|
||||
"examples": [6.206]
|
||||
},
|
||||
|
||||
"project": {
|
||||
"title": "Project",
|
||||
"description": "Project name or description. Stored in the compliance config block and echoed on event reports. Max 41 ASCII characters.",
|
||||
"type": "string",
|
||||
"maxLength": 41,
|
||||
"examples": ["Bridge Inspection 2026"]
|
||||
},
|
||||
|
||||
"client_name": {
|
||||
"title": "Client",
|
||||
"description": "Client or company name. Max 41 ASCII characters.",
|
||||
"type": "string",
|
||||
"maxLength": 41,
|
||||
"examples": ["City of Portland"]
|
||||
},
|
||||
|
||||
"operator": {
|
||||
"title": "Operator",
|
||||
"description": "Operator or technician name. Stored as 'User Name:' in the device. Max 41 ASCII characters.",
|
||||
"type": "string",
|
||||
"maxLength": 41,
|
||||
"examples": ["Brian Harrison"]
|
||||
},
|
||||
|
||||
"seis_loc": {
|
||||
"title": "Sensor Location",
|
||||
"description": "Sensor location description. Stored as 'Seis Loc:' in the device. Max 41 ASCII characters.",
|
||||
"type": "string",
|
||||
"maxLength": 41,
|
||||
"examples": ["South Abutment — 3 m from blast"]
|
||||
},
|
||||
|
||||
"notes": {
|
||||
"title": "Extended Notes",
|
||||
"description": "Free-form notes. Stored as 'Extended Notes' in the device. Max 41 ASCII characters.",
|
||||
"type": "string",
|
||||
"maxLength": 41,
|
||||
"examples": ["Pre-blast baseline, no charges"]
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
"examples": [
|
||||
{
|
||||
"project": "Bridge Inspection 2026",
|
||||
"client_name": "City of Portland",
|
||||
"operator": "Brian Harrison",
|
||||
"seis_loc": "South Abutment",
|
||||
"notes": "Pre-blast baseline"
|
||||
},
|
||||
{
|
||||
"sample_rate": 1024,
|
||||
"record_time": 3.0,
|
||||
"trigger_level_geo": 0.5,
|
||||
"alarm_level_geo": 1.0
|
||||
},
|
||||
{
|
||||
"sample_rate": 2048,
|
||||
"record_time": 5.0,
|
||||
"trigger_level_geo": 0.25,
|
||||
"alarm_level_geo": 0.75,
|
||||
"project": "Quarry Blast Monitoring",
|
||||
"client_name": "Acme Quarry LLC",
|
||||
"operator": "Brian Harrison",
|
||||
"seis_loc": "Nearest Structure — East Wall",
|
||||
"notes": "Production blast series B"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Parse SUB 0x1C (monitoring status) response frames.
|
||||
|
||||
SUB 0x1C returns device monitoring status with different payload sizes depending on state:
|
||||
- IDLE (not monitoring): 58 bytes with full details
|
||||
- MONITORING (actively streaming): 12 bytes condensed format
|
||||
"""
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonitoringStatus:
|
||||
"""Parsed SUB 0x1C response fields."""
|
||||
|
||||
monitor_mode: int # 0x2c = OFF, 0x00 = ON
|
||||
day: int # 1–31
|
||||
hour: int # 0–23
|
||||
month: int # 1–12
|
||||
year: int # 2000–2100
|
||||
minute: int # 0–59 (uncertain encoding)
|
||||
second: int # 0–59 (uncertain encoding)
|
||||
battery_voltage_v: float # Volts (6–8V typical)
|
||||
memory_total_kb: float # Kilobytes
|
||||
memory_free_kb: float # Kilobytes
|
||||
raw_payload: bytes
|
||||
|
||||
def __str__(self) -> str:
|
||||
mode_str = "OFF" if self.monitor_mode == 0x2c else "ON"
|
||||
date_str = f"{self.year:04d}-{self.month:02d}-{self.day:02d}"
|
||||
time_str = f"{self.hour:02d}:{self.minute:02d}:{self.second:02d}"
|
||||
return (
|
||||
f"MonitoringStatus(\n"
|
||||
f" mode={mode_str} (0x{self.monitor_mode:02x})\n"
|
||||
f" datetime={date_str} {time_str}\n"
|
||||
f" battery={self.battery_voltage_v:.2f}V\n"
|
||||
f" memory=total {self.memory_total_kb:.1f} KB, "
|
||||
f"free {self.memory_free_kb:.1f} KB\n"
|
||||
f")"
|
||||
)
|
||||
|
||||
|
||||
def parse_0x1c_response(data: bytes) -> Optional[MonitoringStatus]:
|
||||
"""
|
||||
Parse a SUB 0x1C response payload (after S3 header removed).
|
||||
|
||||
Args:
|
||||
data: Destuffed payload bytes (without the 5-byte S3 header)
|
||||
|
||||
Returns:
|
||||
MonitoringStatus object, or None if parse fails
|
||||
"""
|
||||
|
||||
if len(data) < 39:
|
||||
# Minimum size for idle response
|
||||
print(f"[!] Payload too short: {len(data)} bytes (need >=39)")
|
||||
return None
|
||||
|
||||
try:
|
||||
monitor_mode = data[0x00]
|
||||
|
||||
day = data[0x0d]
|
||||
hour = data[0x0e]
|
||||
month = data[0x0f]
|
||||
year = struct.unpack('>H', data[0x10:0x12])[0]
|
||||
minute = data[0x12]
|
||||
second = data[0x13]
|
||||
|
||||
# Battery voltage: uint16 BE, divide by 100
|
||||
# At offset [2f:31]
|
||||
voltage_raw = struct.unpack('>H', data[0x2f:0x31])[0]
|
||||
battery_voltage_v = voltage_raw / 100.0
|
||||
|
||||
# Memory total: uint32 BE, in bytes
|
||||
# At offset [31:35]
|
||||
memory_total_bytes = struct.unpack('>I', data[0x31:0x35])[0]
|
||||
memory_total_kb = memory_total_bytes / 1024.0
|
||||
|
||||
# Memory free: uint32 BE, in bytes
|
||||
# At offset [35:39]
|
||||
memory_free_bytes = struct.unpack('>I', data[0x35:0x39])[0]
|
||||
memory_free_kb = memory_free_bytes / 1024.0
|
||||
|
||||
return MonitoringStatus(
|
||||
monitor_mode=monitor_mode,
|
||||
day=day,
|
||||
hour=hour,
|
||||
month=month,
|
||||
year=year,
|
||||
minute=minute,
|
||||
second=second,
|
||||
battery_voltage_v=battery_voltage_v,
|
||||
memory_total_kb=memory_total_kb,
|
||||
memory_free_kb=memory_free_kb,
|
||||
raw_payload=data
|
||||
)
|
||||
|
||||
except (struct.error, IndexError) as e:
|
||||
print(f"[!] Parse error: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def hex_dump(data: bytes, offset: int = 0) -> str:
|
||||
"""Pretty-print hex dump of binary data."""
|
||||
lines = []
|
||||
for i in range(0, len(data), 16):
|
||||
chunk = data[i:i+16]
|
||||
hex_str = ' '.join(f'{b:02x}' for b in chunk)
|
||||
ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
|
||||
lines.append(f" {offset+i:04x}: {hex_str:<48} {ascii_str}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: parse_0x1c_response.py <hex_string_or_file>")
|
||||
print()
|
||||
print("Example (hex string):")
|
||||
print(" python3 parse_0x1c_response.py 2c00000000000000000000000008100407ea00013b2d...")
|
||||
print()
|
||||
print("Example (from capture file, idle frame):")
|
||||
print(" Idle response (58 bytes):")
|
||||
idle_hex = (
|
||||
"2c00000000000000000000000008100407ea00013b2d000000000000"
|
||||
"010107cb00060000010107cb0015000000001002a8000efff2000e9e52ef"
|
||||
)
|
||||
status = parse_0x1c_response(bytes.fromhex(idle_hex))
|
||||
print(hex_dump(bytes.fromhex(idle_hex)))
|
||||
print()
|
||||
if status:
|
||||
print(status)
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
# Parse input
|
||||
input_str = sys.argv[1]
|
||||
|
||||
try:
|
||||
payload = bytes.fromhex(input_str)
|
||||
except ValueError:
|
||||
print(f"[!] Invalid hex string: {input_str}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Parsing {len(payload)} bytes:")
|
||||
print(hex_dump(payload))
|
||||
print()
|
||||
|
||||
status = parse_0x1c_response(payload)
|
||||
if status:
|
||||
print(status)
|
||||
else:
|
||||
print("[!] Failed to parse")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,274 @@
|
||||
# SUB 0x1C — Monitoring Status Response Format
|
||||
|
||||
**Capture file:** `/sessions/intelligent-nice-wright/mnt/seismo-relay/bridges/captures/4-8-26/2ndtry/raw_s3_20260408_015927.bin`
|
||||
|
||||
**Analysis date:** 2026-04-08
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
SUB 0x1C is a monitoring status query that returns different sized responses depending on device state:
|
||||
|
||||
- **IDLE/OFF (unit not monitoring):** 58-byte response with detailed fields
|
||||
- **MONITORING/ON (unit actively monitoring):** 12-byte response with condensed format
|
||||
|
||||
The key fields CONFIRMED from wire capture analysis:
|
||||
|
||||
| Field | Offset | Format | Value (Idle) | Notes |
|
||||
|-------|--------|--------|-------------|-------|
|
||||
| **Monitor Mode** | [00] | uint8 | 0x2c (OFF) | 0x2c = Idle, 0x00 = Monitoring |
|
||||
| **Day** | [0d] | uint8 | 0x08 | 1–31 |
|
||||
| **Hour** | [0e] | uint8 | 0x10 | 0–23 (16 = 4 PM) |
|
||||
| **Month** | [0f] | uint8 | 0x04 | 1–12 (April) |
|
||||
| **Year** | [10:12] | uint16 BE | 0x07ea | 2026 |
|
||||
| **Minute** | [12] | uint8 | 0x00 | 0–59 |
|
||||
| **Second** | [13] | uint8 | 0x01 | 0–59 (but this seems off) |
|
||||
| **Battery Voltage** | [2f:31] | uint16 BE, ÷100 | 0x02a8 | 680 → 6.80V |
|
||||
| **Memory Total** | [31:35] | uint32 BE | 0x000efff2 | 983,026 bytes = 960.0 KB |
|
||||
| **Memory Free** | [35:39] | uint32 BE | 0x000e9e52 | 958,034 bytes = 935.6 KB |
|
||||
|
||||
---
|
||||
|
||||
## Idle Frame (58 bytes) — Full Hex Dump
|
||||
|
||||
```
|
||||
00: 2c 00 00 00 00 00 00 00 00 00 00 00 00 08 10 04 ,...............
|
||||
10: 07 ea 00 01 3b 2d 00 00 00 00 00 00 01 01 07 cb ....;-..........
|
||||
20: 00 06 00 00 01 01 07 cb 00 15 00 00 00 00 10 02 ................
|
||||
30: a8 00 0e ff f2 00 0e 9e 52 ef ........R.
|
||||
```
|
||||
|
||||
### Field Breakdown
|
||||
|
||||
**[00:01] = Monitor Mode**
|
||||
```
|
||||
Offset 00: 0x2c = 44 (decimal)
|
||||
Interpretation: Unit is NOT currently monitoring (idle/off state)
|
||||
Counter-example in monitoring frame: 0x00 (ON state)
|
||||
```
|
||||
|
||||
**[01:0d] = Padding/Reserved (12 bytes of zeros)**
|
||||
```
|
||||
Offsets 01-0c: all 0x00
|
||||
```
|
||||
|
||||
**[0d:12] = Timestamp (5 bytes)**
|
||||
```
|
||||
Offset 0d: 0x08 = 8 → DAY
|
||||
Offset 0e: 0x10 = 16 → HOUR (4 PM)
|
||||
Offset 0f: 0x04 = 4 → MONTH (April)
|
||||
Offset 10-11: 0x07ea → YEAR (big-endian: 2026)
|
||||
= 2026-04-08, 16:??:??
|
||||
```
|
||||
|
||||
**[12:14] = Time (minute/second, ambiguous)**
|
||||
```
|
||||
Offset 12: 0x00 = 0 → Likely MINUTE
|
||||
Offset 13: 0x01 = 1 → Likely SECOND
|
||||
But this seems too low; may be wrong interpretation
|
||||
```
|
||||
|
||||
**[14:16] = Unknown (2 bytes)**
|
||||
```
|
||||
Offset 14: 0x3b = 59 (decimal) - could be seconds?
|
||||
Offset 15: 0x2d = 45 (decimal)
|
||||
```
|
||||
|
||||
**[16:2f] = Unknown/Filler (25 bytes)**
|
||||
```
|
||||
Contains various device-specific configuration or state bytes.
|
||||
Some patterns suggest repeating data structures (e.g., 01 01 07 cb appears twice).
|
||||
```
|
||||
|
||||
**[2f:31] = Battery Voltage (2 bytes, uint16 BE, divide by 100)**
|
||||
```
|
||||
Offset 2f-30: 0x02a8
|
||||
= 680 (decimal)
|
||||
÷ 100 = 6.80 volts
|
||||
Expected: ~6.8V ✓ CONFIRMED
|
||||
```
|
||||
|
||||
**[31:35] = Memory Total (4 bytes, uint32 BE)**
|
||||
```
|
||||
Offset 31-34: 0x000efff2
|
||||
= 983,026 (decimal, bytes)
|
||||
÷ 1024 = 960.0 KB ✓ CONFIRMED
|
||||
(Device spec: ~960 KB)
|
||||
```
|
||||
|
||||
**[35:39] = Memory Free (4 bytes, uint32 BE)**
|
||||
```
|
||||
Offset 35-38: 0x000e9e52
|
||||
= 958,034 (decimal, bytes)
|
||||
÷ 1024 = 935.6 KB ✓ CONFIRMED
|
||||
(Expected: ~936 KB)
|
||||
```
|
||||
|
||||
**[39:3a] = Trailing byte**
|
||||
```
|
||||
Offset 39: 0xef = 239
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring Frame (12 bytes) — Condensed Response
|
||||
|
||||
When the unit is actively monitoring, the response shrinks to 12 bytes:
|
||||
|
||||
```
|
||||
00: 00 00 00 00 2c 00 00 00 00 00 00 1f ....,.......
|
||||
```
|
||||
|
||||
### Changes from Idle
|
||||
|
||||
| Field | Idle Frame | Monitoring Frame | Note |
|
||||
|-------|------------|------------------|------|
|
||||
| Monitor Mode | [00] = 0x2c | [04] = 0x2c → may shift or invert | Moved to offset [04]? |
|
||||
| Size | 58 bytes | 12 bytes | Truncated response; only status, no detail |
|
||||
| [0b] | varies | 0x1f | New/different byte at end |
|
||||
|
||||
**Interpretation:**
|
||||
- The response layout changes based on monitoring state
|
||||
- In monitoring mode, many detailed fields are suppressed
|
||||
- The monitor_mode indicator may move or encode differently
|
||||
|
||||
---
|
||||
|
||||
## Date/Time Interpretation
|
||||
|
||||
The timestamp at [0d:12] uses this layout (confirmed from capture):
|
||||
|
||||
```
|
||||
[0d] = DAY (1–31) = 0x08 = 8
|
||||
[0e] = HOUR (0–23) = 0x10 = 16 (4 PM)
|
||||
[0f] = MONTH (1–12) = 0x04 = 4 (April)
|
||||
[10:12] = YEAR (uint16 BE) = 0x07ea = 2026
|
||||
```
|
||||
|
||||
**Timestamp extracted:** 2026-04-08 16:??:??
|
||||
|
||||
Minutes and seconds are less clear:
|
||||
- [12] = 0x00 → possibly minute
|
||||
- [13] = 0x01 → possibly second (but unusually low)
|
||||
- [14] = 0x3b = 59 (redundant second marker?)
|
||||
|
||||
---
|
||||
|
||||
## Voltage Encoding
|
||||
|
||||
Battery voltage is stored as **uint16 big-endian, divide by 100:**
|
||||
|
||||
```
|
||||
[2f:31] = 0x02a8
|
||||
Raw value: 680
|
||||
Voltage: 680 / 100 = 6.80 V
|
||||
Expected: ~6.8V ✓
|
||||
```
|
||||
|
||||
Other attempted decodings (all ruled out):
|
||||
- `÷1000`: 0.680V (too low)
|
||||
- `÷10`: 68V (too high)
|
||||
- float32 BE/LE: no match in range 6–8V
|
||||
- Fixed-point: no other range matched
|
||||
|
||||
---
|
||||
|
||||
## Memory Encoding
|
||||
|
||||
Both fields use **uint32 big-endian, in bytes:**
|
||||
|
||||
```
|
||||
Memory Total:
|
||||
[31:35] = 0x000efff2 = 983,026 bytes = 960.0 KB
|
||||
|
||||
Memory Free:
|
||||
[35:39] = 0x000e9e52 = 958,034 bytes = 935.6 KB
|
||||
|
||||
Sanity check: free < total ✓
|
||||
Free percentage: 935.6 / 960.0 = 97.5% (plausible)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitor Mode Field Transitions
|
||||
|
||||
**Idle/OFF State:**
|
||||
```
|
||||
[00] = 0x2c (decimal 44)
|
||||
```
|
||||
|
||||
**Monitoring/ON State (response shrinks to 12 bytes):**
|
||||
```
|
||||
Byte layout shifts; [04] carries 0x2c or another value
|
||||
Possible interpretation: the byte moves, or encoding inverts
|
||||
```
|
||||
|
||||
**Confirmed behavior:**
|
||||
- When idle: byte [00] = 0x2c, response is 58 bytes
|
||||
- When monitoring: byte position shifts to [04], response is 12 bytes
|
||||
- Value 0x2c appears to mean "OFF" or "not actively streaming"
|
||||
- Value 0x00 appears to mean "ON" or "actively streaming"
|
||||
|
||||
---
|
||||
|
||||
## Unknown Fields (for future analysis)
|
||||
|
||||
The following regions have been observed but their purpose is unclear:
|
||||
|
||||
| Range | Hex (Idle) | Notes |
|
||||
|-------|----------|-------|
|
||||
| [01:0d] | all 0x00 | Padding or reserved? |
|
||||
| [14:16] | 3b 2d | 59, 45 — possibly countdown timers or other state |
|
||||
| [16:2f] | mixed | Appears to contain device configuration snapshots; pattern repeats suggest sub-structures (e.g., trigger levels, calibration dates) |
|
||||
|
||||
---
|
||||
|
||||
## Wire Frame Structure (S3 Format)
|
||||
|
||||
Raw S3 response for SUB 0x1C (response SUB = 0xE3):
|
||||
|
||||
```
|
||||
[DLE=0x10][STX=0x02][destuffed_payload+chk][bare ETX=0x03]
|
||||
|
||||
Destuffed payload:
|
||||
[0] CMD = 0x00
|
||||
[1] flags = 0x10
|
||||
[2] SUB = 0xE3 (response)
|
||||
[3] PAGE_HI = 0x00
|
||||
[4] PAGE_LO = 0x00
|
||||
[5+] data = 58 or 12 bytes (depending on mode)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Table (Idle/OFF State)
|
||||
|
||||
| Field | Bytes | Value | Interpretation |
|
||||
|-------|-------|-------|------------------|
|
||||
| Monitor Mode | [00] | 0x2c | Device idle (not streaming) |
|
||||
| Reserved | [01:0d] | 0x00×12 | Padding |
|
||||
| **Date/Time** | — | — | — |
|
||||
| Day | [0d] | 0x08 | 8th |
|
||||
| Hour | [0e] | 0x10 | 16 (4 PM) |
|
||||
| Month | [0f] | 0x04 | April |
|
||||
| Year | [10:12] | 0x07ea | 2026 |
|
||||
| Minute | [12] | 0x00 | 00 (uncertain) |
|
||||
| Second | [13] | 0x01 | 01 (uncertain) |
|
||||
| Unknown | [14:2f] | — | 27 bytes of mixed data |
|
||||
| **Battery** | — | — | — |
|
||||
| Voltage | [2f:31] | 0x02a8 | 6.80 V (BE ÷100) |
|
||||
| **Memory** | — | — | — |
|
||||
| Total | [31:35] | 0x000efff2 | 960.0 KB (BE) |
|
||||
| Free | [35:39] | 0x000e9e52 | 935.6 KB (BE) |
|
||||
| Trailer | [39:3a] | 0xef | Unknown (1 byte) |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Verify minute/second fields** — Compare against multiple captures to confirm [12:14] layout
|
||||
2. **Decode unknown region [16:2f]** — Likely contains trigger levels, calibration dates, alarm thresholds
|
||||
3. **Monitoring mode byte position** — Confirm whether it truly moves to [04] in the monitoring response or if response layout is completely different
|
||||
4. **Min/max voltage limits** — Check if voltage ever deviates from 6.8V to validate encoding
|
||||
5. **Memory dynamics** — Track total/free across sessions to understand flash layout
|
||||
@@ -0,0 +1,225 @@
|
||||
SUB 0x1C MONITORING STATUS RESPONSE — FINAL FIELD LOCATIONS
|
||||
============================================================
|
||||
|
||||
Source: raw_s3_20260408_015927.bin (2ndtry capture)
|
||||
Frames analyzed:
|
||||
- IDLE (OFF): Frame 90 at file offset 4115 (58-byte response)
|
||||
- MONITORING (ON): Frame 106 at file offset 4922 (12-byte response)
|
||||
|
||||
================================================================================
|
||||
IDLE/OFF RESPONSE (58 bytes) — COMPLETE FIELD MAP
|
||||
================================================================================
|
||||
|
||||
HEX DUMP:
|
||||
00: 2c 00 00 00 00 00 00 00 00 00 00 00 00 08 10 04
|
||||
10: 07 ea 00 01 3b 2d 00 00 00 00 00 00 01 01 07 cb
|
||||
20: 00 06 00 00 01 01 07 cb 00 15 00 00 00 00 10 02
|
||||
30: a8 00 0e ff f2 00 0e 9e 52 ef
|
||||
|
||||
CONFIRMED FIELDS:
|
||||
─────────────────────────────────────────────────────────────────
|
||||
|
||||
[00] MONITOR_MODE
|
||||
Value: 0x2c (44 decimal)
|
||||
Meaning: Device is IDLE (not monitoring)
|
||||
When ON: 0x00
|
||||
|
||||
[0d] DAY
|
||||
Value: 0x08 (8 decimal)
|
||||
Range: 1–31
|
||||
Date: 8th
|
||||
|
||||
[0e] HOUR
|
||||
Value: 0x10 (16 decimal)
|
||||
Range: 0–23
|
||||
Interpretation: 4:00 PM (16:00)
|
||||
|
||||
[0f] MONTH
|
||||
Value: 0x04 (4 decimal)
|
||||
Range: 1–12
|
||||
Meaning: April
|
||||
|
||||
[10:12] YEAR (uint16 BE)
|
||||
Value: 0x07ea
|
||||
Decimal: 2026
|
||||
Full date: 2026-04-08
|
||||
|
||||
[12] MINUTE
|
||||
Value: 0x00 (0 decimal)
|
||||
Range: 0–59
|
||||
Note: May have different encoding in other captures
|
||||
|
||||
[13] SECOND
|
||||
Value: 0x01 (1 decimal)
|
||||
Range: 0–59
|
||||
Note: Unusually low; likely indicates sampling at minute turn-over
|
||||
|
||||
[2f:31] BATTERY_VOLTAGE (uint16 BE, ÷100)
|
||||
Raw bytes: 0x02a8
|
||||
Raw decimal: 680
|
||||
Voltage: 680 ÷ 100 = 6.80 V
|
||||
✓ CONFIRMED: Expected ~6.8V
|
||||
Alternative encodings tested and ruled out:
|
||||
- BE/1000: 0.68V (too low)
|
||||
- BE/10: 68V (too high)
|
||||
- float32 BE/LE: no match
|
||||
- Fixed-point variations: no match
|
||||
|
||||
[31:35] MEMORY_TOTAL (uint32 BE, in bytes)
|
||||
Raw bytes: 0x000efff2
|
||||
Decimal: 983,026 bytes
|
||||
Kilobytes: 983,026 ÷ 1024 = 960.0 KB
|
||||
✓ CONFIRMED: Expected ~960 KB
|
||||
|
||||
[35:39] MEMORY_FREE (uint32 BE, in bytes)
|
||||
Raw bytes: 0x000e9e52
|
||||
Decimal: 958,034 bytes
|
||||
Kilobytes: 958,034 ÷ 1024 = 935.6 KB
|
||||
✓ CONFIRMED: Expected ~936 KB
|
||||
Sanity check: 935.6 / 960.0 = 97.5% (plausible)
|
||||
|
||||
UNIDENTIFIED REGIONS:
|
||||
─────────────────────────────────────────────────────────────────
|
||||
|
||||
[01:0d] PADDING/RESERVED (12 bytes)
|
||||
All zeros: 00 00 00 00 00 00 00 00 00 00 00 00
|
||||
|
||||
[14:16] UNKNOWN (2 bytes)
|
||||
Value: 0x3b2d (59, 45)
|
||||
Possibly event countdown or state field
|
||||
|
||||
[16:2f] CONFIGURATION SNAPSHOT (25 bytes)
|
||||
Contains repeating patterns suggesting sub-structures:
|
||||
- Possibly trigger levels
|
||||
- Possibly calibration data
|
||||
- Possibly alarm settings
|
||||
|
||||
[39] TRAILER (1 byte)
|
||||
Value: 0xef (239)
|
||||
Purpose unknown
|
||||
|
||||
================================================================================
|
||||
MONITORING/ON RESPONSE (12 bytes) — CONDENSED FORMAT
|
||||
================================================================================
|
||||
|
||||
HEX DUMP:
|
||||
00: 00 00 00 00 2c 00 00 00 00 00 00 1f
|
||||
|
||||
INTERPRETATION:
|
||||
─────────────────────────────────────────────────────────────────
|
||||
|
||||
When the unit is actively monitoring, the response shrinks to 12 bytes.
|
||||
Response layout appears different from idle format.
|
||||
|
||||
[04] POSSIBLE MONITOR_MODE (shifted position?)
|
||||
Value: 0x2c
|
||||
Note: In idle response this was at [00]
|
||||
|
||||
[0b] TRAILER (1 byte)
|
||||
Value: 0x1f (31 decimal)
|
||||
Different from idle trailer (0xef at [39])
|
||||
|
||||
All other bytes: 0x00 padding
|
||||
|
||||
HYPOTHESIS:
|
||||
When monitoring, the device suppresses detailed fields and returns only:
|
||||
- Monitor mode status (position may shift)
|
||||
- A condensed state indicator
|
||||
|
||||
================================================================================
|
||||
TIME FIELD SUMMARY (3 INTERPRETATIONS)
|
||||
================================================================================
|
||||
|
||||
OBSERVED BYTES:
|
||||
[0d] = 0x08 (day)
|
||||
[0e] = 0x10 (hour)
|
||||
[0f] = 0x04 (month)
|
||||
[10:12] = 0x07ea (year)
|
||||
[12] = 0x00 (minute)
|
||||
[13] = 0x01 (second)
|
||||
|
||||
INTERPRETATION #1 (MOST LIKELY):
|
||||
2026-04-08 16:00:01
|
||||
|
||||
INTERPRETATION #2 (IF BYTES ARE SWAPPED):
|
||||
Could be 2026-04-08 04:10:?? (but less likely)
|
||||
|
||||
INTERPRETATION #3 (IF TIME IS ELSEWHERE):
|
||||
Bytes at [14:16] = 0x3b2d could indicate 59 seconds, 45 ???
|
||||
But structure is unclear
|
||||
|
||||
CONFIDENCE: MEDIUM
|
||||
The date part (day/month/year) is confirmed at 2026-04-08.
|
||||
The hour=16 (4 PM) seems reasonable.
|
||||
Minute=00 and second=01 seem offset but may reflect the sample time.
|
||||
|
||||
================================================================================
|
||||
VOLTAGE ENCODING VERIFICATION
|
||||
================================================================================
|
||||
|
||||
Test: uint16 BE ÷ 100
|
||||
Raw bytes: 0x02a8
|
||||
As BE uint16: 680
|
||||
After ÷100: 6.80 V
|
||||
Expected: ~6.8V ✓ MATCH
|
||||
|
||||
Eliminated alternatives:
|
||||
÷1000: 0.68V ✗ (too low)
|
||||
÷10: 68V ✗ (too high)
|
||||
float32 BE: no 6.8V match ✗
|
||||
float32 LE: no 6.8V match ✗
|
||||
Fixed-point 8.8: no match ✗
|
||||
Fixed-point 16.0: no match ✗
|
||||
|
||||
CONCLUSION: uint16 BE ÷ 100 is correct encoding.
|
||||
|
||||
================================================================================
|
||||
MEMORY ENCODING VERIFICATION
|
||||
================================================================================
|
||||
|
||||
Test: uint32 BE (bytes), convert to KB
|
||||
|
||||
Memory Total:
|
||||
Raw bytes: 0x000efff2
|
||||
As BE uint32: 983,026
|
||||
In KB: 983,026 ÷ 1024 = 960.0 KB
|
||||
Spec: ~960 KB ✓ MATCH
|
||||
|
||||
Memory Free:
|
||||
Raw bytes: 0x000e9e52
|
||||
As BE uint32: 958,034
|
||||
In KB: 958,034 ÷ 1024 = 935.6 KB
|
||||
Spec: ~936 KB ✓ MATCH
|
||||
|
||||
Sanity check: free (935.6) < total (960.0) ✓
|
||||
Usage: (960.0 - 935.6) / 960.0 = 2.5% (plausible)
|
||||
|
||||
CONCLUSION: uint32 BE (in bytes), divide by 1024 for KB.
|
||||
|
||||
================================================================================
|
||||
PYTHON PARSING REFERENCE
|
||||
================================================================================
|
||||
|
||||
from struct import unpack
|
||||
|
||||
data = bytes.fromhex("2c00000000000000000000000008100407ea00013b2d...")
|
||||
|
||||
monitor_mode = data[0x00]
|
||||
day = data[0x0d]
|
||||
hour = data[0x0e]
|
||||
month = data[0x0f]
|
||||
year = unpack('>H', data[0x10:0x12])[0]
|
||||
minute = data[0x12]
|
||||
second = data[0x13]
|
||||
|
||||
voltage_v = unpack('>H', data[0x2f:0x31])[0] / 100.0
|
||||
memory_total_kb = unpack('>I', data[0x31:0x35])[0] / 1024.0
|
||||
memory_free_kb = unpack('>I', data[0x35:0x39])[0] / 1024.0
|
||||
|
||||
print(f"Monitor: {['ON', 'OFF'][monitor_mode == 0x2c]}")
|
||||
print(f"Date: {year:04d}-{month:02d}-{day:02d}")
|
||||
print(f"Time: {hour:02d}:{minute:02d}:{second:02d}")
|
||||
print(f"Battery: {voltage_v:.2f} V")
|
||||
print(f"Memory: {memory_total_kb:.1f} KB total, {memory_free_kb:.1f} KB free")
|
||||
|
||||
================================================================================
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
experiments.py — Protocol minimization experiments for MiniMate Plus.
|
||||
|
||||
Goal: figure out which steps in Blastware's sequences are truly required vs.
|
||||
cargo-culted, so we can build a faster, smarter client.
|
||||
|
||||
Each experiment is self-contained (opens its own TCP connection) and reports
|
||||
PASS / FAIL / INCONCLUSIVE with timing and notes.
|
||||
|
||||
Usage:
|
||||
python experiments.py [--host IP] [--port PORT] [exp1 exp2 ...]
|
||||
|
||||
Run all: python experiments.py
|
||||
Run specific: python experiments.py cold_status fast_event_count no_5a
|
||||
|
||||
Available experiments
|
||||
---------------------
|
||||
cold_status EXP1 Monitor status (1C) with NO prior POLL
|
||||
fast_event_count EXP2 Event count via POLL+08 only — skip identity reads
|
||||
no_5a EXP3 Event record (0C) without bulk waveform stream (5A)
|
||||
skip_1e EXP4 0A/0C directly with cached key — skip initial 1E
|
||||
fewer_polls EXP5 Only 1 POLL before 5A instead of Blastware's 3
|
||||
compliance_only EXP6 Write compliance ONLY (71x3→72), skip event index+trigger+waveform
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.WARNING, # experiment output is via print(); set DEBUG for wire trace
|
||||
format="%(asctime)s %(levelname)-7s %(name)-20s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger("experiments")
|
||||
|
||||
# ── Imports ───────────────────────────────────────────────────────────────────
|
||||
|
||||
from minimateplus.transport import TcpTransport
|
||||
from minimateplus.protocol import (
|
||||
MiniMateProtocol,
|
||||
ProtocolError,
|
||||
TimeoutError as ProtoTimeout,
|
||||
SUB_MONITOR_STATUS,
|
||||
SUB_SERIAL_NUMBER,
|
||||
SUB_FULL_CONFIG,
|
||||
SUB_EVENT_INDEX,
|
||||
SUB_COMPLIANCE,
|
||||
SUB_WRITE_CONFIRM_A,
|
||||
SUB_WRITE_CONFIRM_B,
|
||||
)
|
||||
from minimateplus.framing import build_bw_frame, SESSION_RESET
|
||||
from minimateplus.client import (
|
||||
MiniMateClient,
|
||||
_decode_compliance_config_into,
|
||||
_encode_compliance_config,
|
||||
)
|
||||
from minimateplus.models import DeviceInfo
|
||||
|
||||
|
||||
DEFAULT_HOST = "63.43.212.232"
|
||||
DEFAULT_PORT = 9034
|
||||
|
||||
|
||||
# ── Result container ──────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Result:
|
||||
name: str
|
||||
outcome: str # "PASS" | "FAIL" | "INCONCLUSIVE"
|
||||
elapsed: float = 0.0
|
||||
notes: str = ""
|
||||
details: dict = field(default_factory=dict)
|
||||
|
||||
def __str__(self) -> str:
|
||||
sym = {"PASS": "✅", "FAIL": "❌", "INCONCLUSIVE": "⚠️ "}.get(self.outcome, "?")
|
||||
lines = [f" {sym} {self.outcome:13s} {self.name} ({self.elapsed:.1f}s)"]
|
||||
if self.notes:
|
||||
lines.append(f" {self.notes}")
|
||||
for k, v in self.details.items():
|
||||
lines.append(f" {k}: {v}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Connection helpers ────────────────────────────────────────────────────────
|
||||
|
||||
def connect_proto(host: str, port: int, timeout: float = 15.0) -> tuple[TcpTransport, MiniMateProtocol]:
|
||||
"""Open a raw TCP connection and return (transport, proto) without any handshake."""
|
||||
t = TcpTransport(host, port)
|
||||
t.connect()
|
||||
proto = MiniMateProtocol(t, recv_timeout=timeout)
|
||||
return t, proto
|
||||
|
||||
|
||||
def connect_client(host: str, port: int, timeout: float = 30.0) -> tuple[MiniMateClient, DeviceInfo]:
|
||||
"""Open a MiniMateClient and run the full connect() handshake."""
|
||||
transport = TcpTransport(host, port)
|
||||
client = MiniMateClient(transport=transport, timeout=timeout)
|
||||
client.open()
|
||||
info = client.connect()
|
||||
return client, info
|
||||
|
||||
|
||||
# ── Experiment runner ─────────────────────────────────────────────────────────
|
||||
|
||||
def run(name: str, fn, *args, **kwargs) -> Result:
|
||||
print(f"\n{'─'*60}")
|
||||
print(f" Running: {name}")
|
||||
print(f"{'─'*60}")
|
||||
t0 = time.time()
|
||||
try:
|
||||
outcome, notes, details = fn(*args, **kwargs)
|
||||
except Exception as exc:
|
||||
outcome = "FAIL"
|
||||
notes = f"Uncaught exception: {exc}"
|
||||
details = {}
|
||||
log.exception("Experiment %s raised:", name)
|
||||
elapsed = time.time() - t0
|
||||
r = Result(name=name, outcome=outcome, elapsed=elapsed, notes=notes, details=details)
|
||||
print(str(r))
|
||||
return r
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# EXP1 — Monitor status (1C) with NO prior POLL
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Blastware always does a full POLL handshake before any other command.
|
||||
# We want to know: can we query SUB 1C (battery, memory, monitoring state)
|
||||
# cold, with only a SESSION_RESET signal and no POLL at all?
|
||||
#
|
||||
# If PASS: status checks become near-instant (no ~1s POLL round-trip).
|
||||
# If FAIL: we need POLL first, but maybe we can cache it.
|
||||
|
||||
def exp_cold_status(host: str, port: int) -> tuple[str, str, dict]:
|
||||
"""SUB 1C without any POLL — just SESSION_RESET + 1C probe + 1C data."""
|
||||
t, proto = connect_proto(host, port)
|
||||
try:
|
||||
print(" Sending SESSION_RESET only (no POLL)")
|
||||
t.write(SESSION_RESET)
|
||||
time.sleep(0.1)
|
||||
|
||||
print(" Sending SUB 1C probe (no POLL first)…")
|
||||
rsp_sub = (0xFF - SUB_MONITOR_STATUS) & 0xFF # 0xE3
|
||||
t.write(build_bw_frame(SUB_MONITOR_STATUS, 0x00))
|
||||
probe = proto._recv_one(expected_sub=rsp_sub, timeout=8.0)
|
||||
print(f" 1C probe OK page_key=0x{probe.page_key:04X} data={probe.data.hex()}")
|
||||
|
||||
t.write(build_bw_frame(SUB_MONITOR_STATUS, 0x2C))
|
||||
data_rsp = proto._recv_one(expected_sub=rsp_sub, timeout=8.0)
|
||||
|
||||
section = data_rsp.data
|
||||
print(f" 1C data OK {len(section)} bytes hex: {section.hex()}")
|
||||
|
||||
# Decode battery + memory from the end of the section
|
||||
details = {"raw_bytes": len(section)}
|
||||
if len(section) >= 10:
|
||||
batt_raw = struct.unpack_from(">H", section, len(section) - 10)[0]
|
||||
mem_total = struct.unpack_from(">I", section, len(section) - 8)[0]
|
||||
mem_free = struct.unpack_from(">I", section, len(section) - 4)[0]
|
||||
is_monitoring = (section[1] == 0x10)
|
||||
details["battery_v"] = f"{batt_raw / 100:.2f} V"
|
||||
details["memory_total"] = f"{mem_total:,} bytes"
|
||||
details["memory_free"] = f"{mem_free:,} bytes"
|
||||
details["monitoring"] = is_monitoring
|
||||
print(f" battery={batt_raw/100:.2f}V mem_free={mem_free:,} monitoring={is_monitoring}")
|
||||
|
||||
return "PASS", "SUB 1C responded without any POLL — cold status read works!", details
|
||||
|
||||
except ProtoTimeout:
|
||||
return "FAIL", "Device did not respond to 1C without POLL (timeout)", {}
|
||||
except ProtocolError as exc:
|
||||
return "FAIL", f"Protocol error: {exc}", {}
|
||||
finally:
|
||||
t.disconnect()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# EXP2 — Fast event count: POLL + SUB 08 only (skip identity reads)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Blastware's connect() does: POLL → 15 → 01 → 1A → 08
|
||||
# We want to know: can we skip 15/01/1A and go straight from POLL to 08?
|
||||
#
|
||||
# Reading identity (15, 01) and full compliance (1A, ~2126 bytes over TCP)
|
||||
# takes several seconds each connect. If we only need event count, skipping
|
||||
# them would be a huge win.
|
||||
#
|
||||
# If PASS: fast status poll = POLL + 08 only (~2 round trips vs ~8+).
|
||||
|
||||
def exp_fast_event_count(host: str, port: int) -> tuple[str, str, dict]:
|
||||
"""POLL startup → SUB 08 only, skip serial/config/compliance reads."""
|
||||
t, proto = connect_proto(host, port)
|
||||
try:
|
||||
print(" Running startup (POLL only)…")
|
||||
proto.startup()
|
||||
print(" POLL OK — now reading SUB 08 (event index) directly…")
|
||||
|
||||
idx_raw = proto.read_event_index()
|
||||
print(f" SUB 08 OK {len(idx_raw)} bytes")
|
||||
|
||||
# Try to decode event count from SUB 08 payload
|
||||
# The raw block is 88 bytes; bytes [3:7] may be a count (uint32 BE)
|
||||
details = {"idx_raw_len": len(idx_raw)}
|
||||
if len(idx_raw) >= 7:
|
||||
count_candidate = struct.unpack_from(">I", idx_raw, 3)[0]
|
||||
details["count_candidate"] = count_candidate
|
||||
print(f" idx[3:7] as uint32 BE = {count_candidate} (may or may not be event count)")
|
||||
|
||||
# Also verify we can read 1E without the identity reads having been done
|
||||
print(" Reading 1E (event header) to confirm event access works…")
|
||||
key4, data8 = proto.read_event_first()
|
||||
is_empty = data8[4:8] == b"\x00\x00\x00\x00"
|
||||
details["first_key"] = key4.hex()
|
||||
details["is_empty"] = is_empty
|
||||
print(f" 1E OK key={key4.hex()} empty={is_empty}")
|
||||
|
||||
return "PASS", "POLL+08+1E all work without identity reads (15/01/1A skipped)", details
|
||||
|
||||
except ProtocolError as exc:
|
||||
return "FAIL", f"Protocol error: {exc}", {}
|
||||
finally:
|
||||
t.disconnect()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# EXP3 — Get event record (0C) without bulk waveform stream (5A)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Blastware's event download = 1E → 0A → 1E-arm → 0C → 1F(dl) → POLL×3 → 5A → 1F(browse)
|
||||
#
|
||||
# The 5A bulk stream is the slow part (several large frames, ~1s+ per event).
|
||||
# We only need 5A for: client, operator, seis_loc, notes (not in 0C).
|
||||
# If you don't need those fields, can we do: 1E → 0A → 0C → 1F(browse) ?
|
||||
#
|
||||
# Two variants tested:
|
||||
# 3a: Skip 1E-arm AND 5A — just 0A → 0C → 1F(browse)
|
||||
# 3b: Include 1E-arm but skip 5A+POLL — 0A → 1E-arm → 0C → 1F(browse)
|
||||
#
|
||||
# If PASS: event peak values available without the slow bulk stream.
|
||||
# If FAIL on 3a but PASS on 3b: 1E-arm required even without 5A.
|
||||
|
||||
def exp_no_5a(host: str, port: int) -> tuple[str, str, dict]:
|
||||
"""Event record via 0A→0C without 5A or POLL×3. Tests both with and without 1E-arm."""
|
||||
t, proto = connect_proto(host, port)
|
||||
try:
|
||||
print(" Startup (POLL)…")
|
||||
proto.startup()
|
||||
|
||||
# Get the first event key via 1E
|
||||
key4, data8 = proto.read_event_first()
|
||||
if data8[4:8] == b"\x00\x00\x00\x00":
|
||||
return "INCONCLUSIVE", "Device has no stored events — cannot test", {}
|
||||
print(f" First event key: {key4.hex()}")
|
||||
|
||||
details: dict = {"key": key4.hex()}
|
||||
|
||||
# ── Variant 3a: 0A → 0C → 1F(browse), no 1E-arm ─────────────────────
|
||||
print("\n [3a] 0A → 0C → 1F(browse) (NO 1E-arm, NO 5A)")
|
||||
try:
|
||||
_hdr, rec_len = proto.read_waveform_header(key4)
|
||||
print(f" 0A OK rec_len=0x{rec_len:02X}")
|
||||
record_3a = proto.read_waveform_record(key4)
|
||||
print(f" 0C OK {len(record_3a)} bytes")
|
||||
# Check for recognizable content
|
||||
has_tran = b"Tran" in record_3a
|
||||
has_vert = b"Vert" in record_3a
|
||||
has_long = b"Long" in record_3a
|
||||
print(f" 0C content check: Tran={has_tran} Vert={has_vert} Long={has_long}")
|
||||
details["3a_0c_bytes"] = len(record_3a)
|
||||
details["3a_has_peaks"] = has_tran and has_vert and has_long
|
||||
|
||||
# Now try browse 1F without any 5A
|
||||
key4_next, data8_next = proto.advance_event(browse=True)
|
||||
null_sentinel = data8_next[4:8] == b"\x00\x00\x00\x00"
|
||||
print(f" 1F(browse) → key={key4_next.hex()} null={null_sentinel}")
|
||||
details["3a_1f_ok"] = True
|
||||
details["3a_outcome"] = "PASS"
|
||||
except ProtocolError as exc:
|
||||
print(f" 3a FAILED: {exc}")
|
||||
details["3a_outcome"] = f"FAIL: {exc}"
|
||||
# Try to recover by reconnecting for 3b
|
||||
t.disconnect()
|
||||
t2, proto2 = connect_proto(host, port)
|
||||
proto2.startup()
|
||||
key4, data8 = proto2.read_event_first()
|
||||
if data8[4:8] == b"\x00\x00\x00\x00":
|
||||
return "FAIL", f"3a failed and device empty on retry: {exc}", details
|
||||
t, proto = t2, proto2
|
||||
|
||||
# ── Variant 3b: 0A → 1E-arm → 0C → 1F(browse), no 5A ───────────────
|
||||
print("\n [3b] 0A → 1E-arm(0xFE) → 0C → 1F(browse) (NO POLL×3, NO 5A)")
|
||||
try:
|
||||
_hdr, rec_len = proto.read_waveform_header(key4)
|
||||
print(f" 0A OK rec_len=0x{rec_len:02X}")
|
||||
|
||||
# 1E download-arm (token=0xFE) between 0A and 0C
|
||||
proto.read_event_first(token=0xFE)
|
||||
print(" 1E-arm OK")
|
||||
|
||||
record_3b = proto.read_waveform_record(key4)
|
||||
print(f" 0C OK {len(record_3b)} bytes")
|
||||
has_tran = b"Tran" in record_3b
|
||||
print(f" 0C content check: Tran={has_tran} Vert={b'Vert' in record_3b}")
|
||||
details["3b_0c_bytes"] = len(record_3b)
|
||||
details["3b_has_peaks"] = has_tran
|
||||
|
||||
# Browse 1F without 5A / POLL×3
|
||||
key4_next2, data8_next2 = proto.advance_event(browse=True)
|
||||
null_sentinel2 = data8_next2[4:8] == b"\x00\x00\x00\x00"
|
||||
print(f" 1F(browse) → key={key4_next2.hex()} null={null_sentinel2}")
|
||||
details["3b_1f_ok"] = True
|
||||
details["3b_outcome"] = "PASS"
|
||||
except ProtocolError as exc:
|
||||
print(f" 3b FAILED: {exc}")
|
||||
details["3b_outcome"] = f"FAIL: {exc}"
|
||||
|
||||
# Summarize
|
||||
a_ok = details.get("3a_outcome") == "PASS"
|
||||
b_ok = details.get("3b_outcome") == "PASS"
|
||||
if a_ok:
|
||||
return "PASS", "3a: 0A→0C works with NO 1E-arm and NO 5A. Huge speedup possible!", details
|
||||
elif b_ok:
|
||||
return "PASS", "3b: 0A→1E-arm→0C works without 5A (1E-arm still needed before 0C)", details
|
||||
else:
|
||||
return "FAIL", "Both 3a and 3b failed — 5A may be required for device state", details
|
||||
|
||||
except ProtocolError as exc:
|
||||
return "FAIL", f"Protocol error during setup: {exc}", {}
|
||||
finally:
|
||||
try:
|
||||
t.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# EXP4 — Skip initial 1E if we already know the event key
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# In Blastware, every session starts with 1E to discover the first key.
|
||||
# But if we already fetched and cached the event keys from a previous session,
|
||||
# can we skip 1E entirely and go straight to 0A(cached_key)?
|
||||
#
|
||||
# Practical use case: we poll the device every N minutes. We already know
|
||||
# all the event keys from last time. On re-connect, can we go direct to 0A?
|
||||
#
|
||||
# If PASS: subsequent polls that don't add new events can skip 1E discovery.
|
||||
|
||||
def exp_skip_1e(host: str, port: int) -> tuple[str, str, dict]:
|
||||
"""Get the first event key, disconnect, reconnect, go straight to 0A (skip 1E)."""
|
||||
# Phase 1: get the key
|
||||
t, proto = connect_proto(host, port)
|
||||
try:
|
||||
proto.startup()
|
||||
key4, data8 = proto.read_event_first()
|
||||
if data8[4:8] == b"\x00\x00\x00\x00":
|
||||
return "INCONCLUSIVE", "No events stored — cannot test", {}
|
||||
print(f" Phase 1: got event key = {key4.hex()}")
|
||||
finally:
|
||||
t.disconnect()
|
||||
time.sleep(0.5)
|
||||
|
||||
# Phase 2: fresh connection, skip 1E, go straight to 0A with cached key
|
||||
t2, proto2 = connect_proto(host, port)
|
||||
try:
|
||||
print(" Phase 2: fresh connection — startup + 0A directly (no 1E)")
|
||||
proto2.startup()
|
||||
|
||||
_hdr, rec_len = proto2.read_waveform_header(key4)
|
||||
print(f" 0A OK rec_len=0x{rec_len:02X}")
|
||||
|
||||
record = proto2.read_waveform_record(key4)
|
||||
has_peaks = b"Tran" in record
|
||||
print(f" 0C OK {len(record)} bytes has_peaks={has_peaks}")
|
||||
|
||||
details = {
|
||||
"cached_key": key4.hex(),
|
||||
"0c_bytes": len(record),
|
||||
"has_peaks": has_peaks,
|
||||
}
|
||||
return "PASS", "0A works with cached key — 1E discovery can be skipped on known sessions", details
|
||||
|
||||
except ProtocolError as exc:
|
||||
return "FAIL", f"0A failed with cached key (device needs 1E first?): {exc}", {"key": key4.hex()}
|
||||
finally:
|
||||
t2.disconnect()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# EXP5 — Fewer POLLs before 5A (try POLL×1 instead of Blastware's POLL×3)
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Blastware always sends 3 full POLL probe+data cycles between 1F and 5A.
|
||||
# Each POLL is a round trip. Can we get away with just 1?
|
||||
#
|
||||
# WARNING: If POLL×1 fails, the device may be in a bad state. We try to
|
||||
# recover with an extra POLL×2 and a fresh 5A attempt. Even on failure we
|
||||
# try to leave the device in a usable state.
|
||||
#
|
||||
# Strategy: run the full event sequence up to 1F(download), then try 5A
|
||||
# with only 1 POLL. If 5A responds → PASS. If timeout → try 2 more POLLs
|
||||
# and check if the device recovers.
|
||||
|
||||
def exp_fewer_polls(host: str, port: int) -> tuple[str, str, dict]:
|
||||
"""Full sequence to 1F, then only 1 POLL before 5A (Blastware does 3)."""
|
||||
t, proto = connect_proto(host, port)
|
||||
try:
|
||||
proto.startup()
|
||||
|
||||
key4, data8 = proto.read_event_first()
|
||||
if data8[4:8] == b"\x00\x00\x00\x00":
|
||||
return "INCONCLUSIVE", "No events stored — cannot test", {}
|
||||
print(f" Event key: {key4.hex()}")
|
||||
|
||||
# Full setup: 0A → 1E-arm → 0C → 1F(download)
|
||||
_hdr, rec_len = proto.read_waveform_header(key4)
|
||||
print(f" 0A OK rec_len=0x{rec_len:02X}")
|
||||
proto.read_event_first(token=0xFE) # 1E-arm
|
||||
print(" 1E-arm OK")
|
||||
proto.read_waveform_record(key4)
|
||||
print(" 0C OK")
|
||||
arm_key4, _ = proto.advance_event(browse=False) # 1F(download) — arms 5A
|
||||
print(f" 1F(download) OK arm_key={arm_key4.hex()}")
|
||||
|
||||
# Only 1 POLL (Blastware does 3)
|
||||
print(" Sending 1 POLL (instead of 3)…")
|
||||
proto.poll()
|
||||
print(" POLL ok — now probing 5A…")
|
||||
|
||||
try:
|
||||
frames = proto.read_bulk_waveform_stream(key4, stop_after_metadata=True, max_chunks=12)
|
||||
print(f" 5A OK after 1 POLL — {len(frames)} frames received")
|
||||
details = {"poll_count": 1, "frames": len(frames)}
|
||||
return "PASS", "5A works with only 1 POLL (saved 2 round-trips per event)!", details
|
||||
|
||||
except ProtoTimeout:
|
||||
print(" 5A timed out after 1 POLL — device needs more POLLs")
|
||||
# Attempt recovery: send 2 more POLLs and see if 5A then works
|
||||
print(" Attempting recovery: 2 more POLLs…")
|
||||
try:
|
||||
proto.poll()
|
||||
proto.poll()
|
||||
frames2 = proto.read_bulk_waveform_stream(key4, stop_after_metadata=True, max_chunks=12)
|
||||
print(f" 5A worked after total 3 POLLs ({len(frames2)} frames)")
|
||||
return "FAIL", "5A needs 3 POLLs — 1 is not enough (recovery confirmed 3 still works)", {
|
||||
"poll_count_tried": 1, "recovery_polls": 3, "recovery_frames": len(frames2)
|
||||
}
|
||||
except ProtocolError as exc2:
|
||||
return "FAIL", f"5A failed even after 3 total POLLs — device may need reconnect: {exc2}", {}
|
||||
|
||||
except ProtocolError as exc:
|
||||
return "FAIL", f"Setup failed: {exc}", {}
|
||||
finally:
|
||||
t.disconnect()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# EXP6 — Compliance-only write (71×3→72), skip event index + trigger + waveform
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Blastware's full write sequence: 68→73 | 71×3→72 | 82→83 | 69→74→72
|
||||
# We want to know: can we write ONLY the compliance block (71×3→72)?
|
||||
#
|
||||
# Test procedure:
|
||||
# 1. Read current compliance config (SUB 1A)
|
||||
# 2. Patch the "notes" field to a test marker
|
||||
# 3. Write ONLY 71×3→72 (skip 68, 73, 82, 83, 69, 74, final 72)
|
||||
# 4. Read back (SUB 1A) and verify the change was written
|
||||
# 5. Restore original value
|
||||
#
|
||||
# If PASS: we can push individual config fields without touching event index,
|
||||
# trigger config, or waveform data — huge simplification.
|
||||
# If FAIL: the device needs the full write sequence (may reject partial write).
|
||||
#
|
||||
# SAFETY: We restore original data in a finally block. If the restore write
|
||||
# fails, the device will have the test marker in "notes" — harmless but visible.
|
||||
|
||||
_EXP6_MARKER = "[exp6-test]"
|
||||
|
||||
def exp_compliance_only(host: str, port: int) -> tuple[str, str, dict]:
|
||||
"""Write compliance block alone (71×3→72), verify, and restore."""
|
||||
client, info = connect_client(host, port)
|
||||
original_raw: Optional[bytes] = None
|
||||
try:
|
||||
proto = client._proto
|
||||
if proto is None:
|
||||
return "FAIL", "Could not get protocol handle from client", {}
|
||||
|
||||
# 1. Read current compliance
|
||||
print(" Reading current compliance config (SUB 1A)…")
|
||||
original_raw = proto.read_compliance_config()
|
||||
print(f" Got {len(original_raw)} bytes of compliance config")
|
||||
|
||||
# Find current notes value for display
|
||||
info_obj = DeviceInfo()
|
||||
_decode_compliance_config_into(original_raw, info_obj)
|
||||
cc = info_obj.compliance_config
|
||||
orig_notes = cc.notes if cc else "(unknown)"
|
||||
print(f" Current notes field: {orig_notes!r}")
|
||||
|
||||
# 2. Build modified payload with test marker in notes
|
||||
test_notes = _EXP6_MARKER
|
||||
modified_raw = _encode_compliance_config(
|
||||
original_raw,
|
||||
notes=test_notes,
|
||||
)
|
||||
print(f" Encoded modified compliance payload ({len(modified_raw)} bytes)")
|
||||
print(f" Patching notes: {orig_notes!r} → {test_notes!r}")
|
||||
|
||||
# 3. Write ONLY the compliance block: 71×3 → 72
|
||||
print(" Writing compliance ONLY (71×3→72) — skipping 68/73/82/83/69/74…")
|
||||
proto.write_compliance_config_raw(modified_raw)
|
||||
print(" Write complete — device acked 71×3→72")
|
||||
|
||||
# 4. Read back and verify
|
||||
print(" Reading back compliance config to verify…")
|
||||
readback_raw = proto.read_compliance_config()
|
||||
readback_info = DeviceInfo()
|
||||
_decode_compliance_config_into(readback_raw, readback_info)
|
||||
rb_cc = readback_info.compliance_config
|
||||
readback_notes = rb_cc.notes if rb_cc else "(decode failed)"
|
||||
print(f" Read-back notes: {readback_notes!r}")
|
||||
|
||||
write_worked = (readback_notes == test_notes)
|
||||
print(f" Write verified: {write_worked}")
|
||||
|
||||
details = {
|
||||
"original_notes": orig_notes,
|
||||
"written_notes": test_notes,
|
||||
"readback_notes": readback_notes,
|
||||
"write_verified": write_worked,
|
||||
}
|
||||
|
||||
if write_worked:
|
||||
return "PASS", "Compliance-only write works! No event index or trigger writes needed.", details
|
||||
else:
|
||||
return "FAIL", f"Write was not reflected in read-back (got {readback_notes!r})", details
|
||||
|
||||
except ProtocolError as exc:
|
||||
return "FAIL", f"Protocol error: {exc}", {}
|
||||
|
||||
finally:
|
||||
# Restore original compliance data regardless of outcome
|
||||
if original_raw is not None:
|
||||
print(" Restoring original compliance config…")
|
||||
try:
|
||||
proto2 = client._proto
|
||||
if proto2:
|
||||
proto2.write_compliance_config_raw(
|
||||
_encode_compliance_config(original_raw) # no-op patch = verbatim
|
||||
)
|
||||
print(" Restore complete")
|
||||
else:
|
||||
print(" WARNING: protocol handle gone — could not restore")
|
||||
except Exception as exc_r:
|
||||
print(f" WARNING: restore failed: {exc_r}")
|
||||
client.close()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# Registry + main
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
EXPERIMENTS = {
|
||||
"cold_status": ("EXP1", exp_cold_status, "Monitor status (1C) with no POLL"),
|
||||
"fast_event_count": ("EXP2", exp_fast_event_count, "Event count via POLL+08, skip identity reads"),
|
||||
"no_5a": ("EXP3", exp_no_5a, "Event record (0C) without bulk waveform (5A)"),
|
||||
"skip_1e": ("EXP4", exp_skip_1e, "0A/0C with cached key — skip initial 1E"),
|
||||
"fewer_polls": ("EXP5", exp_fewer_polls, "1 POLL before 5A instead of Blastware's 3"),
|
||||
"compliance_only": ("EXP6", exp_compliance_only, "Compliance-only write (71×3→72), no other blocks"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="MiniMate Plus protocol minimization experiments")
|
||||
ap.add_argument("--host", default=DEFAULT_HOST)
|
||||
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||||
ap.add_argument("--debug", action="store_true", help="Enable DEBUG wire logging")
|
||||
ap.add_argument("experiments", nargs="*",
|
||||
help=f"Which to run (default: all). Choices: {', '.join(EXPERIMENTS)}")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.debug:
|
||||
logging.getLogger().setLevel(logging.DEBUG)
|
||||
|
||||
which = args.experiments or list(EXPERIMENTS.keys())
|
||||
unknown = [e for e in which if e not in EXPERIMENTS]
|
||||
if unknown:
|
||||
print(f"Unknown experiments: {unknown}")
|
||||
print(f"Available: {', '.join(EXPERIMENTS)}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n{'═'*60}")
|
||||
print(f" MiniMate Plus Protocol Minimization Experiments")
|
||||
print(f" Target: {args.host}:{args.port}")
|
||||
print(f" Running: {', '.join(which)}")
|
||||
print(f"{'═'*60}")
|
||||
|
||||
results: list[Result] = []
|
||||
for key in which:
|
||||
tag, fn, desc = EXPERIMENTS[key]
|
||||
label = f"{tag}: {desc}"
|
||||
r = run(label, fn, args.host, args.port)
|
||||
results.append(r)
|
||||
time.sleep(1.5) # brief pause between experiments — let device settle
|
||||
|
||||
print(f"\n\n{'═'*60}")
|
||||
print(" SUMMARY")
|
||||
print(f"{'═'*60}")
|
||||
for r in results:
|
||||
sym = {"PASS": "✅", "FAIL": "❌", "INCONCLUSIVE": "⚠️ "}.get(r.outcome, "?")
|
||||
print(f" {sym} {r.outcome:13s} {r.name}")
|
||||
print(f"{'═'*60}")
|
||||
|
||||
passed = sum(1 for r in results if r.outcome == "PASS")
|
||||
failed = sum(1 for r in results if r.outcome == "FAIL")
|
||||
skipped = sum(1 for r in results if r.outcome == "INCONCLUSIVE")
|
||||
print(f" {passed} passed {failed} failed {skipped} inconclusive")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.")
|
||||
sys.exit(0)
|
||||
@@ -20,8 +20,8 @@ Typical usage (TCP / modem):
|
||||
"""
|
||||
|
||||
from .client import MiniMateClient
|
||||
from .models import DeviceInfo, Event
|
||||
from .models import DeviceInfo, Event, MonitorLogEntry
|
||||
from .transport import SerialTransport, TcpTransport
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__all__ = ["MiniMateClient", "DeviceInfo", "Event", "SerialTransport", "TcpTransport"]
|
||||
__all__ = ["MiniMateClient", "DeviceInfo", "Event", "MonitorLogEntry", "SerialTransport", "TcpTransport"]
|
||||
|
||||
@@ -0,0 +1,949 @@
|
||||
"""
|
||||
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 1–9, 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. BE6907→H, BE11529→M, BE14036→P, BE18003→T
|
||||
|
||||
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 (0–1295)
|
||||
|
||||
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")
|
||||
+1985
-136
File diff suppressed because it is too large
Load Diff
+277
-12
@@ -90,6 +90,66 @@ def checksum(payload: bytes) -> int:
|
||||
|
||||
# ── BW→S3 frame builder ───────────────────────────────────────────────────────
|
||||
|
||||
# SUB byte for 5A — used by build_5a_frame below (protocol.py has the full
|
||||
# constant set; defined here to avoid a circular import).
|
||||
SUB_5A = 0x5A
|
||||
|
||||
|
||||
def build_5a_frame(offset_word: int, raw_params: bytes) -> bytes:
|
||||
"""
|
||||
Build a BW→S3 frame for SUB 5A (BULK_WAVEFORM_STREAM) that exactly
|
||||
matches Blastware's wire output.
|
||||
|
||||
SUB 5A uses a DIFFERENT frame format from all other read commands:
|
||||
1. The offset field (bytes [4:6]) is written RAW — the 0x10 in
|
||||
offset_hi=0x10 is NOT DLE-stuffed, unlike build_bw_frame().
|
||||
2. The checksum uses a DLE-aware sum: for each 0x10 XX pair in the
|
||||
stuffed section, only XX contributes; lone bytes contribute normally.
|
||||
This differs from the standard SUM8 checksum on the unstuffed payload.
|
||||
|
||||
Both differences are confirmed from the 1-2-26 BW TX capture (all 10 frames
|
||||
verified against this algorithm on 2026-04-02).
|
||||
|
||||
Args:
|
||||
offset_word: 16-bit offset (0x1004 for probe/chunks, 0x005A for term).
|
||||
raw_params: 10 or 11 params bytes (from bulk_waveform_params or
|
||||
bulk_waveform_term_params). 0x10 bytes in params are
|
||||
written RAW — NOT DLE-stuffed. Confirmed 2026-04-06 by
|
||||
comparing wire bytes: BW sends bare `10 04` for chunk 1
|
||||
(counter=0x1004), not stuffed `10 10 04`. Device reads
|
||||
params at fixed byte positions; stuffing shifts the bytes
|
||||
and corrupts the counter, causing device to ignore the frame.
|
||||
|
||||
Returns:
|
||||
Complete frame bytes: [ACK][STX][stuffed_section][chk][ETX]
|
||||
"""
|
||||
if len(raw_params) not in (10, 11):
|
||||
raise ValueError(f"raw_params must be 10 or 11 bytes, got {len(raw_params)}")
|
||||
|
||||
# Build stuffed section between STX and checksum
|
||||
s = bytearray()
|
||||
s += b"\x10\x10" # DLE-stuffed BW_CMD
|
||||
s += b"\x00" # flags
|
||||
s += bytes([SUB_5A]) # sub = 0x5A
|
||||
s += b"\x00" # field3
|
||||
s += bytes([(offset_word >> 8) & 0xFF, # offset_hi — raw, NOT stuffed
|
||||
offset_word & 0xFF]) # offset_lo
|
||||
for b in raw_params: # params — NOT DLE-stuffed (raw bytes, match BW wire format)
|
||||
s.append(b)
|
||||
|
||||
# DLE-aware checksum: for 0x10 XX pairs count XX; for lone bytes count them
|
||||
chk, i = 0, 0
|
||||
while i < len(s):
|
||||
if s[i] == DLE and i + 1 < len(s):
|
||||
chk = (chk + s[i + 1]) & 0xFF
|
||||
i += 2
|
||||
else:
|
||||
chk = (chk + s[i]) & 0xFF
|
||||
i += 1
|
||||
|
||||
return bytes([ACK, STX]) + bytes(s) + bytes([chk, ETX])
|
||||
|
||||
|
||||
def build_bw_frame(sub: int, offset: int = 0, params: bytes = bytes(10)) -> bytes:
|
||||
"""
|
||||
Build a BW→S3 read-command frame.
|
||||
@@ -134,6 +194,109 @@ def build_bw_frame(sub: int, offset: int = 0, params: bytes = bytes(10)) -> byte
|
||||
return wire
|
||||
|
||||
|
||||
def build_bw_write_frame(
|
||||
sub: int,
|
||||
data: bytes,
|
||||
*,
|
||||
offset: int = 0,
|
||||
params: bytes = bytes(10),
|
||||
) -> bytes:
|
||||
"""
|
||||
Build a BW→S3 write-command frame.
|
||||
|
||||
Write frames extend the standard 16-byte read header with a variable-length
|
||||
data payload. They use a different checksum formula from read frames.
|
||||
|
||||
**CRITICAL: Write frames use minimal DLE stuffing.**
|
||||
|
||||
Unlike read frames (build_bw_frame), write frames do NOT apply full DLE
|
||||
stuffing to the payload. Only the BW_CMD byte (0x10) at position [0] is
|
||||
doubled to 0x10 0x10 on the wire. All other bytes — flags, sub, offset,
|
||||
params, data, and checksum — are written RAW with no stuffing, even if they
|
||||
contain 0x10 bytes (e.g. offset_hi=0x10 for compliance chunks, or 0x10
|
||||
bytes in the write data payload).
|
||||
|
||||
Confirmed from 3-11-26 BW TX capture (frames 102–112): all 11 write frames
|
||||
match the rule "double BW_CMD only; everything else raw." ✅ 2026-04-07.
|
||||
|
||||
Wire layout:
|
||||
[41] ACK
|
||||
[02] STX
|
||||
[10 10] BW_CMD doubled (the ONLY DLE stuffing applied)
|
||||
[00] flags
|
||||
[sub] write command byte (0x68–0x83)
|
||||
[00] always zero
|
||||
[hi][lo] offset as uint16 BE (raw; NOT stuffed even if hi=0x10)
|
||||
[params] 10 bytes (raw)
|
||||
[data] variable-length write payload (raw; NOT stuffed)
|
||||
[chk] checksum byte (raw; NOT stuffed even if 0x10)
|
||||
[03] ETX
|
||||
|
||||
De-stuffed payload (for checksum computation):
|
||||
[0] BW_CMD 0x10
|
||||
[1] flags 0x00
|
||||
[2] SUB write command byte
|
||||
[3] 0x00 always zero
|
||||
[4] offset_hi
|
||||
[5] offset_lo
|
||||
[6:16] params 10 bytes
|
||||
[16:] data write payload
|
||||
[-1] chk
|
||||
|
||||
**Checksum formula (confirmed 2026-03-12 from 3-11-26 BW TX capture):**
|
||||
chk = (sum(b for b in payload[2:] if b != 0x10) + 0x10) % 256
|
||||
where payload = destuffed content BEFORE appending chk.
|
||||
This skips all 0x10 bytes in payload[2:] (sub onwards), including any
|
||||
0x10 bytes in the offset, params, data, and the checksum byte itself.
|
||||
|
||||
The offset field [4:6] meaning per write SUB:
|
||||
- SUBs 68, 69, 82 (single-chunk writes): offset = data[1] + 2, where
|
||||
data[1] is an embedded length field in the write payload.
|
||||
Confirmed from capture: 68→0x5A (data[1]=0x58+2), 82→0x1C
|
||||
(data[1]=0x1A+2), 69→0xCA (data[1]=0xC8+2).
|
||||
- SUB 71 (multi-chunk compliance): 0x1004 for full chunks, 0x002C
|
||||
for the final partial chunk.
|
||||
- Confirm frames (72, 73, 74, 83): offset=0, no data.
|
||||
|
||||
Args:
|
||||
sub: Write command SUB byte.
|
||||
data: Write payload (variable length; empty for confirm frames).
|
||||
offset: 16-bit value placed at [4:6]. See per-SUB notes above.
|
||||
params: 10 bytes placed at [6:16]. All-zero for most writes; compliance
|
||||
chunk writes use chunk-specific values.
|
||||
|
||||
Returns:
|
||||
Complete frame bytes ready to write to the transport.
|
||||
"""
|
||||
if len(params) != 10:
|
||||
raise ValueError(f"params must be exactly 10 bytes, got {len(params)}")
|
||||
if offset > 0xFFFF:
|
||||
raise ValueError(f"offset must fit in uint16, got {offset:#06x}")
|
||||
|
||||
offset_hi = (offset >> 8) & 0xFF
|
||||
offset_lo = offset & 0xFF
|
||||
|
||||
# Destuffed payload (used only for checksum; not sent directly)
|
||||
payload_no_chk = bytes([BW_CMD, 0x00, sub, 0x00, offset_hi, offset_lo]) + params + data
|
||||
|
||||
# Large-frame checksum: sum payload[2:] skipping all 0x10 bytes, add 0x10.
|
||||
# Applied to the destuffed representation — confirms correctly against
|
||||
# all 11 write frames in the 3-11-26/170151 BW TX capture. ✅
|
||||
chk = (sum(b for b in payload_no_chk[2:] if b != 0x10) + 0x10) & 0xFF
|
||||
|
||||
# Wire construction: only BW_CMD is doubled; everything else is raw.
|
||||
# Do NOT use dle_stuff() here — that would incorrectly double 0x10 bytes
|
||||
# in the offset, params, and data sections.
|
||||
wire = (
|
||||
bytes([ACK, STX]) # Frame prefix (not part of payload)
|
||||
+ bytes([BW_CMD, BW_CMD]) # BW_CMD doubled (only DLE stuffing applied)
|
||||
+ payload_no_chk[1:] # flags, sub, offset, params, data — RAW
|
||||
+ bytes([chk]) # checksum — RAW
|
||||
+ bytes([ETX]) # Frame terminator
|
||||
)
|
||||
return wire
|
||||
|
||||
|
||||
def waveform_key_params(key4: bytes) -> bytes:
|
||||
"""
|
||||
Build the 10-byte params block that carries a 4-byte waveform key.
|
||||
@@ -164,21 +327,106 @@ def token_params(token: int = 0) -> bytes:
|
||||
Build the 10-byte params block that carries a single token byte.
|
||||
|
||||
Used for SUBs 1E (EVENT_HEADER) and 1F (EVENT_ADVANCE).
|
||||
The token goes at params[6], which maps to payload[12].
|
||||
The token goes at params[7], which maps to payload[13].
|
||||
|
||||
Confirmed from BOTH 3-31-26 and 4-3-26 BW TX captures:
|
||||
raw params bytes: 00 00 00 00 00 00 00 fe 00 00
|
||||
token is at index 7 (not 6 — that was wrong).
|
||||
|
||||
Confirmed from 3-31-26 capture:
|
||||
- token=0x00: first-event read / browse mode (no download marking)
|
||||
- token=0xfe: download mode (causes 1F to skip partial bins and
|
||||
advance to the next full record)
|
||||
|
||||
The device echoes the token at data[8] of the S3 response (payload[13]),
|
||||
distinct from the next-event key at data[11:15] (payload[16:20]).
|
||||
|
||||
Args:
|
||||
token: single byte to place at params[6] / payload[12].
|
||||
token: single byte to place at params[7] / payload[13].
|
||||
|
||||
Returns:
|
||||
10-byte params block with token at position [6].
|
||||
10-byte params block with token at position [7].
|
||||
"""
|
||||
p = bytearray(10)
|
||||
p[6] = token
|
||||
p[7] = token
|
||||
return bytes(p)
|
||||
|
||||
|
||||
def bulk_waveform_params(key4: bytes, counter: int, *, is_probe: bool = False) -> bytes:
|
||||
"""
|
||||
Build the 10-byte params block for SUB 5A (BULK_WAVEFORM_STREAM) requests.
|
||||
|
||||
Confirmed 2026-04-02 from 1-2-26 BW TX capture analysis:
|
||||
|
||||
Probe / first request (is_probe=True, counter=0):
|
||||
params[0] = 0x00
|
||||
params[1:5] = key4 (all 4 key bytes; counter overlaps key4[2:4] = 0x0000)
|
||||
params[5:] = zeros
|
||||
|
||||
Regular chunk requests (is_probe=False):
|
||||
params[0] = 0x00
|
||||
params[1:3] = key4[0:2] (first 2 key bytes as session handle)
|
||||
params[3:5] = counter (BE uint16) (chunk position, increments by 0x0400)
|
||||
params[5:] = zeros
|
||||
|
||||
Termination request: DO NOT use this helper — see bulk_waveform_term_params().
|
||||
|
||||
Args:
|
||||
key4: 4-byte waveform key from EVENT_HEADER (1E) response.
|
||||
counter: Chunk position counter (uint16 BE). Pass 0 for probe.
|
||||
is_probe: If True, embed full key4 (probe step only).
|
||||
|
||||
Returns:
|
||||
11-byte params block. (BW confirmed: chunk frames carry 11 params bytes,
|
||||
not 10; the extra trailing 0x00 was confirmed from 1-2-26 wire capture
|
||||
on 2026-04-02.)
|
||||
"""
|
||||
if len(key4) != 4:
|
||||
raise ValueError(f"waveform key must be 4 bytes, got {len(key4)}")
|
||||
p = bytearray(11) # 11 bytes confirmed from BW wire capture
|
||||
p[0] = 0x00
|
||||
p[1] = key4[0]
|
||||
p[2] = key4[1]
|
||||
if is_probe:
|
||||
# Full key4; counter=0 is implied (overlaps with key4[2:4] which must be 0x0000)
|
||||
p[3] = key4[2]
|
||||
p[4] = key4[3]
|
||||
else:
|
||||
p[3] = (counter >> 8) & 0xFF
|
||||
p[4] = counter & 0xFF
|
||||
return bytes(p)
|
||||
|
||||
|
||||
def bulk_waveform_term_params(key4: bytes, counter: int) -> bytes:
|
||||
"""
|
||||
Build the 10-byte params block for the SUB 5A termination request.
|
||||
|
||||
The termination request uses offset=0x005A and a DIFFERENT params layout —
|
||||
the leading 0x00 byte is dropped, key4[0:2] shifts to params[0:2], and the
|
||||
counter high byte is at params[2]:
|
||||
|
||||
params[0] = key4[0]
|
||||
params[1] = key4[1]
|
||||
params[2] = (counter >> 8) & 0xFF
|
||||
params[3:] = zeros
|
||||
|
||||
Counter for the termination request = last_regular_counter + 0x0400.
|
||||
|
||||
Confirmed from 1-2-26 BW TX capture: final request (frame 83) uses
|
||||
offset=0x005A, params[0:3] = key4[0:2] + term_counter_hi.
|
||||
|
||||
Args:
|
||||
key4: 4-byte waveform key.
|
||||
counter: Termination counter (= last regular counter + 0x0400).
|
||||
|
||||
Returns:
|
||||
10-byte params block.
|
||||
"""
|
||||
if len(key4) != 4:
|
||||
raise ValueError(f"waveform key must be 4 bytes, got {len(key4)}")
|
||||
p = bytearray(10)
|
||||
p[0] = key4[0]
|
||||
p[1] = key4[1]
|
||||
p[2] = (counter >> 8) & 0xFF
|
||||
return bytes(p)
|
||||
|
||||
|
||||
@@ -190,6 +438,14 @@ def token_params(token: int = 0) -> bytes:
|
||||
POLL_PROBE = build_bw_frame(0x5B, 0x00) # length-probe POLL (offset = 0)
|
||||
POLL_DATA = build_bw_frame(0x5B, 0x30) # data-request POLL (offset = 0x30)
|
||||
|
||||
# Session-reset signal (ACK + ETX, no STX/payload).
|
||||
# Confirmed from 4-8-26 BW TX captures: Blastware sends this 2-byte sequence
|
||||
# immediately before the first POLL probe, and again between the POLL probe
|
||||
# and the POLL data request. Required to wake a unit that is actively
|
||||
# monitoring — without it the unit does not respond to POLL over TCP.
|
||||
# Harmless for idle units (they respond to POLL regardless).
|
||||
SESSION_RESET = bytes([0x41, 0x03])
|
||||
|
||||
|
||||
# ── S3 response dataclass ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -201,6 +457,11 @@ 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:
|
||||
@@ -241,13 +502,15 @@ class S3FrameParser:
|
||||
_IN_FRAME_DLE = 3
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._state = self._IDLE
|
||||
self._body = bytearray() # accumulates de-stuffed frame bytes
|
||||
self._state = self._IDLE
|
||||
self._body = bytearray() # accumulates de-stuffed frame bytes
|
||||
self.frames: list[S3Frame] = []
|
||||
self.bytes_fed: int = 0 # cumulative raw bytes fed since last reset
|
||||
|
||||
def reset(self) -> None:
|
||||
self._state = self._IDLE
|
||||
self._state = self._IDLE
|
||||
self._body.clear()
|
||||
self.bytes_fed = 0
|
||||
|
||||
def feed(self, data: bytes) -> list[S3Frame]:
|
||||
"""
|
||||
@@ -256,6 +519,7 @@ class S3FrameParser:
|
||||
Returns a list of S3Frame objects completed during this call.
|
||||
All completed frames are also appended to self.frames.
|
||||
"""
|
||||
self.bytes_fed += len(data)
|
||||
completed: list[S3Frame] = []
|
||||
for b in data:
|
||||
frame = self._step(b)
|
||||
@@ -333,9 +597,10 @@ 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,
|
||||
)
|
||||
|
||||
+254
-9
@@ -14,6 +14,7 @@ Notes on certainty:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import struct
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
@@ -98,14 +99,17 @@ class Timestamp:
|
||||
|
||||
Wire layout (✅ CONFIRMED 2026-04-01 against Blastware event report):
|
||||
byte[0]: day (uint8)
|
||||
byte[1]: sub_code / mode flag (0x10 = Waveform mode) 🔶
|
||||
byte[1]: sub_code / mode flag (0x10 = Waveform single-shot)
|
||||
byte[2]: month (uint8)
|
||||
bytes[3–4]: year (big-endian uint16)
|
||||
byte[5]: unknown (0x00 in all observed samples ❓)
|
||||
byte[5]: unknown (0x00 in all observed samples)
|
||||
byte[6]: hour (uint8)
|
||||
byte[7]: minute (uint8)
|
||||
byte[8]: second (uint8)
|
||||
|
||||
Used for sub_code=0x10 records only. For sub_code=0x03 (continuous
|
||||
mode) use from_continuous_record() — the layout is shifted by 1 byte.
|
||||
|
||||
Args:
|
||||
data: at least 9 bytes; only the first 9 are consumed.
|
||||
|
||||
@@ -120,7 +124,7 @@ class Timestamp:
|
||||
f"Waveform record timestamp requires at least 9 bytes, got {len(data)}"
|
||||
)
|
||||
day = data[0]
|
||||
sub_code = data[1] # 0x10 = Waveform; histogram code not yet confirmed
|
||||
sub_code = data[1] # 0x10 = Waveform single-shot
|
||||
month = data[2]
|
||||
year = struct.unpack_from(">H", data, 3)[0]
|
||||
unknown_byte = data[5]
|
||||
@@ -139,6 +143,64 @@ class Timestamp:
|
||||
second=second,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_continuous_record(cls, data: bytes) -> "Timestamp":
|
||||
"""
|
||||
Decode a 10-byte timestamp from the first bytes of a sub_code=0x03
|
||||
(Waveform Continuous) 210-byte record.
|
||||
|
||||
Wire layout (✅ CONFIRMED 2026-04-03 against Blastware event report,
|
||||
event recorded at 15:20:17 April 3 2026, raw: 10 03 10 04 07 ea 00 0f 14 11):
|
||||
byte[0]: unknown_a (0x10 observed — meaning TBD)
|
||||
byte[1]: day (uint8)
|
||||
byte[2]: unknown_b (0x10 observed — meaning TBD)
|
||||
bytes[3]: month (uint8)
|
||||
bytes[4–5]: year (big-endian uint16)
|
||||
byte[6]: unknown (0x00 in all observed samples)
|
||||
byte[7]: hour (uint8)
|
||||
byte[8]: minute (uint8)
|
||||
byte[9]: second (uint8)
|
||||
|
||||
This is the sub_code=0x10 layout shifted forward by 1 byte, with two
|
||||
extra unknown bytes at [0] and [2]. The sub_code (0x03) itself is at
|
||||
byte[1] in the raw record, which also encodes the day — but the day
|
||||
value (3 = April 3rd) happens to differ from the sub_code (0x03) only
|
||||
in semantics; the byte is shared.
|
||||
|
||||
Args:
|
||||
data: at least 10 bytes; only the first 10 are consumed.
|
||||
|
||||
Returns:
|
||||
Decoded Timestamp with hour/minute/second populated.
|
||||
|
||||
Raises:
|
||||
ValueError: if data is fewer than 10 bytes.
|
||||
"""
|
||||
if len(data) < 10:
|
||||
raise ValueError(
|
||||
f"Continuous record timestamp requires at least 10 bytes, got {len(data)}"
|
||||
)
|
||||
unknown_a = data[0] # 0x10 observed; meaning unknown
|
||||
day = data[1] # doubles as the sub_code byte (0x03) — day=3 on Apr 3
|
||||
unknown_b = data[2] # 0x10 observed; meaning unknown
|
||||
month = data[3]
|
||||
year = struct.unpack_from(">H", data, 4)[0]
|
||||
unknown_byte = data[6]
|
||||
hour = data[7]
|
||||
minute = data[8]
|
||||
second = data[9]
|
||||
return cls(
|
||||
raw=bytes(data[:10]),
|
||||
flag=unknown_a,
|
||||
year=year,
|
||||
unknown_byte=unknown_byte,
|
||||
month=month,
|
||||
day=day,
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
second=second,
|
||||
)
|
||||
|
||||
@property
|
||||
def clock_set(self) -> bool:
|
||||
"""False when year == 1995 (factory default / battery-lost state)."""
|
||||
@@ -183,6 +245,9 @@ class DeviceInfo:
|
||||
# ── From SUB 1A (COMPLIANCE_CONFIG_RESPONSE) ──────────────────────────────
|
||||
compliance_config: Optional["ComplianceConfig"] = None # E5 response, read in connect()
|
||||
|
||||
# ── From SUB 08 (EVENT_INDEX_RESPONSE) ────────────────────────────────────
|
||||
event_count: Optional[int] = None # stored event count from F7 response 🔶
|
||||
|
||||
def __str__(self) -> str:
|
||||
fw = self.firmware_version or f"?.{self.firmware_minor}"
|
||||
mdl = self.model or "MiniMate Plus"
|
||||
@@ -204,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 # full-scale calibration constant (e.g. 6.206) 🔶
|
||||
max_range: float # hardware/firmware sensitivity constant (e.g. 6.206053) ✅ confirmed same on all units
|
||||
unit_label: str # e.g. "in./s" or "psi" ✅
|
||||
|
||||
|
||||
@@ -273,15 +338,34 @@ class ComplianceConfig:
|
||||
raw: Optional[bytes] = None # full 2090-byte payload (for debugging)
|
||||
|
||||
# Recording parameters (✅ CONFIRMED from §7.6)
|
||||
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 ❓
|
||||
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)
|
||||
|
||||
# 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)
|
||||
max_range_geo: Optional[float] = None # in/s full-scale range
|
||||
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)
|
||||
|
||||
# Project/setup strings (sourced from E5 / SUB 71 write payload)
|
||||
# These are the FULL project metadata from compliance config,
|
||||
@@ -294,6 +378,78 @@ 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
|
||||
@@ -324,12 +480,23 @@ class Event:
|
||||
# Raw ADC samples keyed by channel label. Not fetched unless explicitly
|
||||
# requested (large data transfer — up to several MB per event).
|
||||
raw_samples: Optional[dict] = None # {"Tran": [...], "Vert": [...], ...}
|
||||
total_samples: Optional[int] = None # from STRT record: expected total sample-sets
|
||||
pretrig_samples: Optional[int] = None # from STRT record: pre-trigger sample count
|
||||
rectime_seconds: Optional[int] = None # from STRT record: record duration (seconds)
|
||||
|
||||
# ── Debug / introspection ─────────────────────────────────────────────────
|
||||
# Raw 210-byte waveform record bytes, set when debug mode is active.
|
||||
# Exposed by the SFM server via ?debug=true so field layouts can be verified.
|
||||
_raw_record: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
# 4-byte waveform key used to request this event via SUB 5A.
|
||||
# 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 = ""
|
||||
@@ -346,3 +513,81 @@ class Event:
|
||||
parts.append(f"M={pv.micl:.6f}")
|
||||
ppv = " [" + ", ".join(parts) + " in/s]"
|
||||
return f"Event#{self.index} {ts}{ppv}"
|
||||
|
||||
|
||||
# ── MonitorLogEntry ───────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class MonitorLogEntry:
|
||||
"""
|
||||
A monitor log entry decoded from a SUB 0x0A (WAVEFORM_HEADER) response
|
||||
whose first byte is 0x2C (partial record, recording mode = continuous
|
||||
monitoring without a triggered event).
|
||||
|
||||
These are the "partial bins" that Blastware stores between triggered events.
|
||||
Each entry represents one monitoring interval — the span of time during
|
||||
which the unit was actively monitoring but no threshold crossing occurred.
|
||||
|
||||
Confirmed from 4-11-26 MITM capture analysis (2026-04-11):
|
||||
|
||||
Header layout (full response data[0:]):
|
||||
data[0] = 0x2C (partial record type / data length in probe response)
|
||||
data[1:5] = 0x00 × 4
|
||||
data[5:9] = event key (4 bytes, big-endian hex)
|
||||
data[9:11] = 0x00 × 2
|
||||
data[11:] = timestamp_start (9 or 10 bytes depending on recording mode)
|
||||
+ timestamp_stop (same format)
|
||||
+ separator (4–5 bytes, variable)
|
||||
+ serial null-terminated (e.g. "BE11529\\0")
|
||||
+ "Geo: X.XXX in/s\\0" (trigger threshold string)
|
||||
|
||||
Timestamp format detection:
|
||||
data[11] == 0x10 → 10-byte sub_code=0x03 (continuous) format
|
||||
data[12] == 0x10 → 9-byte sub_code=0x10 (single-shot) format
|
||||
|
||||
In contrast to Event (triggered records, type 0x46), MonitorLogEntry
|
||||
records do NOT have a waveform record (SUB 0x0C) or bulk waveform stream
|
||||
(SUB 5A). All available metadata is in the 0x0A header alone.
|
||||
"""
|
||||
index: int # 0-based position in device record list
|
||||
key: str # 8-hex event key (e.g. "01114290") ✅
|
||||
|
||||
start_time: Optional[datetime.datetime] = None # monitoring session start ✅
|
||||
stop_time: Optional[datetime.datetime] = None # monitoring session stop ✅
|
||||
serial: Optional[str] = None # device serial (e.g. "BE11529") ✅
|
||||
geo_threshold_ips: Optional[float] = None # trigger level from "Geo: X.XXX in/s" ✅
|
||||
|
||||
# Raw bytes for debugging / future decoding
|
||||
raw_header: Optional[bytes] = field(default=None, repr=False)
|
||||
|
||||
@property
|
||||
def duration_seconds(self) -> Optional[float]:
|
||||
"""Duration of monitoring interval in seconds, or None if times unavailable."""
|
||||
if self.start_time and self.stop_time:
|
||||
return (self.stop_time - self.start_time).total_seconds()
|
||||
return None
|
||||
|
||||
def __str__(self) -> str:
|
||||
start = self.start_time.isoformat() if self.start_time else "?"
|
||||
stop = self.stop_time.isoformat() if self.stop_time else "?"
|
||||
dur = f" ({self.duration_seconds:.0f}s)" if self.duration_seconds is not None else ""
|
||||
return f"MonitorLog#{self.index} key={self.key} {start}→{stop}{dur}"
|
||||
|
||||
|
||||
# ── MonitorStatus ─────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class MonitorStatus:
|
||||
"""
|
||||
Current monitoring state decoded from SUB 0x1C response.
|
||||
|
||||
Confirmed field locations from 4-8-26/2ndtry BW capture:
|
||||
battery_v : data[11 + 0x2F : 11 + 0x31] uint16 BE ÷ 100 e.g. 680 → 6.80 V
|
||||
memory_total: data[11 + 0x31 : 11 + 0x35] uint32 BE bytes e.g. 983040 → 960 KB
|
||||
memory_free : data[11 + 0x35 : 11 + 0x39] uint32 BE bytes (subset of total)
|
||||
is_monitoring: inferred from payload length — idle = 44 bytes, monitoring = 12 bytes
|
||||
"""
|
||||
is_monitoring: bool # True if unit is actively recording ✅
|
||||
battery_v: Optional[float] = None # Battery voltage in volts ✅
|
||||
memory_total: Optional[int] = None # Total flash memory in bytes ✅
|
||||
memory_free: Optional[int] = None # Free flash memory in bytes ✅
|
||||
|
||||
+992
-46
File diff suppressed because it is too large
Load Diff
@@ -418,3 +418,39 @@ class TcpTransport(BaseTransport):
|
||||
def __repr__(self) -> str:
|
||||
state = "connected" if self.is_connected else "disconnected"
|
||||
return f"TcpTransport({self.host!r}, port={self.port}, {state})"
|
||||
|
||||
|
||||
# ── Inbound / accepted-socket transport ───────────────────────────────────────
|
||||
|
||||
class SocketTransport(TcpTransport):
|
||||
"""
|
||||
Like TcpTransport but wraps an already-accepted inbound socket.
|
||||
|
||||
Used by the ACH inbound server (bridges/ach_server.py) — the device dials
|
||||
IN to us, so by the time we create this transport the socket is already live.
|
||||
connect() is a no-op; everything else (read, write, read_until_idle, …) is
|
||||
inherited unchanged from TcpTransport.
|
||||
|
||||
Args:
|
||||
sock: An already-connected socket.socket returned by server_socket.accept().
|
||||
peer: Human-readable peer label for repr / logging (e.g. "203.0.113.5:54321").
|
||||
"""
|
||||
|
||||
def __init__(self, sock: socket.socket, peer: str = "inbound") -> None:
|
||||
# Bypass TcpTransport.__init__ — we already have a live socket.
|
||||
self.host = peer
|
||||
self.port = 0
|
||||
self.connect_timeout = 0.0
|
||||
self._sock = sock
|
||||
sock.settimeout(self._RECV_TIMEOUT)
|
||||
|
||||
def connect(self) -> None:
|
||||
"""No-op — socket was already accepted inbound."""
|
||||
pass # Already have a live socket; nothing to open.
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
return self._sock is not None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"SocketTransport(peer={self.host!r})"
|
||||
|
||||
+28
-10
@@ -33,7 +33,7 @@ STX = 0x02
|
||||
ETX = 0x03
|
||||
ACK = 0x41
|
||||
|
||||
__version__ = "0.2.2"
|
||||
__version__ = "0.2.3"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -227,17 +227,32 @@ def parse_s3(blob: bytes, trailer_len: int) -> List[Frame]:
|
||||
trailer_end = trailer_start + trailer_len
|
||||
trailer = blob[trailer_start:trailer_end]
|
||||
|
||||
# For S3 mode we don't assume checksum type here yet.
|
||||
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
|
||||
|
||||
frames.append(Frame(
|
||||
index=idx,
|
||||
start_offset=start_offset,
|
||||
end_offset=end_offset,
|
||||
payload_raw=bytes(body),
|
||||
payload=bytes(body),
|
||||
payload=payload,
|
||||
trailer=trailer,
|
||||
checksum_valid=None,
|
||||
checksum_type=None,
|
||||
checksum_hex=None
|
||||
checksum_valid=chk_valid,
|
||||
checksum_type=chk_type,
|
||||
checksum_hex=chk_hex
|
||||
))
|
||||
|
||||
idx += 1
|
||||
@@ -298,10 +313,13 @@ def parse_bw(blob: bytes, trailer_len: int, validate_checksum: bool) -> List[Fra
|
||||
|
||||
if b == ETX:
|
||||
# Candidate end-of-frame.
|
||||
# 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)
|
||||
# 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)
|
||||
|
||||
if not (next_is_start or at_eof):
|
||||
# Not a real boundary -> payload byte
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
poc_set_project.py — POC test for set_project_info() against a live MiniMate Plus.
|
||||
|
||||
Usage:
|
||||
python poc_set_project.py [--host IP] [--port PORT]
|
||||
|
||||
Default target: BE11529 at 63.43.212.232:9034
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger("poc_set_project")
|
||||
|
||||
from minimateplus import MiniMateClient
|
||||
from minimateplus.transport import TcpTransport
|
||||
|
||||
|
||||
DEFAULT_HOST = "63.43.212.232"
|
||||
DEFAULT_PORT = 9034
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser(description="POC: write project info to MiniMate Plus")
|
||||
ap.add_argument("--host", default=DEFAULT_HOST, help="Modem IP address")
|
||||
ap.add_argument("--port", type=int, default=DEFAULT_PORT, help="TCP port")
|
||||
ap.add_argument("--project", default="POC Write Test")
|
||||
ap.add_argument("--client-name", default="Terra-Mechanics Inc.")
|
||||
ap.add_argument("--operator", default="B. Harrison")
|
||||
ap.add_argument("--seis-loc", default="Lab Bench - POC")
|
||||
ap.add_argument("--notes", default="set_project_info POC 2026-04-07")
|
||||
args = ap.parse_args()
|
||||
|
||||
log.info("Connecting to %s:%d", args.host, args.port)
|
||||
transport = TcpTransport(args.host, port=args.port)
|
||||
|
||||
with MiniMateClient(transport=transport, timeout=60.0) as client:
|
||||
log.info("Performing POLL handshake + identity read …")
|
||||
info = client.connect()
|
||||
log.info("Connected: serial=%s firmware=%s", info.serial, info.firmware_version)
|
||||
|
||||
log.info("Calling set_project_info() …")
|
||||
client.set_project_info(
|
||||
project=args.project,
|
||||
client_name=args.client_name,
|
||||
operator=args.operator,
|
||||
seis_loc=args.seis_loc,
|
||||
notes=args.notes,
|
||||
)
|
||||
log.info("set_project_info() returned — write sequence complete")
|
||||
|
||||
log.info("Done. Reconnect Blastware to verify the fields were written.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
except Exception as exc:
|
||||
log.exception("Fatal: %s", exc)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,20 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "seismo-relay"
|
||||
version = "0.12.0"
|
||||
description = "Python client and REST server for MiniMate Plus seismographs"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.104",
|
||||
"uvicorn[standard]>=0.24",
|
||||
"pyserial>=3.5",
|
||||
"sqlalchemy>=2.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
# Auto-discovers minimateplus/, sfm/, bridges/ as packages
|
||||
where = ["."]
|
||||
include = ["minimateplus*", "sfm*", "bridges*"]
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
sqlalchemy
|
||||
pyserial
|
||||
+593
-34
@@ -97,16 +97,24 @@ class AnalyzerState:
|
||||
class BridgePanel(tk.Frame):
|
||||
"""
|
||||
All bridge controls and live log output.
|
||||
Calls on_bridge_started(raw_bw_path, raw_s3_path) when the bridge starts
|
||||
so the parent can wire up the Analyzer.
|
||||
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.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped, **kw):
|
||||
def __init__(self, parent: tk.Widget, on_bridge_started, on_bridge_stopped,
|
||||
on_capture_started=None, on_capture_complete=None, **kw):
|
||||
super().__init__(parent, bg=BG2, **kw)
|
||||
self._on_started = on_bridge_started # signature: (raw_bw, raw_s3, struct_bin)
|
||||
self._on_stopped = on_bridge_stopped
|
||||
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.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()
|
||||
|
||||
@@ -146,17 +154,7 @@ 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: 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
|
||||
# Row 2: buttons + status
|
||||
btn_row = tk.Frame(self, bg=BG2)
|
||||
btn_row.pack(side=tk.TOP, fill=tk.X, padx=4, pady=2)
|
||||
|
||||
@@ -170,6 +168,18 @@ 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")
|
||||
@@ -179,9 +189,34 @@ 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=18, font=MONO_SM,
|
||||
self, height=14, font=MONO_SM,
|
||||
bg=BG, fg=FG, insertbackground=FG,
|
||||
relief="flat", state="disabled",
|
||||
)
|
||||
@@ -221,14 +256,8 @@ class BridgePanel(tk.Frame):
|
||||
|
||||
args = [sys.executable, str(BRIDGE_PATH),
|
||||
"--bw", bw, "--s3", s3, "--baud", baud, "--logdir", logdir]
|
||||
|
||||
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]
|
||||
# Raw BW/S3 taps are NOT opened at bridge start.
|
||||
# Use "New Capture" to start a labeled tap on demand.
|
||||
|
||||
# Structured bin path — written by bridge automatically, named by ts
|
||||
struct_bin_path = os.path.join(logdir, f"s3_session_{ts}.bin")
|
||||
@@ -250,11 +279,12 @@ 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.mark_btn.configure(state="normal")
|
||||
self.cap_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 so Analyzer can wire up live mode
|
||||
self._on_started(raw_bw_path, raw_s3_path, struct_bin_path)
|
||||
# Notify parent — no raw files yet, just the structured bin path
|
||||
self._on_started(struct_bin_path)
|
||||
|
||||
def stop_bridge(self) -> None:
|
||||
if self.process and self.process.poll() is None:
|
||||
@@ -270,7 +300,11 @@ 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:
|
||||
@@ -288,12 +322,120 @@ 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
|
||||
@@ -1071,6 +1213,398 @@ class AnalyzerPanel(tk.Frame):
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Serial Watch panel — tap the RS-232 line between device and modem
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
import serial as _serial
|
||||
from serial.tools import list_ports as _list_ports
|
||||
_SERIAL_OK = True
|
||||
except ImportError:
|
||||
_SERIAL_OK = False
|
||||
|
||||
from minimateplus.framing import S3FrameParser as _S3FrameParser # noqa: E402
|
||||
|
||||
_SW_KNOWN_SUBS = {
|
||||
0xA4: "POLL_RSP", 0xA5: "BULK_WAVEFORM_RSP", 0xE0: "ADV_EVENT_RSP",
|
||||
0xE1: "EVT_IDX_FIRST_RSP", 0xE3: "MONITOR_STATUS_RSP", 0xEA: "SERIAL_NUM_RSP",
|
||||
0xF3: "WAVEFORM_REC_RSP", 0xF5: "WAVEFORM_HDR_RSP", 0xF7: "EVENT_INDEX_RSP",
|
||||
0xF9: "UNK_06_RSP", 0xFE: "DEVICE_INFO_RSP",
|
||||
0x69: "START_MON_ACK", 0x68: "STOP_MON_ACK",
|
||||
}
|
||||
|
||||
|
||||
class SerialWatchPanel(tk.Frame):
|
||||
"""
|
||||
Tap the RS-232 line between the MiniMate Plus and its modem (RV50/RV55).
|
||||
Runs the serial reader in a background thread; surfaces parsed S3 frames
|
||||
live in the log view. Writes raw_s3_<ts>.bin compatible with Analyzer.
|
||||
|
||||
Typical use for call-home capture:
|
||||
1. Connect a USB-to-serial tap to the RS-232 line.
|
||||
2. Pick that COM port here, click Start.
|
||||
3. Wait for the unit to trigger / call home.
|
||||
4. Click Stop, then 'Open in Analyzer' to inspect the frames.
|
||||
"""
|
||||
|
||||
_COL_FRAME = "#4ec9b0" # teal — parsed S3 frame
|
||||
_COL_CTRL = "#dcdcaa" # yellow — control-line change
|
||||
_COL_AT = "#9cdcfe" # blue — AT command / ASCII noise
|
||||
_COL_ERR = "#f44747" # red — error
|
||||
|
||||
def __init__(self, parent: tk.Widget, on_capture_ready=None, **kw):
|
||||
"""
|
||||
on_capture_ready(raw_s3_path: str) — called when capture stops,
|
||||
so the parent can inject the file into the Analyzer.
|
||||
"""
|
||||
super().__init__(parent, bg=BG2, **kw)
|
||||
self._on_capture_ready = on_capture_ready
|
||||
self._serial: Optional[object] = None # serial.Serial instance
|
||||
self._reader_thread: Optional[threading.Thread] = None
|
||||
self._stop_evt = threading.Event()
|
||||
self._log_q: queue.Queue[tuple[str, str]] = queue.Queue() # (text, colour)
|
||||
self._raw_fh = None # open binary file handle
|
||||
self._raw_path: Optional[str] = None
|
||||
self._frame_count = 0
|
||||
self._build()
|
||||
self._poll_log_queue()
|
||||
|
||||
# ── build ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _build(self) -> None:
|
||||
pad = {"padx": 6, "pady": 4}
|
||||
|
||||
cfg = tk.Frame(self, bg=BG2)
|
||||
cfg.pack(side=tk.TOP, fill=tk.X, padx=4, pady=4)
|
||||
|
||||
# Row 0 — port picker
|
||||
tk.Label(cfg, text="COM port:", bg=BG2, fg=FG, font=MONO
|
||||
).grid(row=0, column=0, sticky="e", **pad)
|
||||
|
||||
self._port_var = tk.StringVar()
|
||||
self._port_cb = ttk.Combobox(cfg, textvariable=self._port_var,
|
||||
width=12, font=MONO, state="normal")
|
||||
self._port_cb.grid(row=0, column=1, sticky="w", **pad)
|
||||
|
||||
tk.Button(cfg, text="↺", bg=BG3, fg=FG, relief="flat", cursor="hand2",
|
||||
font=MONO, command=self._refresh_ports
|
||||
).grid(row=0, column=2, **pad)
|
||||
|
||||
tk.Label(cfg, text=" Baud:", bg=BG2, fg=FG, font=MONO
|
||||
).grid(row=0, column=3, sticky="e", **pad)
|
||||
self._baud_var = tk.StringVar(value="38400")
|
||||
tk.Entry(cfg, textvariable=self._baud_var, width=8,
|
||||
bg=BG3, fg=FG, insertbackground=FG, relief="flat", font=MONO
|
||||
).grid(row=0, column=4, sticky="w", **pad)
|
||||
|
||||
self._ack_ok_var = tk.BooleanVar(value=False)
|
||||
tk.Checkbutton(cfg, text="Ack OK to AT commands",
|
||||
variable=self._ack_ok_var,
|
||||
bg=BG2, fg=FG, selectcolor=BG3, activebackground=BG2,
|
||||
font=MONO).grid(row=0, column=5, sticky="w", **pad)
|
||||
|
||||
# Row 1 — capture dir
|
||||
tk.Label(cfg, text="Save to:", bg=BG2, fg=FG, font=MONO
|
||||
).grid(row=1, column=0, sticky="e", **pad)
|
||||
self._dir_var = tk.StringVar(
|
||||
value=str(SCRIPT_DIR / "bridges" / "captures"))
|
||||
tk.Entry(cfg, textvariable=self._dir_var, width=40,
|
||||
bg=BG3, fg=FG, insertbackground=FG, relief="flat", font=MONO
|
||||
).grid(row=1, column=1, columnspan=4, sticky="we", **pad)
|
||||
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)
|
||||
|
||||
# Button row
|
||||
btn_row = tk.Frame(self, bg=BG2)
|
||||
btn_row.pack(side=tk.TOP, fill=tk.X, padx=4, pady=2)
|
||||
|
||||
self._start_btn = tk.Button(
|
||||
btn_row, text="Start Watch", bg=GREEN, fg="#000000",
|
||||
relief="flat", padx=12, cursor="hand2", font=MONO_B,
|
||||
command=self._start)
|
||||
self._start_btn.pack(side=tk.LEFT, padx=6)
|
||||
|
||||
self._stop_btn = tk.Button(
|
||||
btn_row, text="Stop", bg=BG3, fg=FG,
|
||||
relief="flat", padx=12, cursor="hand2", font=MONO,
|
||||
command=self._stop, state="disabled")
|
||||
self._stop_btn.pack(side=tk.LEFT, padx=4)
|
||||
|
||||
self._analyzer_btn = tk.Button(
|
||||
btn_row, text="Open in Analyzer", bg=BG3, fg=FG,
|
||||
relief="flat", padx=10, cursor="hand2", font=MONO,
|
||||
command=self._send_to_analyzer, state="disabled")
|
||||
self._analyzer_btn.pack(side=tk.LEFT, padx=4)
|
||||
|
||||
tk.Button(btn_row, text="Clear", bg=BG3, fg=FG,
|
||||
relief="flat", padx=8, cursor="hand2", font=MONO,
|
||||
command=self._clear_log).pack(side=tk.LEFT, padx=4)
|
||||
|
||||
self._status_var = tk.StringVar(value="Idle")
|
||||
tk.Label(btn_row, textvariable=self._status_var,
|
||||
bg=BG2, fg=FG_DIM, font=MONO).pack(side=tk.LEFT, padx=10)
|
||||
|
||||
# Log view
|
||||
self._log = scrolledtext.ScrolledText(
|
||||
self, height=24, font=MONO_SM,
|
||||
bg=BG, fg=FG, insertbackground=FG,
|
||||
relief="flat", state="disabled",
|
||||
)
|
||||
self._log.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
|
||||
self._log.tag_config("frame", foreground=self._COL_FRAME)
|
||||
self._log.tag_config("ctrl", foreground=self._COL_CTRL)
|
||||
self._log.tag_config("at", foreground=self._COL_AT)
|
||||
self._log.tag_config("err", foreground=self._COL_ERR)
|
||||
self._log.tag_config("dim", foreground=FG_DIM)
|
||||
|
||||
# Populate ports on first load
|
||||
self._refresh_ports()
|
||||
|
||||
# ── port helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _refresh_ports(self) -> None:
|
||||
if not _SERIAL_OK:
|
||||
self._port_cb["values"] = ["(pyserial not installed)"]
|
||||
return
|
||||
ports = [p.device for p in _list_ports.comports()]
|
||||
self._port_cb["values"] = ports
|
||||
if ports and not self._port_var.get():
|
||||
self._port_var.set(ports[0])
|
||||
|
||||
def _choose_dir(self) -> None:
|
||||
d = filedialog.askdirectory(initialdir=self._dir_var.get())
|
||||
if d:
|
||||
self._dir_var.set(d)
|
||||
|
||||
# ── start / stop ──────────────────────────────────────────────────────
|
||||
|
||||
def _start(self) -> None:
|
||||
if not _SERIAL_OK:
|
||||
messagebox.showerror(
|
||||
"pyserial missing",
|
||||
"Install pyserial first:\n pip install pyserial")
|
||||
return
|
||||
|
||||
port = self._port_var.get().strip()
|
||||
if not port or "not installed" in port:
|
||||
messagebox.showerror("Error", "Select a valid COM port first.")
|
||||
return
|
||||
|
||||
try:
|
||||
baud = int(self._baud_var.get().strip())
|
||||
except ValueError:
|
||||
messagebox.showerror("Error", "Invalid baud rate.")
|
||||
return
|
||||
|
||||
# Open output files
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
out_dir = Path(self._dir_var.get()) / f"serial_{ts}"
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._raw_path = str(out_dir / f"raw_s3_{ts}.bin")
|
||||
try:
|
||||
self._raw_fh = open(self._raw_path, "wb")
|
||||
except OSError as exc:
|
||||
messagebox.showerror("Error", f"Cannot open capture file:\n{exc}")
|
||||
return
|
||||
|
||||
# Open serial port
|
||||
try:
|
||||
ser = _serial.Serial(
|
||||
port=port, baudrate=baud,
|
||||
bytesize=8, parity=_serial.PARITY_NONE,
|
||||
stopbits=_serial.STOPBITS_ONE,
|
||||
timeout=0.05, write_timeout=0,
|
||||
)
|
||||
ser.setDTR(True)
|
||||
ser.setRTS(True)
|
||||
except Exception as exc:
|
||||
self._raw_fh.close()
|
||||
self._raw_fh = None
|
||||
messagebox.showerror("Error", f"Cannot open {port}:\n{exc}")
|
||||
return
|
||||
|
||||
self._serial = ser
|
||||
self._stop_evt.clear()
|
||||
self._frame_count = 0
|
||||
self._analyzer_btn.configure(state="disabled")
|
||||
|
||||
self._reader_thread = threading.Thread(
|
||||
target=self._reader_loop,
|
||||
args=(ser, baud),
|
||||
daemon=True,
|
||||
)
|
||||
self._reader_thread.start()
|
||||
|
||||
self._status_var.set(f"Watching {port} @ {baud}")
|
||||
self._start_btn.configure(state="disabled")
|
||||
self._stop_btn.configure(state="normal", bg=RED)
|
||||
self._append(f"── Serial watch started {port} @ {baud} [{ts}] ──\n", "dim")
|
||||
self._append(f" Capture: {self._raw_path}\n", "dim")
|
||||
self._append(" Waiting for data…\n\n", "dim")
|
||||
|
||||
def _stop(self) -> None:
|
||||
self._stop_evt.set()
|
||||
if self._serial:
|
||||
try:
|
||||
self._serial.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._serial = None
|
||||
if self._raw_fh:
|
||||
self._raw_fh.close()
|
||||
self._raw_fh = None
|
||||
self._status_var.set("Stopped")
|
||||
self._start_btn.configure(state="normal")
|
||||
self._stop_btn.configure(state="disabled", bg=BG3)
|
||||
if self._raw_path and Path(self._raw_path).exists():
|
||||
self._analyzer_btn.configure(state="normal")
|
||||
self._append("\n── Watch stopped ──\n", "dim")
|
||||
|
||||
# ── reader thread ─────────────────────────────────────────────────────
|
||||
|
||||
def _reader_loop(self, ser, baud: int) -> None:
|
||||
parser = _S3FrameParser()
|
||||
rx_buf = bytearray()
|
||||
ack_ok = self._ack_ok_var.get()
|
||||
|
||||
# Monitor control lines in a sub-thread
|
||||
ctrl_stop = threading.Event()
|
||||
ctrl_thread = threading.Thread(
|
||||
target=self._ctrl_loop, args=(ser, ctrl_stop), daemon=True)
|
||||
ctrl_thread.start()
|
||||
|
||||
try:
|
||||
while not self._stop_evt.is_set():
|
||||
try:
|
||||
data = ser.read(4096)
|
||||
except Exception as exc:
|
||||
self._log_q.put((f"Read error: {exc}\n", "err"))
|
||||
break
|
||||
|
||||
if not data:
|
||||
continue
|
||||
|
||||
# Save raw bytes
|
||||
if self._raw_fh:
|
||||
try:
|
||||
self._raw_fh.write(data)
|
||||
self._raw_fh.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Parse S3 frames
|
||||
for byte in data:
|
||||
result = parser.feed(bytes([byte]))
|
||||
if result:
|
||||
frames = result if isinstance(result, list) else [result]
|
||||
for f in frames:
|
||||
self._frame_count += 1
|
||||
name = _SW_KNOWN_SUBS.get(f.sub, f"UNK_0x{f.sub:02X}")
|
||||
chk = "✓" if f.checksum_valid else "✗ BAD_CHK"
|
||||
peek = f.data[:32].hex() + ("…" if len(f.data) > 32 else "")
|
||||
msg = (
|
||||
f"[{self._frame_count:04d}] "
|
||||
f"SUB=0x{f.sub:02X} ({name:<22}) "
|
||||
f"page=0x{f.page_key:04X} "
|
||||
f"data={len(f.data):4d}B {chk}\n"
|
||||
f" {peek}\n"
|
||||
)
|
||||
self._log_q.put((msg, "frame"))
|
||||
|
||||
# AT command handling for --ack-ok mode
|
||||
if ack_ok:
|
||||
rx_buf.extend(data)
|
||||
while b"\r" in rx_buf or b"\n" in rx_buf:
|
||||
for sep in (b"\r", b"\n"):
|
||||
idx = rx_buf.find(sep)
|
||||
if idx != -1:
|
||||
line_bytes = bytes(rx_buf[:idx])
|
||||
del rx_buf[:idx + 1]
|
||||
break
|
||||
else:
|
||||
break
|
||||
line_str = line_bytes.decode("latin1", errors="ignore").strip()
|
||||
if line_str.upper().startswith("AT"):
|
||||
self._log_q.put((f"AT: {line_str!r}\n", "at"))
|
||||
if not line_str.upper().startswith("ATDT"):
|
||||
try:
|
||||
ser.write(b"\r\nOK\r\n")
|
||||
ser.flush()
|
||||
self._log_q.put((f" → OK\n", "at"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
finally:
|
||||
ctrl_stop.set()
|
||||
ctrl_thread.join(timeout=0.5)
|
||||
# Signal the main thread that the reader ended naturally
|
||||
if not self._stop_evt.is_set():
|
||||
self._log_q.put(("<<done>>", ""))
|
||||
|
||||
def _ctrl_loop(self, ser, stop: threading.Event) -> None:
|
||||
prev = {}
|
||||
try:
|
||||
prev = dict(CTS=ser.cts, DSR=ser.dsr, DCD=ser.cd)
|
||||
try:
|
||||
prev["RI"] = ser.ri
|
||||
except Exception:
|
||||
prev["RI"] = None
|
||||
except Exception:
|
||||
return
|
||||
|
||||
while not stop.is_set():
|
||||
try:
|
||||
cur = dict(CTS=ser.cts, DSR=ser.dsr, DCD=ser.cd, RI=None)
|
||||
try:
|
||||
cur["RI"] = ser.ri
|
||||
except Exception:
|
||||
pass
|
||||
for name, val in cur.items():
|
||||
if val != prev.get(name):
|
||||
self._log_q.put((f"CTRL {name} → {val}\n", "ctrl"))
|
||||
prev[name] = val
|
||||
except Exception:
|
||||
break
|
||||
stop.wait(0.2)
|
||||
|
||||
# ── log view ──────────────────────────────────────────────────────────
|
||||
|
||||
def _poll_log_queue(self) -> None:
|
||||
try:
|
||||
while True:
|
||||
text, tag = self._log_q.get_nowait()
|
||||
if text == "<<done>>":
|
||||
self._stop()
|
||||
break
|
||||
self._append(text, tag)
|
||||
except queue.Empty:
|
||||
pass
|
||||
finally:
|
||||
self.after(80, self._poll_log_queue)
|
||||
|
||||
def _append(self, text: str, tag: str = "") -> None:
|
||||
self._log.configure(state="normal")
|
||||
if tag:
|
||||
self._log.insert(tk.END, text, tag)
|
||||
else:
|
||||
self._log.insert(tk.END, text)
|
||||
self._log.see(tk.END)
|
||||
self._log.configure(state="disabled")
|
||||
|
||||
def _clear_log(self) -> None:
|
||||
self._log.configure(state="normal")
|
||||
self._log.delete("1.0", tk.END)
|
||||
self._log.configure(state="disabled")
|
||||
|
||||
# ── send to analyzer ──────────────────────────────────────────────────
|
||||
|
||||
def _send_to_analyzer(self) -> None:
|
||||
if self._raw_path and self._on_capture_ready:
|
||||
self._on_capture_ready(self._raw_path)
|
||||
|
||||
|
||||
# Console panel (tk.Frame — lives inside a notebook tab)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1492,6 +2026,8 @@ 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 ")
|
||||
|
||||
@@ -1504,26 +2040,49 @@ class SeismoLab(tk.Tk):
|
||||
)
|
||||
nb.add(self._console_panel, text=" Console ")
|
||||
|
||||
self._serial_watch_panel = SerialWatchPanel(
|
||||
nb,
|
||||
on_capture_ready=self._on_serial_capture_ready,
|
||||
)
|
||||
nb.add(self._serial_watch_panel, text=" Serial Watch ")
|
||||
|
||||
self._nb = nb
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
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_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_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)
|
||||
self._nb.select(1)
|
||||
|
||||
def _on_serial_capture_ready(self, raw_s3_path: str) -> None:
|
||||
"""Serial Watch capture finished → inject into Analyzer and switch tab."""
|
||||
self._analyzer_panel.s3_var.set(raw_s3_path)
|
||||
self._nb.select(1)
|
||||
|
||||
def _on_close(self) -> None:
|
||||
self._bridge_panel.stop_bridge()
|
||||
self._serial_watch_panel._stop()
|
||||
self.destroy()
|
||||
|
||||
|
||||
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
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
|
||||
+486
@@ -0,0 +1,486 @@
|
||||
"""
|
||||
sfm/database.py — SQLite persistence layer for seismo-relay.
|
||||
|
||||
Three tables, all keyed by unit serial number:
|
||||
|
||||
ach_sessions — one row per inbound ACH call-home
|
||||
events — one row per triggered waveform event (deduped by serial+timestamp)
|
||||
monitor_log — one row per monitoring interval (deduped by serial+start_time)
|
||||
|
||||
The DB file lives at:
|
||||
<output_dir>/seismo_relay.db (default: bridges/captures/seismo_relay.db)
|
||||
|
||||
Usage
|
||||
-----
|
||||
from sfm.database import SeismoDb
|
||||
|
||||
db = SeismoDb("bridges/captures/seismo_relay.db")
|
||||
|
||||
# Write a call-home session
|
||||
session_id = db.insert_ach_session(serial="BE11529", peer="1.2.3.4:51920",
|
||||
events_downloaded=3, monitor_entries=2,
|
||||
duration_seconds=47.3)
|
||||
|
||||
# Write events (silently skips duplicates)
|
||||
db.insert_events(events, serial="BE11529", session_id=session_id)
|
||||
|
||||
# Write monitor log entries
|
||||
db.insert_monitor_log(entries, session_id=session_id)
|
||||
|
||||
# Query
|
||||
rows = db.query_events(serial="BE11529", from_dt=datetime(...), to_dt=datetime(...))
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import sqlite3
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from minimateplus.models import Event, MonitorLogEntry
|
||||
|
||||
log = logging.getLogger("sfm.database")
|
||||
|
||||
# ── Schema ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
_SCHEMA = """
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ach_sessions (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
serial TEXT NOT NULL,
|
||||
session_time TEXT NOT NULL, -- ISO-8601 UTC
|
||||
peer TEXT, -- "ip:port"
|
||||
events_downloaded INTEGER NOT NULL DEFAULT 0,
|
||||
monitor_entries INTEGER NOT NULL DEFAULT 0,
|
||||
duration_seconds REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ach_sessions_serial ON ach_sessions(serial);
|
||||
CREATE INDEX IF NOT EXISTS idx_ach_sessions_time ON ach_sessions(session_time);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
serial TEXT NOT NULL,
|
||||
waveform_key TEXT NOT NULL, -- 8-hex device key (dedup field)
|
||||
session_id TEXT, -- FK → ach_sessions.id
|
||||
timestamp TEXT, -- ISO-8601 local time from device
|
||||
tran_ppv REAL, -- in/s
|
||||
vert_ppv REAL, -- in/s
|
||||
long_ppv REAL, -- in/s
|
||||
peak_vector_sum REAL, -- in/s
|
||||
mic_ppv REAL, -- psi or dB depending on setup
|
||||
project TEXT,
|
||||
client TEXT,
|
||||
operator TEXT,
|
||||
sensor_location TEXT,
|
||||
sample_rate INTEGER,
|
||||
record_type TEXT, -- "single_shot" | "continuous"
|
||||
false_trigger INTEGER NOT NULL DEFAULT 0, -- 0=no, 1=yes (manual flag)
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(serial, timestamp)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_serial ON events(serial);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS monitor_log (
|
||||
id TEXT PRIMARY KEY, -- UUID
|
||||
serial TEXT NOT NULL,
|
||||
waveform_key TEXT NOT NULL, -- 8-hex device key (dedup field)
|
||||
session_id TEXT, -- FK → ach_sessions.id
|
||||
start_time TEXT, -- ISO-8601
|
||||
stop_time TEXT, -- ISO-8601
|
||||
duration_seconds REAL,
|
||||
geo_threshold_ips REAL, -- in/s
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(serial, start_time)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_log_serial ON monitor_log(serial);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_log_start ON monitor_log(start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_log_session ON monitor_log(session_id);
|
||||
"""
|
||||
|
||||
|
||||
# ── SeismoDb class ─────────────────────────────────────────────────────────────
|
||||
|
||||
class SeismoDb:
|
||||
"""
|
||||
Thin SQLite wrapper for seismo-relay persistence.
|
||||
|
||||
Thread-safe: each call opens, uses, and closes a connection with
|
||||
check_same_thread=False and WAL mode enabled. For the ACH server's
|
||||
single-writer / occasional-reader pattern this is more than sufficient.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | Path) -> None:
|
||||
self.db_path = Path(db_path)
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._init_schema()
|
||||
log.info("SeismoDb initialised at %s", self.db_path)
|
||||
|
||||
# ── Internal helpers ───────────────────────────────────────────────────────
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with self._connect() as conn:
|
||||
conn.executescript(_SCHEMA)
|
||||
self._migrate(conn)
|
||||
|
||||
def _migrate(self, conn: sqlite3.Connection) -> None:
|
||||
"""Apply in-place schema migrations for existing databases."""
|
||||
|
||||
# Migration 1: change events UNIQUE from (serial, waveform_key) [or any
|
||||
# waveform_key-based variant] to (serial, timestamp).
|
||||
# Rationale: device key counter resets to 01110000 after every erase, so
|
||||
# waveform_key is not a stable dedup field across erase cycles. The event
|
||||
# timestamp (from the device clock) is the correct natural key.
|
||||
row = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='events'"
|
||||
).fetchone()
|
||||
if row and "UNIQUE(serial, timestamp)" not in row[0]:
|
||||
log.info("_migrate: rebuilding events table — UNIQUE(serial, timestamp)")
|
||||
conn.executescript("""
|
||||
ALTER TABLE events RENAME TO events_old;
|
||||
|
||||
CREATE TABLE events (
|
||||
id TEXT PRIMARY KEY,
|
||||
serial TEXT NOT NULL,
|
||||
waveform_key TEXT NOT NULL,
|
||||
session_id TEXT,
|
||||
timestamp TEXT,
|
||||
tran_ppv REAL,
|
||||
vert_ppv REAL,
|
||||
long_ppv REAL,
|
||||
peak_vector_sum REAL,
|
||||
mic_ppv REAL,
|
||||
project TEXT,
|
||||
client TEXT,
|
||||
operator TEXT,
|
||||
sensor_location TEXT,
|
||||
sample_rate INTEGER,
|
||||
record_type TEXT,
|
||||
false_trigger INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(serial, timestamp)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO events SELECT * FROM events_old;
|
||||
DROP TABLE events_old;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_serial ON events(serial);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
|
||||
""")
|
||||
log.info("_migrate: events table rebuilt OK")
|
||||
|
||||
# Migration 2: change monitor_log UNIQUE from (serial, waveform_key) to
|
||||
# (serial, start_time) — same reasoning as events.
|
||||
row = conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND name='monitor_log'"
|
||||
).fetchone()
|
||||
if row and "UNIQUE(serial, start_time)" not in row[0]:
|
||||
log.info("_migrate: rebuilding monitor_log table — UNIQUE(serial, start_time)")
|
||||
conn.executescript("""
|
||||
ALTER TABLE monitor_log RENAME TO monitor_log_old;
|
||||
|
||||
CREATE TABLE monitor_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
serial TEXT NOT NULL,
|
||||
waveform_key TEXT NOT NULL,
|
||||
session_id TEXT,
|
||||
start_time TEXT,
|
||||
stop_time TEXT,
|
||||
duration_seconds REAL,
|
||||
geo_threshold_ips REAL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
UNIQUE(serial, start_time)
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO monitor_log SELECT * FROM monitor_log_old;
|
||||
DROP TABLE monitor_log_old;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_log_serial ON monitor_log(serial);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_log_start ON monitor_log(start_time);
|
||||
CREATE INDEX IF NOT EXISTS idx_monitor_log_session ON monitor_log(session_id);
|
||||
""")
|
||||
log.info("_migrate: monitor_log table rebuilt OK")
|
||||
|
||||
@staticmethod
|
||||
def _iso(dt: Optional[datetime.datetime]) -> Optional[str]:
|
||||
return dt.isoformat() if dt is not None else None
|
||||
|
||||
@staticmethod
|
||||
def _new_id() -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
# ── ACH sessions ──────────────────────────────────────────────────────────
|
||||
|
||||
def insert_ach_session(
|
||||
self,
|
||||
*,
|
||||
serial: str,
|
||||
peer: Optional[str] = None,
|
||||
events_downloaded: int = 0,
|
||||
monitor_entries: int = 0,
|
||||
duration_seconds: Optional[float] = None,
|
||||
session_time: Optional[datetime.datetime] = None,
|
||||
) -> str:
|
||||
"""Insert a new ACH session row. Returns the new session UUID."""
|
||||
sid = self._new_id()
|
||||
ts = self._iso(session_time or datetime.datetime.utcnow())
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO ach_sessions
|
||||
(id, serial, session_time, peer,
|
||||
events_downloaded, monitor_entries, duration_seconds)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(sid, serial, ts, peer,
|
||||
events_downloaded, monitor_entries, duration_seconds),
|
||||
)
|
||||
log.debug("ach_session inserted: %s serial=%s events=%d monitor=%d",
|
||||
sid, serial, events_downloaded, monitor_entries)
|
||||
return sid
|
||||
|
||||
def get_sessions(
|
||||
self,
|
||||
serial: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
) -> list[dict]:
|
||||
"""Return recent ACH sessions, newest first."""
|
||||
with self._connect() as conn:
|
||||
if serial:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM ach_sessions WHERE serial=? "
|
||||
"ORDER BY session_time DESC LIMIT ?",
|
||||
(serial, limit),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM ach_sessions ORDER BY session_time DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
def insert_events(
|
||||
self,
|
||||
events: list[Event],
|
||||
*,
|
||||
serial: str,
|
||||
session_id: Optional[str] = None,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Insert triggered events. Silently skips duplicates (serial+timestamp).
|
||||
Returns (inserted, skipped).
|
||||
"""
|
||||
inserted = skipped = 0
|
||||
with self._connect() as conn:
|
||||
for ev in events:
|
||||
key = ev._waveform_key.hex() if ev._waveform_key else None
|
||||
if key is None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
ts = None
|
||||
if ev.timestamp:
|
||||
try:
|
||||
ts = datetime.datetime(
|
||||
ev.timestamp.year, ev.timestamp.month, ev.timestamp.day,
|
||||
ev.timestamp.hour, ev.timestamp.minute, ev.timestamp.second,
|
||||
).isoformat()
|
||||
except Exception:
|
||||
ts = str(ev.timestamp)
|
||||
|
||||
pv = ev.peak_values
|
||||
pi = ev.project_info
|
||||
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO events
|
||||
(id, serial, waveform_key, session_id, timestamp,
|
||||
tran_ppv, vert_ppv, long_ppv, peak_vector_sum, mic_ppv,
|
||||
project, client, operator, sensor_location,
|
||||
sample_rate, record_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
self._new_id(), serial, key, session_id, ts,
|
||||
pv.tran if pv else None,
|
||||
pv.vert if pv else None,
|
||||
pv.long if pv else None,
|
||||
pv.peak_vector_sum if pv else None,
|
||||
pv.micl if pv else None,
|
||||
pi.project if pi else None,
|
||||
pi.client if pi else None,
|
||||
pi.operator if pi else None,
|
||||
pi.sensor_location if pi else None,
|
||||
ev.sample_rate,
|
||||
ev.record_type,
|
||||
),
|
||||
)
|
||||
inserted += 1
|
||||
except sqlite3.IntegrityError:
|
||||
skipped += 1
|
||||
|
||||
log.debug("insert_events serial=%s inserted=%d skipped=%d",
|
||||
serial, inserted, skipped)
|
||||
return inserted, skipped
|
||||
|
||||
def query_events(
|
||||
self,
|
||||
serial: Optional[str] = None,
|
||||
from_dt: Optional[datetime.datetime] = None,
|
||||
to_dt: Optional[datetime.datetime] = None,
|
||||
false_trigger: Optional[bool] = None,
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Query events with optional filters. Returns newest first."""
|
||||
clauses: list[str] = []
|
||||
params: list = []
|
||||
|
||||
if serial:
|
||||
clauses.append("serial = ?")
|
||||
params.append(serial)
|
||||
if from_dt:
|
||||
clauses.append("timestamp >= ?")
|
||||
params.append(from_dt.isoformat())
|
||||
if to_dt:
|
||||
clauses.append("timestamp <= ?")
|
||||
params.append(to_dt.isoformat())
|
||||
if false_trigger is not None:
|
||||
clauses.append("false_trigger = ?")
|
||||
params.append(1 if false_trigger else 0)
|
||||
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
params += [limit, offset]
|
||||
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM events {where} "
|
||||
f"ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
params,
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def set_false_trigger(self, event_id: str, value: bool) -> bool:
|
||||
"""Set or clear the false_trigger flag on an event. Returns True if found."""
|
||||
with self._connect() as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE events SET false_trigger=? WHERE id=?",
|
||||
(1 if value else 0, event_id),
|
||||
)
|
||||
return cur.rowcount > 0
|
||||
|
||||
# ── Monitor log ───────────────────────────────────────────────────────────
|
||||
|
||||
def insert_monitor_log(
|
||||
self,
|
||||
entries: list[MonitorLogEntry],
|
||||
*,
|
||||
session_id: Optional[str] = None,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Insert monitor log entries. Silently skips duplicates (serial+start_time).
|
||||
Returns (inserted, skipped).
|
||||
"""
|
||||
inserted = skipped = 0
|
||||
with self._connect() as conn:
|
||||
for e in entries:
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO monitor_log
|
||||
(id, serial, waveform_key, session_id,
|
||||
start_time, stop_time, duration_seconds,
|
||||
geo_threshold_ips)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
self._new_id(),
|
||||
e.serial or "",
|
||||
e.key,
|
||||
session_id,
|
||||
self._iso(e.start_time),
|
||||
self._iso(e.stop_time),
|
||||
e.duration_seconds,
|
||||
e.geo_threshold_ips,
|
||||
),
|
||||
)
|
||||
inserted += 1
|
||||
except sqlite3.IntegrityError:
|
||||
skipped += 1
|
||||
|
||||
log.debug("insert_monitor_log inserted=%d skipped=%d", inserted, skipped)
|
||||
return inserted, skipped
|
||||
|
||||
def query_monitor_log(
|
||||
self,
|
||||
serial: Optional[str] = None,
|
||||
from_dt: Optional[datetime.datetime] = None,
|
||||
to_dt: Optional[datetime.datetime] = None,
|
||||
limit: int = 500,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
"""Query monitor log entries with optional filters. Returns newest first."""
|
||||
clauses: list[str] = []
|
||||
params: list = []
|
||||
|
||||
if serial:
|
||||
clauses.append("serial = ?")
|
||||
params.append(serial)
|
||||
if from_dt:
|
||||
clauses.append("start_time >= ?")
|
||||
params.append(from_dt.isoformat())
|
||||
if to_dt:
|
||||
clauses.append("start_time <= ?")
|
||||
params.append(to_dt.isoformat())
|
||||
|
||||
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||
params += [limit, offset]
|
||||
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM monitor_log {where} "
|
||||
f"ORDER BY start_time DESC LIMIT ? OFFSET ?",
|
||||
params,
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── Fleet overview ────────────────────────────────────────────────────────
|
||||
|
||||
def query_units(self) -> list[dict]:
|
||||
"""
|
||||
Return one row per known serial with summary stats:
|
||||
last_seen, total_events, total_monitor_entries.
|
||||
"""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
s.serial,
|
||||
MAX(s.session_time) AS last_seen,
|
||||
SUM(s.events_downloaded) AS total_events,
|
||||
SUM(s.monitor_entries) AS total_monitor_entries,
|
||||
COUNT(*) AS total_sessions
|
||||
FROM ach_sessions s
|
||||
GROUP BY s.serial
|
||||
ORDER BY last_seen DESC
|
||||
"""
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
+1139
-30
File diff suppressed because it is too large
Load Diff
+2228
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,602 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SFM Waveform Viewer</title>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
header {
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #f0f6fc;
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.conn-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
label { color: #8b949e; font-size: 12px; }
|
||||
|
||||
input[type="text"], input[type="number"] {
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #c9d1d9;
|
||||
padding: 5px 8px;
|
||||
font-size: 13px;
|
||||
width: 100px;
|
||||
}
|
||||
input[type="number"] { width: 70px; }
|
||||
input:focus { outline: none; border-color: #388bfd; }
|
||||
|
||||
button {
|
||||
background: #1f6feb;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 5px 14px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover { background: #388bfd; }
|
||||
button:active { background: #1158c7; }
|
||||
button:disabled { background: #21262d; color: #484f58; cursor: not-allowed; }
|
||||
|
||||
#status-bar {
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #21262d;
|
||||
padding: 5px 20px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
min-height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
#status-bar.error { color: #f85149; }
|
||||
#status-bar.ok { color: #3fb950; }
|
||||
#status-bar.loading { color: #d29922; }
|
||||
|
||||
.meta-pill {
|
||||
background: #21262d;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
color: #c9d1d9;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
#charts {
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
background: #161b22;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px 8px;
|
||||
}
|
||||
|
||||
.chart-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chart-canvas-wrap { position: relative; height: 130px; }
|
||||
|
||||
#empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 60vh;
|
||||
color: #484f58;
|
||||
gap: 8px;
|
||||
}
|
||||
#empty-state svg { opacity: 0.3; }
|
||||
#empty-state p { font-size: 14px; }
|
||||
|
||||
.ch-tran { color: #58a6ff; }
|
||||
.ch-vert { color: #3fb950; }
|
||||
.ch-long { color: #d29922; }
|
||||
.ch-mic { color: #bc8cff; }
|
||||
|
||||
#unit-bar {
|
||||
background: #0d1117;
|
||||
border-bottom: 1px solid #21262d;
|
||||
padding: 8px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.unit-field { display: flex; flex-direction: column; gap: 1px; }
|
||||
.unit-field .uf-label { color: #484f58; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.unit-field .uf-value { color: #c9d1d9; font-family: monospace; font-size: 13px; }
|
||||
.unit-field .uf-value.highlight { color: #58a6ff; font-weight: 600; }
|
||||
|
||||
.event-chips {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.event-chip {
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 5px;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.event-chip:hover { background: #1f6feb; border-color: #1f6feb; color: #fff; }
|
||||
.event-chip.active { background: #1f6feb; border-color: #388bfd; color: #fff; font-weight: 600; }
|
||||
|
||||
#connect-btn {
|
||||
background: #238636;
|
||||
margin-left: auto;
|
||||
}
|
||||
#connect-btn:hover { background: #2ea043; }
|
||||
#connect-btn:disabled { background: #21262d; color: #484f58; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>SFM Waveform Viewer</h1>
|
||||
<div class="conn-group">
|
||||
<label>API</label>
|
||||
<input type="text" id="api-base" style="width:180px" />
|
||||
</div>
|
||||
<div class="conn-group">
|
||||
<label>Device host</label>
|
||||
<input type="text" id="dev-host" value="" placeholder="e.g. 10.0.0.5" />
|
||||
<label>TCP port</label>
|
||||
<input type="number" id="dev-tcp-port" value="9034" />
|
||||
</div>
|
||||
<button id="connect-btn" onclick="connectUnit()">Connect</button>
|
||||
<button id="load-btn" onclick="loadWaveform()" disabled>Load Waveform</button>
|
||||
<button id="prev-btn" onclick="stepEvent(-1)" disabled>◀ Prev</button>
|
||||
<button id="next-btn" onclick="stepEvent(+1)" disabled>Next ▶</button>
|
||||
</header>
|
||||
|
||||
<!-- Unit info bar — hidden until connected -->
|
||||
<div id="unit-bar" style="display:none">
|
||||
<div class="unit-field">
|
||||
<span class="uf-label">Serial</span>
|
||||
<span class="uf-value" id="u-serial">—</span>
|
||||
</div>
|
||||
<div class="unit-field">
|
||||
<span class="uf-label">Firmware</span>
|
||||
<span class="uf-value" id="u-fw">—</span>
|
||||
</div>
|
||||
<div class="unit-field">
|
||||
<span class="uf-label">Sample rate</span>
|
||||
<span class="uf-value" id="u-sr">—</span>
|
||||
</div>
|
||||
<div class="unit-field">
|
||||
<span class="uf-label">Events</span>
|
||||
<span class="uf-value highlight" id="u-count">—</span>
|
||||
</div>
|
||||
<div class="event-chips" id="event-chips"></div>
|
||||
</div>
|
||||
|
||||
<div id="status-bar">Ready — enter device host and click Connect.</div>
|
||||
|
||||
<div id="empty-state">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
<p>No waveform loaded</p>
|
||||
</div>
|
||||
|
||||
<div id="charts" style="display:none"></div>
|
||||
|
||||
<script>
|
||||
const CHANNEL_COLORS = {
|
||||
Tran: '#58a6ff',
|
||||
Vert: '#3fb950',
|
||||
Long: '#d29922',
|
||||
Mic: '#bc8cff',
|
||||
};
|
||||
|
||||
let charts = {};
|
||||
let lastData = null;
|
||||
let unitInfo = null;
|
||||
let geoAdcScale = 10.0; // in/s full-scale for geo channels; updated on connect
|
||||
let eventList = []; // populated from /device/events after connect
|
||||
let currentEventIndex = 0;
|
||||
|
||||
function setStatus(msg, cls = '') {
|
||||
const bar = document.getElementById('status-bar');
|
||||
bar.textContent = msg;
|
||||
bar.className = cls;
|
||||
}
|
||||
|
||||
function appendMeta(label, value) {
|
||||
const bar = document.getElementById('status-bar');
|
||||
const pill = document.createElement('span');
|
||||
pill.className = 'meta-pill';
|
||||
pill.textContent = `${label}: ${value}`;
|
||||
bar.appendChild(pill);
|
||||
}
|
||||
|
||||
async function connectUnit() {
|
||||
const apiBase = document.getElementById('api-base').value.replace(/\/$/, '');
|
||||
const devHost = document.getElementById('dev-host').value.trim();
|
||||
const tcpPort = document.getElementById('dev-tcp-port').value;
|
||||
|
||||
if (!devHost) { setStatus('Enter a device host first.', 'error'); return; }
|
||||
|
||||
const btn = document.getElementById('connect-btn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Connecting…';
|
||||
setStatus('Connecting to unit…', 'loading');
|
||||
|
||||
const url = `${apiBase}/device/info?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
|
||||
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);
|
||||
}
|
||||
unitInfo = await resp.json();
|
||||
geoAdcScale = unitInfo.compliance_config?.geo_adc_scale ?? 10.0;
|
||||
} catch (e) {
|
||||
setStatus(`Error: ${e.message}`, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Connect';
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate unit bar from /device/info
|
||||
document.getElementById('u-serial').textContent = unitInfo.serial || '—';
|
||||
document.getElementById('u-fw').textContent = unitInfo.firmware_version || '—';
|
||||
const sr = unitInfo.compliance_config?.sample_rate;
|
||||
document.getElementById('u-sr').textContent = sr ? `${sr} sps` : '—';
|
||||
|
||||
// Fetch real event list from /device/events — SUB 08 count is unreliable
|
||||
setStatus('Fetching event list…', 'loading');
|
||||
const eventsUrl = `${apiBase}/device/events?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
|
||||
try {
|
||||
const evResp = await fetch(eventsUrl);
|
||||
if (!evResp.ok) {
|
||||
const err = await evResp.json().catch(() => ({ detail: evResp.statusText }));
|
||||
throw new Error(err.detail || evResp.statusText);
|
||||
}
|
||||
const evData = await evResp.json();
|
||||
eventList = evData.events || [];
|
||||
} catch (e) {
|
||||
setStatus(`Error fetching events: ${e.message}`, 'error');
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Reconnect';
|
||||
return;
|
||||
}
|
||||
|
||||
const count = eventList.length;
|
||||
document.getElementById('u-count').textContent = count;
|
||||
|
||||
// Build event chips with timestamps
|
||||
const chipsEl = document.getElementById('event-chips');
|
||||
chipsEl.innerHTML = '';
|
||||
eventList.forEach((ev, i) => {
|
||||
const chip = document.createElement('button');
|
||||
chip.className = 'event-chip' + (i === 0 ? ' active' : '');
|
||||
const label = ev.timestamp?.display ?? `Event ${ev.index}`;
|
||||
chip.textContent = label;
|
||||
chip.title = ev.record_type || '';
|
||||
chip.onclick = () => selectEvent(i);
|
||||
chipsEl.appendChild(chip);
|
||||
});
|
||||
|
||||
document.getElementById('unit-bar').style.display = 'flex';
|
||||
document.getElementById('load-btn').disabled = count === 0;
|
||||
document.getElementById('prev-btn').disabled = true;
|
||||
document.getElementById('next-btn').disabled = count <= 1;
|
||||
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Reconnect';
|
||||
|
||||
if (count === 0) {
|
||||
setStatus('Connected — no events stored on device.', 'ok');
|
||||
} else {
|
||||
setStatus(`Connected — ${count} event${count !== 1 ? 's' : ''} stored. Select an event or click Load Waveform.`, 'ok');
|
||||
}
|
||||
}
|
||||
|
||||
function selectEvent(idx) {
|
||||
currentEventIndex = idx;
|
||||
// Update chip highlight
|
||||
document.querySelectorAll('.event-chip').forEach((c, i) => {
|
||||
c.classList.toggle('active', i === idx);
|
||||
});
|
||||
document.getElementById('prev-btn').disabled = idx <= 0;
|
||||
document.getElementById('next-btn').disabled = idx >= eventList.length - 1;
|
||||
loadWaveform();
|
||||
}
|
||||
|
||||
async function loadWaveform() {
|
||||
const apiBase = document.getElementById('api-base').value.replace(/\/$/, '');
|
||||
const devHost = document.getElementById('dev-host').value.trim();
|
||||
const tcpPort = document.getElementById('dev-tcp-port').value;
|
||||
const evIndex = currentEventIndex;
|
||||
|
||||
if (!devHost) { setStatus('Enter a device host first.', 'error'); return; }
|
||||
|
||||
const btn = document.getElementById('load-btn');
|
||||
btn.disabled = true;
|
||||
setStatus('Fetching waveform…', 'loading');
|
||||
|
||||
const url = `${apiBase}/device/event/${evIndex}/waveform?host=${encodeURIComponent(devHost)}&tcp_port=${tcpPort}`;
|
||||
|
||||
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');
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
lastData = data;
|
||||
renderWaveform(data);
|
||||
btn.disabled = false;
|
||||
}
|
||||
|
||||
function stepEvent(delta) {
|
||||
const count = eventList.length;
|
||||
const next = Math.max(0, Math.min(count - 1, currentEventIndex + delta));
|
||||
selectEvent(next);
|
||||
}
|
||||
|
||||
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;
|
||||
const channels = data.channels || {};
|
||||
const recType = data.record_type || 'Unknown';
|
||||
|
||||
// Status bar
|
||||
const bar = document.getElementById('status-bar');
|
||||
bar.innerHTML = '';
|
||||
bar.className = 'ok';
|
||||
const ts = data.timestamp;
|
||||
if (ts) {
|
||||
bar.textContent = `Event #${data.index} — ${ts.display} `;
|
||||
} else {
|
||||
bar.textContent = `Event #${data.index} `;
|
||||
}
|
||||
appendMeta('type', recType);
|
||||
appendMeta('sr', `${sr} sps`);
|
||||
appendMeta('samples', `${decoded.toLocaleString()} / ${total.toLocaleString()}`);
|
||||
appendMeta('pretrig', pretrig);
|
||||
appendMeta('rectime', `${data.rectime_seconds ?? '?'}s`);
|
||||
|
||||
// No waveform data — show a clear reason instead of empty charts
|
||||
if (decoded === 0) {
|
||||
document.getElementById('empty-state').style.display = 'flex';
|
||||
document.getElementById('empty-state').querySelector('p').textContent =
|
||||
recType === 'Waveform'
|
||||
? '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';
|
||||
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)
|
||||
);
|
||||
|
||||
// Show charts area
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
const chartsDiv = document.getElementById('charts');
|
||||
chartsDiv.style.display = 'flex';
|
||||
chartsDiv.innerHTML = '';
|
||||
|
||||
// Destroy old Chart instances
|
||||
Object.values(charts).forEach(c => c.destroy());
|
||||
charts = {};
|
||||
|
||||
// Mic peak PSI from 0C waveform record — used to scale raw mic counts
|
||||
const micPeakPsi = data.peak_values?.micl_psi ?? null;
|
||||
const DBL_REF_PSI = 2.9e-9; // 20 µPa in psi
|
||||
|
||||
for (const [ch, color] of Object.entries(CHANNEL_COLORS)) {
|
||||
const samples = channels[ch];
|
||||
if (!samples || samples.length === 0) continue;
|
||||
|
||||
// Convert raw ADC counts to physical units
|
||||
const isGeo = ch !== 'Mic';
|
||||
let plotSamples, peakLabel, yUnit, tooltipFmt, tickFmt;
|
||||
|
||||
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));
|
||||
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 micScale = (micPeakPsi !== null && peakCounts > 0)
|
||||
? Math.abs(micPeakPsi) / peakCounts
|
||||
: 1.0;
|
||||
plotSamples = samples.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)`;
|
||||
yUnit = 'psi';
|
||||
tooltipFmt = v => `${ch}: ${v.toExponential(3)} psi`;
|
||||
tickFmt = v => v.toExponential(1);
|
||||
}
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chart-wrap';
|
||||
|
||||
const lbl = document.createElement('div');
|
||||
lbl.className = `chart-label ch-${ch.toLowerCase()}`;
|
||||
lbl.textContent = `${ch} — peak ${peakLabel}`;
|
||||
wrap.appendChild(lbl);
|
||||
|
||||
const canvasWrap = document.createElement('div');
|
||||
canvasWrap.className = 'chart-canvas-wrap';
|
||||
const canvas = document.createElement('canvas');
|
||||
canvasWrap.appendChild(canvas);
|
||||
wrap.appendChild(canvasWrap);
|
||||
chartsDiv.appendChild(wrap);
|
||||
|
||||
// Downsample for rendering if very long (keep chart responsive)
|
||||
const MAX_POINTS = 4000;
|
||||
let renderTimes = times;
|
||||
let renderData = plotSamples;
|
||||
if (plotSamples.length > MAX_POINTS) {
|
||||
const step = Math.ceil(plotSamples.length / MAX_POINTS);
|
||||
renderTimes = times.filter((_, i) => i % step === 0);
|
||||
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,
|
||||
}],
|
||||
},
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect API base from wherever this page was served from
|
||||
document.getElementById('api-base').value = window.location.origin;
|
||||
|
||||
// Allow Enter key on connection inputs to trigger connect
|
||||
['api-base', 'dev-host', 'dev-tcp-port'].forEach(id => {
|
||||
document.getElementById(id).addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') connectUnit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,436 @@
|
||||
"""
|
||||
test_write_frames.py — Verify write frame construction against BW capture.
|
||||
|
||||
Validates that build_bw_write_frame() reproduces the exact wire bytes that
|
||||
Blastware sent during the 3-11-26/170151 compliance-config write session.
|
||||
|
||||
Frames tested (BW TX frame indices 102–112):
|
||||
102 — SUB 0x68 event index write
|
||||
103 — SUB 0x73 confirm B
|
||||
104 — SUB 0x71 compliance write chunk 1
|
||||
105 — SUB 0x71 compliance write chunk 2
|
||||
106 — SUB 0x71 compliance write chunk 3
|
||||
107 — SUB 0x72 confirm A
|
||||
108 — SUB 0x82 trigger config write
|
||||
109 — SUB 0x83 trigger confirm
|
||||
110 — SUB 0x69 waveform data write
|
||||
111 — SUB 0x74 confirm C
|
||||
112 — SUB 0x72 confirm A (end of sequence)
|
||||
|
||||
Run:
|
||||
python -m pytest tests/test_write_frames.py -v
|
||||
or:
|
||||
python tests/test_write_frames.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Allow running from the project root without installation
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from minimateplus.framing import build_bw_write_frame
|
||||
|
||||
|
||||
# ── Capture loading ────────────────────────────────────────────────────────────
|
||||
|
||||
CAPTURE_PATH = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"bridges",
|
||||
"captures",
|
||||
"3-11-26",
|
||||
"raw_bw_20260311_170151.bin",
|
||||
)
|
||||
|
||||
|
||||
def _load_bw_frames(path: str) -> list[bytes]:
|
||||
"""
|
||||
Parse a raw BW capture file into a list of BW frames.
|
||||
|
||||
BW frames start with ACK=0x41 followed by STX=0x02. The frame boundary is
|
||||
the position of the NEXT 0x41 0x02 sequence (the ETX=0x03 terminator is the
|
||||
last byte before the next frame start).
|
||||
|
||||
NOTE: A naive scan for ETX=0x03 fails because 0x03 can appear inside the
|
||||
DLE-stuffed payload. This parser uses consecutive 0x41 0x02 starts as
|
||||
boundaries, which is safe because the ACK byte (0x41) is never DLE-stuffed.
|
||||
"""
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
|
||||
boundaries: list[int] = []
|
||||
i = 0
|
||||
while i < len(raw) - 1:
|
||||
if raw[i] == 0x41 and raw[i + 1] == 0x02:
|
||||
boundaries.append(i)
|
||||
i += 1
|
||||
boundaries.append(len(raw))
|
||||
|
||||
frames = []
|
||||
for k in range(len(boundaries) - 1):
|
||||
frames.append(raw[boundaries[k] : boundaries[k + 1]])
|
||||
return frames
|
||||
|
||||
|
||||
def _destuff(data: bytes) -> bytes:
|
||||
"""Undo DLE stuffing: replace every 0x10 0x10 pair with a single 0x10."""
|
||||
result = bytearray()
|
||||
k = 0
|
||||
while k < len(data):
|
||||
if data[k] == 0x10 and k + 1 < len(data) and data[k + 1] == 0x10:
|
||||
result.append(0x10)
|
||||
k += 2
|
||||
else:
|
||||
result.append(data[k])
|
||||
k += 1
|
||||
return bytes(result)
|
||||
|
||||
|
||||
def _decode_bw_frame(wire: bytes) -> tuple[int, int, bytes, bytes, int]:
|
||||
"""
|
||||
Decode a BW wire frame into its components.
|
||||
|
||||
Returns:
|
||||
(sub, offset, params, data, chk)
|
||||
sub — SUB byte (payload[2])
|
||||
offset — uint16 from payload[4:6]
|
||||
params — 10-byte params field (payload[6:16])
|
||||
data — write payload bytes (payload[16:-1])
|
||||
chk — checksum byte (payload[-1])
|
||||
"""
|
||||
inner = wire[2:-1] # strip ACK+STX and trailing ETX
|
||||
payload = _destuff(inner)
|
||||
sub = payload[2]
|
||||
offset = (payload[4] << 8) | payload[5]
|
||||
params = payload[6:16]
|
||||
data = payload[16:-1]
|
||||
chk = payload[-1]
|
||||
return sub, offset, params, data, chk
|
||||
|
||||
|
||||
# ── Test fixtures ──────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def bw_frames() -> list[bytes]:
|
||||
if not os.path.exists(CAPTURE_PATH):
|
||||
pytest.skip(f"Capture file not found: {CAPTURE_PATH}")
|
||||
return _load_bw_frames(CAPTURE_PATH)
|
||||
|
||||
|
||||
# ── Individual frame tests ─────────────────────────────────────────────────────
|
||||
|
||||
class TestWriteFrameReconstruction:
|
||||
"""Verify build_bw_write_frame() reproduces the exact wire bytes from the capture."""
|
||||
|
||||
def test_frame_102_event_index_write_sub68(self, bw_frames: list[bytes]) -> None:
|
||||
"""SUB 0x68 — event index write (frame 102)."""
|
||||
cap_wire = bw_frames[102]
|
||||
sub_cap, offset_cap, params_cap, data_cap, chk_cap = _decode_bw_frame(cap_wire)
|
||||
|
||||
assert sub_cap == 0x68
|
||||
assert params_cap == bytes(10)
|
||||
|
||||
# Reconstruct using build_bw_write_frame with the same data and offset
|
||||
built = build_bw_write_frame(0x68, data_cap, offset=offset_cap, params=params_cap)
|
||||
assert built == cap_wire, (
|
||||
f"SUB 0x68 wire mismatch\n"
|
||||
f" built: {built.hex()}\n"
|
||||
f" capt: {cap_wire.hex()}"
|
||||
)
|
||||
|
||||
def test_frame_103_confirm_b_sub73(self, bw_frames: list[bytes]) -> None:
|
||||
"""SUB 0x73 — confirm B (zero-data confirm frame 103)."""
|
||||
cap_wire = bw_frames[103]
|
||||
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
|
||||
|
||||
assert sub_cap == 0x73
|
||||
assert data_cap == b""
|
||||
assert offset_cap == 0x0000
|
||||
|
||||
built = build_bw_write_frame(0x73, b"")
|
||||
assert built == cap_wire
|
||||
|
||||
def test_frame_104_compliance_chunk1_sub71(self, bw_frames: list[bytes]) -> None:
|
||||
"""SUB 0x71 chunk 1 — 1027-byte compliance write (frame 104)."""
|
||||
cap_wire = bw_frames[104]
|
||||
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
|
||||
|
||||
assert sub_cap == 0x71
|
||||
assert offset_cap == 0x1004
|
||||
assert params_cap == bytes(10)
|
||||
assert len(data_cap) == 1027
|
||||
|
||||
built = build_bw_write_frame(
|
||||
0x71, data_cap,
|
||||
offset=0x1004,
|
||||
params=bytes(10),
|
||||
)
|
||||
assert built == cap_wire
|
||||
|
||||
def test_frame_105_compliance_chunk2_sub71(self, bw_frames: list[bytes]) -> None:
|
||||
"""SUB 0x71 chunk 2 — 1055-byte compliance write (frame 105)."""
|
||||
cap_wire = bw_frames[105]
|
||||
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
|
||||
|
||||
_CHUNK2_PARAMS = bytes([0x00, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00])
|
||||
|
||||
assert sub_cap == 0x71
|
||||
assert offset_cap == 0x1004
|
||||
assert params_cap == _CHUNK2_PARAMS
|
||||
assert len(data_cap) == 1055
|
||||
|
||||
built = build_bw_write_frame(
|
||||
0x71, data_cap,
|
||||
offset=0x1004,
|
||||
params=_CHUNK2_PARAMS,
|
||||
)
|
||||
assert built == cap_wire
|
||||
|
||||
def test_frame_106_compliance_chunk3_sub71(self, bw_frames: list[bytes]) -> None:
|
||||
"""SUB 0x71 chunk 3 — 46-byte compliance write (frame 106)."""
|
||||
cap_wire = bw_frames[106]
|
||||
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
|
||||
|
||||
_CHUNK3_PARAMS = bytes([0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
|
||||
|
||||
assert sub_cap == 0x71
|
||||
assert offset_cap == 0x002C
|
||||
assert params_cap == _CHUNK3_PARAMS
|
||||
assert len(data_cap) == 46
|
||||
|
||||
built = build_bw_write_frame(
|
||||
0x71, data_cap,
|
||||
offset=0x002C,
|
||||
params=_CHUNK3_PARAMS,
|
||||
)
|
||||
assert built == cap_wire
|
||||
|
||||
def test_frame_107_confirm_a_sub72(self, bw_frames: list[bytes]) -> None:
|
||||
"""SUB 0x72 — confirm A after compliance write (frame 107)."""
|
||||
cap_wire = bw_frames[107]
|
||||
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
|
||||
|
||||
assert sub_cap == 0x72
|
||||
assert data_cap == b""
|
||||
assert offset_cap == 0x0000
|
||||
|
||||
built = build_bw_write_frame(0x72, b"")
|
||||
assert built == cap_wire
|
||||
|
||||
def test_frame_108_trigger_config_write_sub82(self, bw_frames: list[bytes]) -> None:
|
||||
"""SUB 0x82 — trigger config write (frame 108)."""
|
||||
cap_wire = bw_frames[108]
|
||||
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
|
||||
|
||||
assert sub_cap == 0x82
|
||||
assert params_cap == bytes(10)
|
||||
assert len(data_cap) == 29
|
||||
|
||||
# Verify offset formula: data[1] + 2
|
||||
assert offset_cap == data_cap[1] + 2, (
|
||||
f"Trigger write offset formula mismatch: "
|
||||
f"data[1]={data_cap[1]} → expected {data_cap[1]+2}, got {offset_cap}"
|
||||
)
|
||||
|
||||
built = build_bw_write_frame(
|
||||
0x82, data_cap,
|
||||
offset=offset_cap,
|
||||
params=params_cap,
|
||||
)
|
||||
assert built == cap_wire
|
||||
|
||||
def test_frame_109_trigger_confirm_sub83(self, bw_frames: list[bytes]) -> None:
|
||||
"""SUB 0x83 — trigger confirm (frame 109)."""
|
||||
cap_wire = bw_frames[109]
|
||||
sub_cap, _, _, data_cap, _ = _decode_bw_frame(cap_wire)
|
||||
|
||||
assert sub_cap == 0x83
|
||||
assert data_cap == b""
|
||||
|
||||
built = build_bw_write_frame(0x83, b"")
|
||||
assert built == cap_wire
|
||||
|
||||
def test_frame_110_waveform_data_write_sub69(self, bw_frames: list[bytes]) -> None:
|
||||
"""SUB 0x69 — waveform data write (frame 110)."""
|
||||
cap_wire = bw_frames[110]
|
||||
sub_cap, offset_cap, params_cap, data_cap, _ = _decode_bw_frame(cap_wire)
|
||||
|
||||
assert sub_cap == 0x69
|
||||
assert params_cap == bytes(10)
|
||||
assert len(data_cap) == 204
|
||||
|
||||
# Verify offset formula: data[1] + 2
|
||||
assert offset_cap == data_cap[1] + 2, (
|
||||
f"Waveform write offset formula mismatch: "
|
||||
f"data[1]={data_cap[1]} → expected {data_cap[1]+2}, got {offset_cap}"
|
||||
)
|
||||
|
||||
built = build_bw_write_frame(
|
||||
0x69, data_cap,
|
||||
offset=offset_cap,
|
||||
params=params_cap,
|
||||
)
|
||||
assert built == cap_wire
|
||||
|
||||
def test_frame_111_confirm_c_sub74(self, bw_frames: list[bytes]) -> None:
|
||||
"""SUB 0x74 — confirm C after waveform data write (frame 111)."""
|
||||
cap_wire = bw_frames[111]
|
||||
sub_cap, _, _, data_cap, _ = _decode_bw_frame(cap_wire)
|
||||
|
||||
assert sub_cap == 0x74
|
||||
assert data_cap == b""
|
||||
|
||||
built = build_bw_write_frame(0x74, b"")
|
||||
assert built == cap_wire
|
||||
|
||||
def test_frame_112_confirm_a_sub72_end(self, bw_frames: list[bytes]) -> None:
|
||||
"""SUB 0x72 — final confirm A at end of write sequence (frame 112)."""
|
||||
cap_wire = bw_frames[112]
|
||||
sub_cap, _, _, data_cap, _ = _decode_bw_frame(cap_wire)
|
||||
|
||||
assert sub_cap == 0x72
|
||||
assert data_cap == b""
|
||||
|
||||
built = build_bw_write_frame(0x72, b"")
|
||||
assert built == cap_wire
|
||||
|
||||
|
||||
class TestOffsetFormula:
|
||||
"""Verify the offset = data[1] + 2 formula for single-chunk write commands."""
|
||||
|
||||
def test_event_index_offset_formula(self, bw_frames: list[bytes]) -> None:
|
||||
"""Frame 102 (SUB 0x68): offset = data[1] + 2."""
|
||||
_, offset_cap, _, data_cap, _ = _decode_bw_frame(bw_frames[102])
|
||||
assert offset_cap == data_cap[1] + 2
|
||||
|
||||
def test_trigger_config_offset_formula(self, bw_frames: list[bytes]) -> None:
|
||||
"""Frame 108 (SUB 0x82): offset = data[1] + 2."""
|
||||
_, offset_cap, _, data_cap, _ = _decode_bw_frame(bw_frames[108])
|
||||
assert offset_cap == data_cap[1] + 2
|
||||
|
||||
def test_waveform_data_offset_formula(self, bw_frames: list[bytes]) -> None:
|
||||
"""Frame 110 (SUB 0x69): offset = data[1] + 2."""
|
||||
_, offset_cap, _, data_cap, _ = _decode_bw_frame(bw_frames[110])
|
||||
assert offset_cap == data_cap[1] + 2
|
||||
|
||||
|
||||
class TestChecksumVerification:
|
||||
"""Verify large-frame DLE-aware checksum for all write frames."""
|
||||
|
||||
def _verify_checksum(self, wire: bytes, label: str) -> None:
|
||||
inner = wire[2:-1]
|
||||
payload = _destuff(inner)
|
||||
chk = payload[-1]
|
||||
computed = (sum(b for b in payload[2:-1] if b != 0x10) + 0x10) & 0xFF
|
||||
assert computed == chk, (
|
||||
f"{label}: checksum mismatch — computed=0x{computed:02X}, got=0x{chk:02X}"
|
||||
)
|
||||
|
||||
def test_all_write_frame_checksums(self, bw_frames: list[bytes]) -> None:
|
||||
write_frames = {
|
||||
102: "SUB 0x68 event index write",
|
||||
103: "SUB 0x73 confirm B",
|
||||
104: "SUB 0x71 compliance chunk 1",
|
||||
105: "SUB 0x71 compliance chunk 2",
|
||||
106: "SUB 0x71 compliance chunk 3",
|
||||
107: "SUB 0x72 confirm A",
|
||||
108: "SUB 0x82 trigger config write",
|
||||
109: "SUB 0x83 trigger confirm",
|
||||
110: "SUB 0x69 waveform data write",
|
||||
111: "SUB 0x74 confirm C",
|
||||
112: "SUB 0x72 confirm A (end)",
|
||||
}
|
||||
for idx, label in write_frames.items():
|
||||
self._verify_checksum(bw_frames[idx], f"Frame {idx} ({label})")
|
||||
|
||||
|
||||
class TestComplianceChunkSizes:
|
||||
"""Verify compliance write chunk sizes and sequence."""
|
||||
|
||||
def test_chunk1_size(self, bw_frames: list[bytes]) -> None:
|
||||
_, _, _, data, _ = _decode_bw_frame(bw_frames[104])
|
||||
assert len(data) == 1027, f"Chunk 1 should be 1027 bytes, got {len(data)}"
|
||||
|
||||
def test_chunk2_size(self, bw_frames: list[bytes]) -> None:
|
||||
_, _, _, data, _ = _decode_bw_frame(bw_frames[105])
|
||||
assert len(data) == 1055, f"Chunk 2 should be 1055 bytes, got {len(data)}"
|
||||
|
||||
def test_chunk3_size(self, bw_frames: list[bytes]) -> None:
|
||||
_, _, _, data, _ = _decode_bw_frame(bw_frames[106])
|
||||
assert len(data) == 46, f"Chunk 3 should be 46 bytes, got {len(data)}"
|
||||
|
||||
def test_total_compliance_data(self, bw_frames: list[bytes]) -> None:
|
||||
total = sum(
|
||||
len(_decode_bw_frame(bw_frames[i])[3]) for i in [104, 105, 106]
|
||||
)
|
||||
assert total == 2128, f"Total compliance write data should be 2128 bytes, got {total}"
|
||||
|
||||
def test_chunk1_params(self, bw_frames: list[bytes]) -> None:
|
||||
_, _, params, _, _ = _decode_bw_frame(bw_frames[104])
|
||||
assert params == bytes(10)
|
||||
|
||||
def test_chunk2_params(self, bw_frames: list[bytes]) -> None:
|
||||
_, _, params, _, _ = _decode_bw_frame(bw_frames[105])
|
||||
assert params == bytes([0x00, 0x00, 0x00, 0x10, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00])
|
||||
|
||||
def test_chunk3_params(self, bw_frames: list[bytes]) -> None:
|
||||
_, _, params, _, _ = _decode_bw_frame(bw_frames[106])
|
||||
assert params == bytes([0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])
|
||||
|
||||
def test_chunk1_offset(self, bw_frames: list[bytes]) -> None:
|
||||
_, offset, _, _, _ = _decode_bw_frame(bw_frames[104])
|
||||
assert offset == 0x1004
|
||||
|
||||
def test_chunk2_offset(self, bw_frames: list[bytes]) -> None:
|
||||
_, offset, _, _, _ = _decode_bw_frame(bw_frames[105])
|
||||
assert offset == 0x1004
|
||||
|
||||
def test_chunk3_offset(self, bw_frames: list[bytes]) -> None:
|
||||
_, offset, _, _, _ = _decode_bw_frame(bw_frames[106])
|
||||
assert offset == 0x002C
|
||||
|
||||
|
||||
# ── Standalone runner ──────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not os.path.exists(CAPTURE_PATH):
|
||||
print(f"ERROR: Capture file not found: {CAPTURE_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
frames = _load_bw_frames(CAPTURE_PATH)
|
||||
print(f"Loaded {len(frames)} BW frames from capture")
|
||||
|
||||
write_frame_indices = list(range(102, 113))
|
||||
all_pass = True
|
||||
print()
|
||||
print(f"{'Frame':>6} {'SUB':>5} {'Offset':>8} {'DataLen':>8} {'Chk OK':>7} {'Rebuilt':>8}")
|
||||
print("-" * 60)
|
||||
for idx in write_frame_indices:
|
||||
wire = frames[idx]
|
||||
sub, offset, params, data, chk = _decode_bw_frame(wire)
|
||||
payload = _destuff(wire[2:-1])
|
||||
computed = (sum(b for b in payload[2:-1] if b != 0x10) + 0x10) & 0xFF
|
||||
chk_ok = computed == chk
|
||||
|
||||
built = build_bw_write_frame(sub, data, offset=offset, params=params)
|
||||
rebuilt_ok = built == wire
|
||||
|
||||
status = "✅" if (chk_ok and rebuilt_ok) else "❌"
|
||||
print(
|
||||
f" {idx:4d} 0x{sub:02X} 0x{offset:04X} {len(data):8d} "
|
||||
f"{'✅' if chk_ok else '❌':>7} {'✅' if rebuilt_ok else '❌':>8} {status}"
|
||||
)
|
||||
if not (chk_ok and rebuilt_ok):
|
||||
all_pass = False
|
||||
|
||||
print()
|
||||
if all_pass:
|
||||
print("All 11 write frames verified ✅")
|
||||
else:
|
||||
print("FAILURES DETECTED ❌")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user